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(); 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(); 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(); 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), }; }