'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 (
{children}
); } 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.

); } 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 */}
๐Ÿ“Š

Pulse Clock ยท Admin

e.target.value && setStartDate(e.target.value)} className="input input-bordered input-sm w-36" /> โ€“ e.target.value && setEndDate(e.target.value)} className="input input-bordered input-sm w-36" />
{/* Date presets */}
{DATE_PRESETS.map((p) => ( ))}
{/* 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{' '}
)}
{issueStats.totalErrors > 0 && ( )}
{/* 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 ( ); })}
{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 && ( )}
)}
{/* 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 && (
Issues Found
    {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 ? (
setEditTimestamps((prev) => ({ ...prev, [entry.id]: e.target.value }))} className="input input-bordered input-xs w-44" />
) : ( <> {formatTime(entry.timestamp, 'MMM d, h:mm a')} )}
{entry.isDeleted ? ( ) : ( )}
{entry.photoBase64 && !entry.isDeleted && ( Punch )}
); })} {!selectedDayEntries?.entries?.length && (

No punches recorded for this day

)}
{/* Manual Punch Form */}

Add Manual Punch

{/* Error display */} {manualPunchError && (
{manualPunchError}
)}
setManualPunchForm((prev) => (prev ? { ...prev, timestamp: e.target.value } : prev))} />
); }