Files
pulsy/src/app/admin/page.tsx
T

887 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<button
type="button"
aria-label="Close details"
className="absolute inset-0 bg-black/50 cursor-default"
onClick={onClose}
/>
<div className="bg-base-100 rounded-t-2xl sm:rounded-2xl shadow-2xl p-6 w-full max-w-lg relative z-10 max-h-[88vh] overflow-y-auto">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={onClose}>
<X className="size-4" />
</button>
{children}
</div>
</div>
);
}
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<Company[]>([]);
const [selectedCompany, setSelectedCompany] = useState<string>('');
const [employees, setEmployees] = useState<Employee[]>([]);
const [allEntries, setAllEntries] = useState<TimeEntry[]>([]);
const [startDate, setStartDate] = useState<string>(() => dateInputValue(subDays(new Date(), 13)));
const [endDate, setEndDate] = useState<string>(() => 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<Set<number>>(new Set());
const [editTimestamps, setEditTimestamps] = useState<Record<number, string>>({});
const [editingOverride, setEditingOverride] = useState<Record<number, boolean>>({});
const [showIssuesOnly, setShowIssuesOnly] = useState(false);
const [manualPunchError, setManualPunchError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSheetOpen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
useEffect(() => {
fetchApi<Company[]>('/api/companies')
.then((data) => {
setCompanies(data);
setSelectedCompany((current) => current || data[0]?.id.toString() || '');
})
.catch(console.error);
}, []);
useEffect(() => {
if (!selectedCompany) return;
fetchApi<Employee[]>(`/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<TimeEntry[]>(
`/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<number, Record<string, DayEntries>> = {};
for (const emp of employees) {
const empData: Record<string, DayEntries> = {};
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<number>();
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 (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card bg-base-100 shadow-xl p-8 w-full max-w-md text-center space-y-4">
<div className="text-5xl">📊</div>
<h1 className="text-2xl font-semibold">Admin Dashboard</h1>
<p className="text-base-content/60">No hotels yet.</p>
<Link href="/admin/manage">
<button className="btn btn-primary w-full">
<Building2 className="size-4" /> Go to Manage
</button>
</Link>
</div>
</div>
);
}
const entryDotColors: Record<string, string> = {
IN: 'bg-success',
OUT: 'bg-error',
BREAK_OUT: 'bg-warning',
BREAK_IN: 'bg-warning',
};
const entryLabels: Record<string, string> = {
IN: 'Clock In',
OUT: 'Clock Out',
BREAK_OUT: 'Break Start',
BREAK_IN: 'Break End',
};
const entryIcons: Record<string, string> = { IN: '🟢', OUT: '🔴', BREAK_OUT: '🟡', BREAK_IN: '🟡' };
return (
<div className="min-h-screen bg-base-200">
{/* Header */}
<header className="bg-base-100 border-b border-base-300 px-4 py-3 sticky top-0 z-10">
<div className="max-w-7xl mx-auto flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<span className="text-xl">📊</span>
<h1 className="font-semibold text-lg">Pulse Clock · Admin</h1>
</div>
<div className="flex items-center gap-2 flex-wrap">
<select
value={selectedCompany}
onChange={(e) => e.target.value && setSelectedCompany(e.target.value)}
className="select select-bordered select-sm w-44"
>
{companies.map((c) => (
<option key={c.id} value={c.id.toString()}>
{c.name}
</option>
))}
</select>
<input
type="date"
value={startDate}
onChange={(e) => e.target.value && setStartDate(e.target.value)}
className="input input-bordered input-sm w-36"
/>
<span className="text-sm text-base-content/40"></span>
<input
type="date"
value={endDate}
onChange={(e) => e.target.value && setEndDate(e.target.value)}
className="input input-bordered input-sm w-36"
/>
<div className="join">
<button className="btn btn-outline btn-square btn-sm join-item" onClick={() => shiftWindow(-1)}>
<ChevronLeft className="size-4" />
</button>
<button className="btn btn-outline btn-square btn-sm join-item" onClick={() => shiftWindow(1)}>
<ChevronRight className="size-4" />
</button>
</div>
<button className="btn btn-outline btn-sm" onClick={loadEntries}>
Refresh
</button>
<button
className={`btn btn-sm ${showIssuesOnly ? 'btn-warning' : 'btn-outline'}`}
onClick={() => setShowIssuesOnly((v) => !v)}
>
<Filter className="size-4" /> {showIssuesOnly ? 'Show All' : 'Issues Only'}
</button>
<Link href="/admin/payroll">
<button className="btn btn-outline btn-sm">
<Download className="size-4" /> Payroll
</button>
</Link>
<Link href="/admin/manage">
<button className="btn btn-outline btn-sm">
<Settings className="size-4" /> Manage
</button>
</Link>
</div>
</div>
{/* Date presets */}
<div className="max-w-7xl mx-auto flex gap-1 mt-2 flex-wrap">
{DATE_PRESETS.map((p) => (
<button key={p.label} className="btn btn-ghost btn-xs" onClick={() => applyPreset(p)}>
{p.label}
</button>
))}
</div>
</header>
{/* Issue Summary Banner , prominent */}
<div className="max-w-7xl mx-auto px-4 pt-4">
<div
className={`rounded-2xl p-4 flex items-center gap-4 border-2 ${
issueStats.totalErrors === 0
? 'bg-success/10 border-success/30 text-success'
: 'bg-warning/10 border-warning/40 text-warning'
}`}
>
<div className={`text-4xl ${issueStats.totalErrors === 0 ? '' : 'shrink-0'}`}>
{issueStats.totalErrors === 0 ? '✅' : <AlertTriangle className="size-8" />}
</div>
<div className="flex-1">
{issueStats.totalErrors === 0 ? (
<>
<div className="text-xl font-semibold">All Clear</div>
<div className="text-sm opacity-80">
{employees.length} employee{employees.length !== 1 ? 's' : ''} tracked · {days.length} day
{days.length !== 1 ? 's' : ''} · 0 issues found
</div>
</>
) : (
<>
<div className="text-xl font-semibold">
⚠️ {issueStats.totalErrors} Issue{issueStats.totalErrors !== 1 ? 's' : ''} Found
</div>
<div className="text-sm opacity-80">
Across {issueStats.employeeCount} employee{issueStats.employeeCount !== 1 ? 's' : ''} · Click any
flagged cell or{' '}
<button
className="underline hover:no-underline font-semibold"
onClick={() => setShowIssuesOnly(true)}
>
show issues only
</button>
</div>
</>
)}
</div>
{issueStats.totalErrors > 0 && (
<button className="btn btn-warning btn-sm" onClick={() => setShowIssuesOnly(true)}>
<Filter className="size-4" /> Show Issues
</button>
)}
</div>
</div>
{/* Time Grid */}
<div className="p-4 max-w-7xl mx-auto overflow-x-auto">
<div className="min-w-[800px]">
{/* Header row */}
<div
className="grid gap-px bg-base-300 rounded-t-lg overflow-hidden"
style={{ gridTemplateColumns: `180px repeat(${days.length}, minmax(52px, 1fr)) 96px 96px 96px` }}
>
<div className="bg-neutral text-neutral-content p-2 text-xs font-semibold flex items-center">Employee</div>
{days.map((day) => (
<div
key={dateKey(toUnix(day))}
className={`p-2 text-xs font-semibold text-center ${['Saturday', 'Sunday'].includes(formatDate(toUnix(day), 'EEEE')) ? 'bg-neutral/60 text-neutral-content/60' : 'bg-neutral text-neutral-content'}`}
>
<div>{formatDate(toUnix(day), 'EEE')}</div>
<div>{formatDate(toUnix(day), 'MM/dd')}</div>
</div>
))}
<div className="bg-neutral text-neutral-content p-2 text-xs font-semibold text-center flex items-center justify-center">
Clock Hrs
</div>
<div className="bg-neutral text-neutral-content p-2 text-xs font-semibold text-center flex items-center justify-center">
Break
</div>
<div className="bg-neutral text-neutral-content p-2 text-xs font-semibold text-center flex items-center justify-center">
Net Hrs
</div>
</div>
{/* 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 (
<div
key={emp.id}
className="grid gap-px bg-base-300"
style={{ gridTemplateColumns: `180px repeat(${days.length}, minmax(52px, 1fr)) 96px 96px 96px` }}
>
<div
className={`bg-base-100 p-2 text-sm font-medium flex items-center truncate ${hasAnyError ? 'text-warning' : ''}`}
>
<span className="truncate">{emp.name}</span>
{hasAnyError && <AlertTriangle className="size-3 ml-1 shrink-0 text-warning" />}
</div>
{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 (
<button
key={`${emp.id}-${dateStr}`}
onClick={() => 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 ? (
<div className="flex flex-col items-center leading-tight">
<span className="font-semibold">{formatHours(wMin) === '-' ? '0m' : formatHours(wMin)}</span>
{bMin > 0 && <span className="text-[9px] opacity-60">{formatHours(bMin)} brk</span>}
</div>
) : (
<span className="font-semibold">-</span>
)}
{/* Error badge */}
{hasErr && (
<span className="absolute -top-0.5 -right-0.5 size-4 bg-error text-white text-[9px] font-semibold rounded-full flex items-center justify-center shadow">
!
</span>
)}
</button>
);
})}
<div className="bg-base-100 p-2 text-xs font-semibold text-center flex items-center justify-center">
{totals.hasActivity && formatHours(totals.clockMinutes) === '-'
? '0m'
: formatHours(totals.clockMinutes)}
</div>
<div className="bg-base-100 p-2 text-xs font-semibold text-center flex items-center justify-center text-warning">
{totals.hasActivity && formatHours(totals.breakMinutes) === '-'
? '0m'
: formatHours(totals.breakMinutes)}
</div>
<div className="bg-base-100 p-2 text-xs font-semibold text-center flex items-center justify-center text-success">
{totals.hasActivity && formatHours(totals.workedMinutes) === '-'
? '0m'
: formatHours(totals.workedMinutes)}
</div>
</div>
);
})}
{visibleEmployees.length === 0 && (
<div className="bg-base-100 p-12 text-center text-base-content/40">
<div className="text-4xl mb-2">👥</div>
<p>
{showIssuesOnly
? 'No issues found for any employee in this date range! 🎉'
: 'No employees found for this hotel. Add employees in the Manage page.'}
</p>
{showIssuesOnly && (
<button className="btn btn-outline btn-sm mt-3" onClick={() => setShowIssuesOnly(false)}>
Show All Employees
</button>
)}
</div>
)}
</div>
</div>
{/* Detail Drawer */}
<Drawer open={sheetOpen} onClose={() => setSheetOpen(false)}>
<div className="flex items-center justify-between mb-1">
<h3 className="font-semibold text-lg">{selectedDayEntries?.employeeName}</h3>
<span className="badge badge-neutral badge-outline text-xs">{selectedDayEntries?.date}</span>
</div>
<p className="text-sm text-base-content/60 mb-4">
{selectedDayEntries?.entries.filter((e) => !e.isDeleted).length
? `${selectedDayEntries!.entries.filter((e) => !e.isDeleted).length} entries`
: 'No entries'}{' '}
for this day
</p>
{/* Summary strip */}
{selectedDayEntries && (
<div className="grid grid-cols-3 gap-2 mb-4 p-3 bg-base-200 rounded-lg text-center text-xs">
<div>
<div className="text-base-content/50 mb-0.5">Clock Hours</div>
<div className="font-semibold flex items-center justify-center gap-1">
<Clock className="size-3" />{' '}
{formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.clockMinutes || 0)}
</div>
</div>
<div>
<div className="text-base-content/50 mb-0.5">Break</div>
<div className="font-semibold flex items-center justify-center gap-1 text-warning">
<Coffee className="size-3" />{' '}
{formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.breakMinutes || 0)}
</div>
</div>
<div>
<div className="text-base-content/50 mb-0.5">Net Worked</div>
<div className="font-semibold flex items-center justify-center gap-1 text-success">
<Zap className="size-3" />{' '}
{formatHours(gridData[manualPunchForm.employeeId]?.[selectedDayEntries.date]?.workedMinutes || 0)}
</div>
</div>
</div>
)}
{/* Issue summary section */}
{selectedDayEntries && selectedDayEntries.errors.length > 0 && (
<div className="mb-4 p-3 bg-error/10 border border-error/20 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="size-4 text-error" />
<span className="font-semibold text-sm text-error">Issues Found</span>
</div>
<ul className="space-y-1">
{selectedDayEntries.errors.map((err) => (
<li key={err} className="text-xs text-error/80 flex items-start gap-1.5">
<span className="mt-0.5"></span>
<span>{err}</span>
</li>
))}
</ul>
</div>
)}
{/* Audit timeline */}
<div className="space-y-2 mb-4">
<h4 className="text-xs font-semibold text-base-content/40 uppercase tracking-wider">Punch Timeline</h4>
{(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 (
<div key={entry.id} className={`flex gap-3 items-start ${entry.isDeleted ? 'opacity-40' : ''}`}>
{!entry.isDeleted && (
<div className="flex flex-col items-center">
<div
className={`size-3 rounded-full border-2 border-base-100 shadow shrink-0 ${entryDotColors[entry.type]}`}
/>
{idx < activeEntries.length - 1 && idx >= 0 && (
<div className="w-0.5 flex-1 bg-base-200 min-h-[24px]" />
)}
</div>
)}
<div className={`flex-1 ${entry.isDeleted ? 'bg-base-200/50' : 'bg-base-200'} rounded-lg p-3`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm">{entryIcons[entry.type]}</span>
<span className={`font-semibold text-sm ${entry.isDeleted ? 'line-through' : ''}`}>
{entryLabels[entry.type] || entry.type}
</span>
{isEditing ? (
<div className="flex flex-col gap-1 ml-2">
<div className="flex items-center gap-1">
<input
type="datetime-local"
value={editTimestamps[entry.id] || ''}
onChange={(e) => setEditTimestamps((prev) => ({ ...prev, [entry.id]: e.target.value }))}
className="input input-bordered input-xs w-44"
/>
<button
className="btn btn-ghost btn-square btn-xs text-success"
onClick={() => saveTimestamp(entry.id, forceOv)}
>
<Check className="size-3" />
</button>
<button
className="btn btn-ghost btn-square btn-xs"
onClick={() => cancelEditing(entry.id)}
>
<X className="size-3" />
</button>
</div>
<label className="flex items-center gap-1 text-xs text-base-content/60 cursor-pointer select-none">
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={forceOv}
onChange={(e) =>
setEditingOverride((prev) => ({ ...prev, [entry.id]: e.target.checked }))
}
/>
Force override
</label>
</div>
) : (
<>
<span
className={`text-xs ${entry.isDeleted ? 'text-base-content/30' : 'text-base-content/50'} ml-1`}
>
{formatTime(entry.timestamp, 'MMM d, h:mm a')}
</span>
<button
className="btn btn-ghost btn-square btn-xs"
onClick={() => startEditingTimestamp(entry)}
>
<Pencil className="size-3 text-base-content/40" />
</button>
</>
)}
</div>
<div className="flex gap-1">
{entry.isDeleted ? (
<button className="btn btn-ghost btn-xs" onClick={() => handleRestoreEntry(entry.id)}>
<Ban className="size-4 text-base-content/40" /> Restore
</button>
) : (
<button
className="btn btn-ghost btn-xs text-error"
onClick={() => handleDeleteEntry(entry.id)}
>
<Trash2 className="size-4" />
</button>
)}
</div>
</div>
{entry.photoBase64 && !entry.isDeleted && (
<Image
src={entry.photoBase64}
alt="Punch"
width={96}
height={72}
unoptimized
className="mt-2 w-24 h-18 object-cover rounded-lg border"
/>
)}
</div>
</div>
);
})}
{!selectedDayEntries?.entries?.length && (
<p className="text-center py-4 text-base-content/40 text-sm">No punches recorded for this day</p>
)}
</div>
<div className="divider my-4" />
{/* Manual Punch Form */}
<div>
<h4 className="font-semibold text-sm mb-3 flex items-center gap-2">
<Plus className="size-4" /> Add Manual Punch
</h4>
{/* Error display */}
{manualPunchError && (
<div className="alert alert-error mb-3 text-sm py-2">
<AlertTriangle className="size-4" /> {manualPunchError}
</div>
)}
<div className="space-y-3">
<div>
<label className="label" htmlFor="manual-punch-type">
<span className="label-text text-xs">Type</span>
</label>
<select
className="select select-bordered w-full select-sm"
value={manualPunchForm?.type || ''}
id="manual-punch-type"
onChange={(e) => setManualPunchForm((prev) => (prev ? { ...prev, type: e.target.value } : prev))}
>
<option value="IN">Clock In</option>
<option value="OUT">Clock Out</option>
<option value="BREAK_OUT">Break Start</option>
<option value="BREAK_IN">Break End</option>
</select>
</div>
<div>
<label className="label" htmlFor="manual-punch-timestamp">
<span className="label-text text-xs">Date &amp; Time</span>
</label>
<input
type="datetime-local"
className="input input-bordered w-full input-sm"
id="manual-punch-timestamp"
value={manualPunchForm?.timestamp?.slice(0, 16) || ''}
onChange={(e) => setManualPunchForm((prev) => (prev ? { ...prev, timestamp: e.target.value } : prev))}
/>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={manualPunchForm?.forceOverride || false}
onChange={(e) =>
setManualPunchForm((prev) => (prev ? { ...prev, forceOverride: e.target.checked } : prev))
}
/>
<span className="text-sm">Force override (skip state machine check)</span>
</label>
<button
className="btn btn-primary w-full btn-sm"
onClick={handleAddManualPunch}
disabled={!manualPunchForm?.type || !manualPunchForm?.timestamp}
>
Add Punch
</button>
</div>
</div>
</Drawer>
</div>
);
}