- Add .gitignore, .yarnrc.yml, and initial project metadata (AGENTS.md, DESIGN.md, package.json, Yarn lock, Tailwind, PostCSS, Next.js config, TypeScript config) - Add CSV data files (users, employees, contracts, attendances) - Implement API routes for auth, employees, contracts, attendances, analytics, and file upload - Add core pages (dashboard, employees, attendances, profile, login, main layout) - Add UI components (avatar, avatar picker, badge, button, card, input, modal, select, sidebar, navigation shell, top header) - Add attendance features (table, clock widget, unified permit modal with file upload, upload API) - Add employee management (contract timeline with file upload, employee form modal, employee table) - Add dashboard widgets (leaderboard, early bird, latecomer, night owl, day status feed, date filter bar) - Add utilities (avatar generator, analytics calculations, CSV DB layer, auth context) - Add type definitions for auth, employee, contract, attendance, dashboard All changes are synchronized with documentation (AGENTS.md, DESIGN.md, walkthrough).
283 lines
9.3 KiB
TypeScript
283 lines
9.3 KiB
TypeScript
import { Employee } from '@/types/employee';
|
|
import { Attendance } from '@/types/attendance';
|
|
import {
|
|
DashboardAnalytics,
|
|
LeaderboardUser,
|
|
EarlyBirdHighlight,
|
|
LatecomerHighlight,
|
|
NightOwlHighlight,
|
|
DailyStatusCount,
|
|
} from '@/types/dashboard';
|
|
|
|
// Helper to convert HH:mm:ss into seconds from midnight
|
|
function timeToSeconds(timeStr: string | null): number | null {
|
|
if (!timeStr) return null;
|
|
const parts = timeStr.split(':').map(Number);
|
|
if (parts.length < 2 || isNaN(parts[0]) || isNaN(parts[1])) return null;
|
|
const h = parts[0];
|
|
const m = parts[1];
|
|
const s = parts[2] || 0;
|
|
return h * 3600 + m * 60 + s;
|
|
}
|
|
|
|
// Helper to convert seconds from midnight back to HH:mm
|
|
function secondsToTimeString(totalSeconds: number): string {
|
|
const h = Math.floor(totalSeconds / 3600) % 24;
|
|
const m = Math.floor((totalSeconds % 3600) / 60);
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
}
|
|
|
|
export function computeDashboardAnalytics(
|
|
employees: Employee[],
|
|
attendances: Attendance[],
|
|
dateRange: { from: string; to: string; preset?: string }
|
|
): DashboardAnalytics {
|
|
const activeEmployees = employees.filter((e) => e.status !== 'terminated' && e.status !== 'resigned');
|
|
const employeeMap = new Map<string, Employee>();
|
|
employees.forEach((e) => employeeMap.set(e.id, e));
|
|
|
|
// Filter attendances in the selected date range
|
|
const filteredAttendances = attendances.filter((a) => {
|
|
return a.date >= dateRange.from && a.date <= dateRange.to;
|
|
});
|
|
|
|
// Group attendances by employee
|
|
const empRecords = new Map<string, Attendance[]>();
|
|
filteredAttendances.forEach((a) => {
|
|
const list = empRecords.get(a.employee_id) || [];
|
|
list.push(a);
|
|
empRecords.set(a.employee_id, list);
|
|
});
|
|
|
|
// 1. Calculate Leaderboard "Si Paling Rajin"
|
|
const leaderboardCandidates: LeaderboardUser[] = activeEmployees.map((emp) => {
|
|
const records = empRecords.get(emp.id) || [];
|
|
const presentRecords = records.filter(
|
|
(r) => r.type === 'PRESENT' && (r.status === 'APPROVED' || r.status === 'CONFIRMED')
|
|
);
|
|
const onTimeRecords = presentRecords.filter((r) => (r.late_minutes || 0) === 0);
|
|
const totalLateMinutes = records.reduce((acc, r) => acc + (r.late_minutes || 0), 0);
|
|
|
|
// Calculate longest consecutive attendance streak in sorted date order
|
|
const sortedDates = Array.from(new Set(presentRecords.map((r) => r.date))).sort();
|
|
let currentStreak = 0;
|
|
let maxStreak = 0;
|
|
for (let i = 0; i < sortedDates.length; i++) {
|
|
if (i === 0) {
|
|
currentStreak = 1;
|
|
} else {
|
|
const prev = new Date(sortedDates[i - 1]);
|
|
const curr = new Date(sortedDates[i]);
|
|
const diffDays = Math.round((curr.getTime() - prev.getTime()) / (1000 * 3600 * 24));
|
|
if (diffDays === 1 || diffDays === 3) {
|
|
// allow weekend skip
|
|
currentStreak++;
|
|
} else {
|
|
currentStreak = 1;
|
|
}
|
|
}
|
|
maxStreak = Math.max(maxStreak, currentStreak);
|
|
}
|
|
|
|
const score = onTimeRecords.length * 10 + (presentRecords.length - onTimeRecords.length) * 5 - totalLateMinutes;
|
|
|
|
return {
|
|
employee: emp,
|
|
score: Math.max(0, score),
|
|
presentCount: presentRecords.length,
|
|
onTimeCount: onTimeRecords.length,
|
|
streakCount: maxStreak,
|
|
rank: 1,
|
|
};
|
|
});
|
|
|
|
// Sort descending by score, then onTimeCount, then streakCount
|
|
leaderboardCandidates.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
if (b.onTimeCount !== a.onTimeCount) return b.onTimeCount - a.onTimeCount;
|
|
return b.streakCount - a.streakCount;
|
|
});
|
|
|
|
const leaderboard: LeaderboardUser[] = leaderboardCandidates.slice(0, 5).map((u, idx) => ({
|
|
...u,
|
|
rank: idx + 1,
|
|
}));
|
|
|
|
// 2. Early Bird "Si Paling Pagi"
|
|
let earlyBird: EarlyBirdHighlight | null = null;
|
|
let minAvgClockInSeconds = Infinity;
|
|
|
|
activeEmployees.forEach((emp) => {
|
|
const records = (empRecords.get(emp.id) || []).filter(
|
|
(r) => (r.type === 'PRESENT' || r.type === 'LATE_PERMIT') && r.clock_in
|
|
);
|
|
if (records.length === 0) return;
|
|
|
|
let totalSeconds = 0;
|
|
let earliestSec = Infinity;
|
|
let earliestStr = '';
|
|
|
|
records.forEach((r) => {
|
|
const sec = timeToSeconds(r.clock_in);
|
|
if (sec !== null) {
|
|
totalSeconds += sec;
|
|
if (sec < earliestSec) {
|
|
earliestSec = sec;
|
|
earliestStr = r.clock_in!;
|
|
}
|
|
}
|
|
});
|
|
|
|
const avgSeconds = totalSeconds / records.length;
|
|
if (avgSeconds < minAvgClockInSeconds) {
|
|
minAvgClockInSeconds = avgSeconds;
|
|
earlyBird = {
|
|
employee: emp,
|
|
averageClockIn: secondsToTimeString(avgSeconds),
|
|
earliestClockIn: earliestStr.slice(0, 5),
|
|
count: records.length,
|
|
};
|
|
}
|
|
});
|
|
|
|
// 3. Chronic Snoozer "Si Paling Telat"
|
|
let latecomer: LatecomerHighlight | null = null;
|
|
let maxLateMinutes = 0;
|
|
|
|
activeEmployees.forEach((emp) => {
|
|
const records = empRecords.get(emp.id) || [];
|
|
const totalLate = records.reduce((acc, r) => acc + (r.late_minutes || 0), 0);
|
|
const lateEntries = records.filter((r) => (r.late_minutes || 0) > 0);
|
|
const maxSingleLate = Math.max(...records.map((r) => r.late_minutes || 0), 0);
|
|
|
|
if (totalLate > maxLateMinutes) {
|
|
maxLateMinutes = totalLate;
|
|
latecomer = {
|
|
employee: emp,
|
|
totalLateMinutes: totalLate,
|
|
lateCount: lateEntries.length,
|
|
latestLateMinutes: maxSingleLate,
|
|
};
|
|
}
|
|
});
|
|
|
|
// 4. Night Owl "Si Paling Pulang Malam"
|
|
let nightOwl: NightOwlHighlight | null = null;
|
|
let maxAvgClockOutSeconds = 0;
|
|
|
|
activeEmployees.forEach((emp) => {
|
|
const records = (empRecords.get(emp.id) || []).filter((r) => r.clock_out);
|
|
if (records.length === 0) return;
|
|
|
|
let totalSeconds = 0;
|
|
let latestSec = 0;
|
|
let latestStr = '';
|
|
|
|
records.forEach((r) => {
|
|
const sec = timeToSeconds(r.clock_out);
|
|
if (sec !== null) {
|
|
totalSeconds += sec;
|
|
if (sec > latestSec) {
|
|
latestSec = sec;
|
|
latestStr = r.clock_out!;
|
|
}
|
|
}
|
|
});
|
|
|
|
const avgSeconds = totalSeconds / records.length;
|
|
if (avgSeconds > maxAvgClockOutSeconds) {
|
|
maxAvgClockOutSeconds = avgSeconds;
|
|
nightOwl = {
|
|
employee: emp,
|
|
averageClockOut: secondsToTimeString(avgSeconds),
|
|
latestClockOut: latestStr.slice(0, 5),
|
|
count: records.length,
|
|
};
|
|
}
|
|
});
|
|
|
|
// 5. Daily Status Feeds (Today, Yesterday, Tomorrow)
|
|
const baseDate = new Date();
|
|
const todayStr = baseDate.toISOString().slice(0, 10);
|
|
|
|
const yesterdayDate = new Date(baseDate);
|
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
|
const yesterdayStr = yesterdayDate.toISOString().slice(0, 10);
|
|
|
|
const tomorrowDate = new Date(baseDate);
|
|
tomorrowDate.setDate(tomorrowDate.getDate() + 1);
|
|
const tomorrowStr = tomorrowDate.toISOString().slice(0, 10);
|
|
|
|
function computeDayStatus(dateStr: string): DailyStatusCount {
|
|
const dayRecords = attendances.filter((a) => a.date === dateStr);
|
|
const presentList: { employee: Employee; record: Attendance }[] = [];
|
|
const leaveList: { employee: Employee; record: Attendance }[] = [];
|
|
const sickList: { employee: Employee; record: Attendance }[] = [];
|
|
const lateList: { employee: Employee; record: Attendance }[] = [];
|
|
const travelList: { employee: Employee; record: Attendance }[] = [];
|
|
const pendingList: { employee: Employee; record: Attendance }[] = [];
|
|
|
|
const recordedEmpIds = new Set<string>();
|
|
|
|
dayRecords.forEach((rec) => {
|
|
const emp = employeeMap.get(rec.employee_id);
|
|
if (!emp) return;
|
|
recordedEmpIds.add(emp.id);
|
|
|
|
if (rec.status === 'PENDING') {
|
|
pendingList.push({ employee: emp, record: rec });
|
|
} else if (rec.type === 'PRESENT') {
|
|
presentList.push({ employee: emp, record: rec });
|
|
if ((rec.late_minutes || 0) > 0) {
|
|
lateList.push({ employee: emp, record: rec });
|
|
}
|
|
} else if (rec.type === 'ANNUAL_LEAVE' || rec.type === 'PERMIT' || rec.type === 'EARLY_LEAVE_PERMIT') {
|
|
leaveList.push({ employee: emp, record: rec });
|
|
} else if (rec.type === 'SICK') {
|
|
sickList.push({ employee: emp, record: rec });
|
|
} else if (rec.type === 'LATE_PERMIT') {
|
|
lateList.push({ employee: emp, record: rec });
|
|
} else if (rec.type === 'OFFICIAL_TRAVEL') {
|
|
travelList.push({ employee: emp, record: rec });
|
|
}
|
|
});
|
|
|
|
const unaccounted = activeEmployees.filter((e) => !recordedEmpIds.has(e.id));
|
|
const wfoCount = presentList.filter((p) => p.record.work_mode === 'WFO').length;
|
|
const wfhCount = presentList.filter((p) => p.record.work_mode === 'WFH').length;
|
|
|
|
return {
|
|
date: dateStr,
|
|
totalEmployees: activeEmployees.length,
|
|
presentCount: presentList.length,
|
|
wfoCount,
|
|
wfhCount,
|
|
leaveCount: leaveList.length,
|
|
sickCount: sickList.length,
|
|
lateCount: lateList.length,
|
|
officialTravelCount: travelList.length,
|
|
pendingCount: pendingList.length,
|
|
employees: {
|
|
present: presentList,
|
|
leave: leaveList,
|
|
sick: sickList,
|
|
late: lateList,
|
|
travel: travelList,
|
|
pending: pendingList,
|
|
unaccounted,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
dateRange,
|
|
leaderboard,
|
|
earlyBird,
|
|
latecomer,
|
|
nightOwl,
|
|
todayStatus: computeDayStatus(todayStr),
|
|
yesterdayStatus: computeDayStatus(yesterdayStr),
|
|
tomorrowStatus: computeDayStatus(tomorrowStr),
|
|
};
|
|
}
|