Initialize Eigen HRIS project

- 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).
This commit is contained in:
2026-08-27 17:41:59 +07:00
commit 16cbe65c62
65 changed files with 7804 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
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),
};
}