887 lines
36 KiB
TypeScript
887 lines
36 KiB
TypeScript
'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 & 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>
|
||
);
|
||
}
|