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:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user