'use client';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { addDays, subDays } from 'date-fns';
import Image from 'next/image';
import {
ChevronLeft,
ChevronRight,
Trash2,
Plus,
Building2,
Settings,
X,
Pencil,
Check,
Ban,
AlertTriangle,
Filter,
Download,
Clock,
Coffee,
Zap,
} from 'lucide-react';
import Link from 'next/link';
import { formatTime, formatDate, toUnix, dayRange, dateKey, fromInput } from '@/lib/time';
import { calculateDayMinutes, formatHours } from '@/lib/calculations';
import { fetchApi } from '@/lib/api';
type Company = { id: number; name: string };
type Employee = { id: number; name: string; isActive: boolean; companyId: number };
type TimeEntry = {
id: number;
employeeId: number;
companyId: number;
type: 'IN' | 'OUT' | 'BREAK_IN' | 'BREAK_OUT';
timestamp: number;
photoBase64: string | null;
isDeleted: boolean;
employeeName: string;
};
type DayEntries = {
date: string;
entries: TimeEntry[];
clockMinutes: number;
breakMinutes: number;
workedMinutes: number;
hasErrors: boolean;
errors: string[];
};
function Drawer({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
if (!open) return null;
return (
);
}
const DATE_PRESETS = [
{ label: 'Today', getDates: () => ({ start: new Date(), end: new Date() }) },
{ label: 'Yesterday', getDates: () => ({ start: subDays(new Date(), 1), end: subDays(new Date(), 1) }) },
{ label: 'Last 7 days', getDates: () => ({ start: subDays(new Date(), 6), end: new Date() }) },
{ label: 'Last 14 days', getDates: () => ({ start: subDays(new Date(), 13), end: new Date() }) },
{ label: 'Last 30 days', getDates: () => ({ start: subDays(new Date(), 29), end: new Date() }) },
];
function dateInputValue(date: Date) {
return formatDate(toUnix(date), 'yyyy-MM-dd');
}
function dateInputToDate(value: string, boundary: 'start' | 'end' = 'start') {
return new Date(`${value}${boundary === 'start' ? 'T00:00:00' : 'T23:59:59'}`);
}
export default function AdminPage() {
const [companies, setCompanies] = useState([]);
const [selectedCompany, setSelectedCompany] = useState('');
const [employees, setEmployees] = useState([]);
const [allEntries, setAllEntries] = useState([]);
const [startDate, setStartDate] = useState(() => dateInputValue(subDays(new Date(), 13)));
const [endDate, setEndDate] = useState(() => dateInputValue(new Date()));
const [selectedDayEntries, setSelectedDayEntries] = useState<{
employeeName: string;
entries: TimeEntry[];
date: string;
errors: string[];
} | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [manualPunchForm, setManualPunchForm] = useState<{
employeeId: number;
type: string;
timestamp: string;
forceOverride: boolean;
}>({ employeeId: 0, type: 'IN', timestamp: '', forceOverride: false });
const [editingEntryIds, setEditingEntryIds] = useState>(new Set());
const [editTimestamps, setEditTimestamps] = useState>({});
const [editingOverride, setEditingOverride] = useState>({});
const [showIssuesOnly, setShowIssuesOnly] = useState(false);
const [manualPunchError, setManualPunchError] = useState(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSheetOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
useEffect(() => {
fetchApi('/api/companies')
.then((data) => {
setCompanies(data);
setSelectedCompany((current) => current || data[0]?.id.toString() || '');
})
.catch(console.error);
}, []);
useEffect(() => {
if (!selectedCompany) return;
fetchApi(`/api/employees?companyId=${selectedCompany}`).then(setEmployees).catch(console.error);
}, [selectedCompany]);
const loadEntries = useCallback(async () => {
if (!selectedCompany) return;
const { start: startUnix } = dayRange(dateInputToDate(startDate));
const { end: endUnix } = dayRange(dateInputToDate(endDate, 'end'));
const contextStartUnix = startUnix - 36 * 3600;
const contextEndUnix = endUnix + 36 * 3600;
fetchApi(
`/api/time-entries?companyId=${selectedCompany}&startDate=${contextStartUnix}&endDate=${contextEndUnix}`,
)
.then(setAllEntries)
.catch(console.error);
}, [selectedCompany, startDate, endDate]);
useEffect(() => {
loadEntries();
}, [loadEntries]);
// Keep the open drawer in sync after any mutation โ grid already refreshes via
// loadEntries() -> allEntries -> gridData, but selectedDayEntries is a snapshot
// taken when the drawer opened. Without this the timeline/summary/errors stay
// frozen until you close and reopen.
useEffect(() => {
if (!sheetOpen || !selectedDayEntries) return;
const empId = manualPunchForm.employeeId;
const dateStr = selectedDayEntries.date;
const dayData = gridData[empId]?.[dateStr];
if (!dayData) return;
const prevIds = selectedDayEntries.entries.map((e) => e.id).join(',');
const nextIds = dayData.entries.map((e) => e.id).join(',');
const prevErr = selectedDayEntries.errors.join('|');
const nextErr = (dayData.errors || []).join('|');
if (prevIds !== nextIds || prevErr !== nextErr) {
setSelectedDayEntries((prev) =>
prev ? { ...prev, entries: dayData.entries, errors: dayData.errors || [] } : prev,
);
}
}, [allEntries, gridData, sheetOpen, selectedDayEntries, manualPunchForm.employeeId]);
const days = useMemo(() => {
const start = dateInputToDate(startDate);
const end = dateInputToDate(endDate);
const diff = Math.max((end.getTime() - start.getTime()) / 86400000, 0);
return Array.from({ length: diff + 1 }, (_, i) => addDays(start, i));
}, [startDate, endDate]);
const gridData = useMemo(() => {
const data: Record> = {};
for (const emp of employees) {
const empData: Record = {};
data[emp.id] = empData;
for (const day of days) {
const { start, end } = dayRange(day);
const dateStr = dateKey(toUnix(day));
const empEntries = allEntries.filter((e) => e.employeeId === emp.id);
const dayEntries = empEntries.filter(
(e) => e.employeeId === emp.id && e.timestamp >= start && e.timestamp <= end,
);
// Use the shared library , never duplicate inline
const result = calculateDayMinutes(empEntries, dateStr);
empData[dateStr] = { date: dateStr, entries: dayEntries, ...result };
}
}
return data;
}, [employees, allEntries, days]);
const employeeTotals = useMemo(() => {
const totals: Record<
number,
{ clockMinutes: number; breakMinutes: number; workedMinutes: number; hasActivity: boolean }
> = {};
for (const emp of employees) {
let clockMinutes = 0,
breakMinutes = 0,
workedMinutes = 0,
hasActivity = false;
for (const day of days) {
const dayData = gridData[emp.id]?.[dateKey(toUnix(day))];
if (dayData) {
clockMinutes += dayData.clockMinutes;
breakMinutes += dayData.breakMinutes;
workedMinutes += dayData.workedMinutes;
if (dayData.entries.length > 0) hasActivity = true;
}
}
totals[emp.id] = { clockMinutes, breakMinutes, workedMinutes, hasActivity };
}
return totals;
}, [employees, gridData, days]);
const issueStats = useMemo(() => {
let totalErrors = 0;
const employeesWithErrors = new Set();
for (const emp of employees) {
for (const day of days) {
if (gridData[emp.id]?.[dateKey(toUnix(day))]?.hasErrors) {
totalErrors++;
employeesWithErrors.add(emp.id);
}
}
}
return { totalErrors, employeeCount: employeesWithErrors.size };
}, [employees, days, gridData]);
const visibleEmployees = useMemo(() => {
if (!showIssuesOnly) return employees;
return employees.filter((emp) => days.some((day) => gridData[emp.id]?.[dateKey(toUnix(day))]?.hasErrors));
}, [employees, days, gridData, showIssuesOnly]);
const openDetailDrawer = (employee: Employee, dateStr: string) => {
const dayData = gridData[employee.id]?.[dateStr];
if (!dayData) return;
setSelectedDayEntries({
employeeName: employee.name,
entries: dayData.entries,
date: dateStr,
errors: dayData.errors || [],
});
setManualPunchForm({
employeeId: employee.id,
type: 'IN',
timestamp: new Date().toISOString().slice(0, 16),
forceOverride: false,
});
setEditingEntryIds(new Set());
setEditTimestamps({});
setEditingOverride({});
setManualPunchError(null);
setSheetOpen(true);
};
const handleDeleteEntry = async (entryId: number) => {
await fetchApi<{ entry: TimeEntry }>('/api/time-entries', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: entryId, isDeleted: true }),
});
loadEntries();
};
const handleRestoreEntry = async (entryId: number) => {
await fetchApi<{ entry: TimeEntry }>('/api/time-entries', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: entryId, isDeleted: false }),
});
loadEntries();
};
const handleAddManualPunch = async () => {
if (!manualPunchForm || !selectedCompany) return;
setManualPunchError(null);
const timestamp = fromInput(manualPunchForm.timestamp);
try {
await fetchApi<{ entry: TimeEntry }>('/api/time-entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
employeeId: manualPunchForm.employeeId,
companyId: parseInt(selectedCompany),
type: manualPunchForm.type,
timestamp,
forceOverride: manualPunchForm.forceOverride,
}),
});
setManualPunchForm((prev) => ({
...prev,
type: 'IN',
timestamp: new Date().toISOString().slice(0, 16),
forceOverride: false,
}));
loadEntries();
} catch (e: unknown) {
setManualPunchError(e instanceof Error ? e.message : 'Failed to add punch');
throw e;
}
};
const startEditingTimestamp = (entry: TimeEntry) => {
setEditingEntryIds((prev) => {
const n = new Set(prev);
n.add(entry.id);
return n;
});
setEditTimestamps((prev) => ({
...prev,
[entry.id]: `${formatDate(entry.timestamp, 'yyyy-MM-dd')}T${formatDate(entry.timestamp, 'HH:mm')}`,
}));
};
const saveTimestamp = async (entryId: number, forceOverride = false) => {
const ts = editTimestamps[entryId];
if (!ts) return;
try {
await fetchApi<{ entry: TimeEntry }>('/api/time-entries', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: entryId, timestamp: fromInput(ts), forceOverride }),
});
setEditingEntryIds((prev) => {
const n = new Set(prev);
n.delete(entryId);
return n;
});
setEditingOverride((prev) => {
const n = { ...prev };
delete n[entryId];
return n;
});
loadEntries();
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Failed to update timestamp');
throw e;
}
};
const cancelEditing = (entryId: number) => {
setEditingEntryIds((prev) => {
const n = new Set(prev);
n.delete(entryId);
return n;
});
setEditingOverride((prev) => {
const n = { ...prev };
delete n[entryId];
return n;
});
};
const applyPreset = (preset: (typeof DATE_PRESETS)[0]) => {
const { start, end } = preset.getDates();
setStartDate(dateInputValue(start));
setEndDate(dateInputValue(end));
};
const shiftWindow = (direction: 1 | -1) => {
// Shift by 1 day increments, preserving the existing date span
setStartDate(dateInputValue(addDays(dateInputToDate(startDate), direction)));
setEndDate(dateInputValue(addDays(dateInputToDate(endDate), direction)));
};
if (companies.length === 0) {
return (
๐
Admin Dashboard
No hotels yet.
Go to Manage
);
}
const entryDotColors: Record = {
IN: 'bg-success',
OUT: 'bg-error',
BREAK_OUT: 'bg-warning',
BREAK_IN: 'bg-warning',
};
const entryLabels: Record = {
IN: 'Clock In',
OUT: 'Clock Out',
BREAK_OUT: 'Break Start',
BREAK_IN: 'Break End',
};
const entryIcons: Record = { IN: '๐ข', OUT: '๐ด', BREAK_OUT: '๐ก', BREAK_IN: '๐ก' };
return (
{/* Header */}
{/* Issue Summary Banner , prominent */}
{issueStats.totalErrors === 0 ? 'โ
' :
}
{issueStats.totalErrors === 0 ? (
<>
All Clear
{employees.length} employee{employees.length !== 1 ? 's' : ''} tracked ยท {days.length} day
{days.length !== 1 ? 's' : ''} ยท 0 issues found
>
) : (
<>
โ ๏ธ {issueStats.totalErrors} Issue{issueStats.totalErrors !== 1 ? 's' : ''} Found
Across {issueStats.employeeCount} employee{issueStats.employeeCount !== 1 ? 's' : ''} ยท Click any
flagged cell or{' '}
setShowIssuesOnly(true)}
>
show issues only
>
)}
{issueStats.totalErrors > 0 && (
setShowIssuesOnly(true)}>
Show Issues
)}
{/* Time Grid */}
{/* Header row */}
Employee
{days.map((day) => (
{formatDate(toUnix(day), 'EEE')}
{formatDate(toUnix(day), 'MM/dd')}
))}
Clock Hrs
Break
Net Hrs
{/* Employee rows */}
{visibleEmployees.map((emp) => {
const totals = employeeTotals[emp.id] || {
clockMinutes: 0,
breakMinutes: 0,
workedMinutes: 0,
hasActivity: false,
};
const hasAnyError = days.some((day) => gridData[emp.id]?.[dateKey(toUnix(day))]?.hasErrors);
return (
{emp.name}
{hasAnyError &&
}
{days.map((day) => {
const dateStr = dateKey(toUnix(day));
const dayData = gridData[emp.id]?.[dateStr];
const bMin = dayData?.breakMinutes || 0;
const wMin = dayData?.workedMinutes || 0;
const hasErr = dayData?.hasErrors || false;
const hasActivity =
(dayData?.entries?.length ?? 0) > 0 ||
(dayData?.clockMinutes ?? 0) > 0 ||
(dayData?.breakMinutes ?? 0) > 0;
return (
openDetailDrawer(emp, dateStr)}
className={`bg-base-100 p-1 text-[11px] text-center hover:bg-primary/5 relative transition-colors ${
!hasActivity ? 'text-base-content/20' : hasErr ? 'bg-red-50 text-red-700' : 'text-base-content'
}`}
>
{hasActivity ? (
{formatHours(wMin) === '-' ? '0m' : formatHours(wMin)}
{bMin > 0 && {formatHours(bMin)} brk }
) : (
-
)}
{/* Error badge */}
{hasErr && (
!
)}
);
})}
{totals.hasActivity && formatHours(totals.clockMinutes) === '-'
? '0m'
: formatHours(totals.clockMinutes)}
{totals.hasActivity && formatHours(totals.breakMinutes) === '-'
? '0m'
: formatHours(totals.breakMinutes)}
{totals.hasActivity && formatHours(totals.workedMinutes) === '-'
? '0m'
: formatHours(totals.workedMinutes)}
);
})}
{visibleEmployees.length === 0 && (
๐ฅ
{showIssuesOnly
? 'No issues found for any employee in this date range! ๐'
: 'No employees found for this hotel. Add employees in the Manage page.'}
{showIssuesOnly && (
setShowIssuesOnly(false)}>
Show All Employees
)}
)}
{/* Detail Drawer */}
setSheetOpen(false)}>
{selectedDayEntries?.employeeName}
{selectedDayEntries?.date}
{selectedDayEntries?.entries.filter((e) => !e.isDeleted).length
? `${selectedDayEntries!.entries.filter((e) => !e.isDeleted).length} entries`
: 'No entries'}{' '}
for this day
{/* Summary strip */}
{selectedDayEntries && (
Clock Hours
{' '}
{formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.clockMinutes || 0)}
Break
{' '}
{formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.breakMinutes || 0)}
Net Worked
{' '}
{formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.workedMinutes || 0)}
)}
{/* Issue summary section */}
{selectedDayEntries && selectedDayEntries.errors.length > 0 && (
{selectedDayEntries.errors.map((err) => (
โข
{err}
))}
)}
{/* Audit timeline */}
Punch Timeline
{(selectedDayEntries?.entries ?? [])
.toSorted((a, b) => a.timestamp - b.timestamp)
.map((entry, i, arr) => {
const isEditing = editingEntryIds.has(entry.id);
const activeEntries = arr.filter((e) => !e.isDeleted);
const idx = activeEntries.indexOf(entry);
const forceOv = editingOverride[entry.id] || false;
return (
{!entry.isDeleted && (
{idx < activeEntries.length - 1 && idx >= 0 && (
)}
)}
{entryIcons[entry.type]}
{entryLabels[entry.type] || entry.type}
{isEditing ? (
) : (
<>
{formatTime(entry.timestamp, 'MMM d, h:mm a')}
startEditingTimestamp(entry)}
>
>
)}
{entry.isDeleted ? (
handleRestoreEntry(entry.id)}>
Restore
) : (
handleDeleteEntry(entry.id)}
>
)}
{entry.photoBase64 && !entry.isDeleted && (
)}
);
})}
{!selectedDayEntries?.entries?.length && (
No punches recorded for this day
)}
{/* Manual Punch Form */}
Add Manual Punch
{/* Error display */}
{manualPunchError && (
)}
);
}