initial: pulsy from pulse-clock-main

This commit is contained in:
Hermes
2026-08-23 00:35:51 +00:00
commit 023f0394b4
53 changed files with 11171 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Admin | Pulse Clock',
description: 'Review time cards, manage hotels and employees, and export payroll.',
};
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+432
View File
@@ -0,0 +1,432 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Building2, Users, Pencil, Trash2, X, Check, Plus, ArrowLeft, Monitor } from 'lucide-react';
import Link from 'next/link';
import { fetchApi } from '@/lib/api';
type Company = { id: number; name: string };
type Employee = { id: number; name: string; isActive: boolean; companyId: number };
export default function ManagePage() {
const [companies, setCompanies] = useState<Company[]>([]);
const [selectedCompany, setSelectedCompany] = useState<string>('');
const [employees, setEmployees] = useState<Employee[]>([]);
const [editingCompany, setEditingCompany] = useState<number | null>(null);
const [editingCompanyName, setEditingCompanyName] = useState('');
const [editingEmployee, setEditingEmployee] = useState<number | null>(null);
const [editingEmployeeName, setEditingEmployeeName] = useState('');
const [newCompanyName, setNewCompanyName] = useState('');
const [showNewCompany, setShowNewCompany] = useState(false);
const [newEmployeeName, setNewEmployeeName] = useState('');
const [showNewEmployee, setShowNewEmployee] = useState(false);
const [error, setError] = useState<string | null>(null);
const fail = (fallback: string, caught: unknown): never => {
setError(caught instanceof Error ? caught.message : fallback);
throw caught;
};
const loadCompanies = useCallback(async () => {
setCompanies(await fetchApi<Company[]>('/api/companies'));
}, []);
const loadEmployees = useCallback(async (companyId: string) => {
setEmployees(await fetchApi<Employee[]>(`/api/employees?companyId=${companyId}`));
}, []);
useEffect(() => {
loadCompanies();
}, [loadCompanies]);
useEffect(() => {
if (selectedCompany) loadEmployees(selectedCompany);
else setEmployees([]);
}, [selectedCompany, loadEmployees]);
const handleCreateCompany = async () => {
if (!newCompanyName.trim()) return;
try {
setError(null);
await fetchApi<{ company: Company }>('/api/companies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newCompanyName.trim() }),
});
setNewCompanyName('');
setShowNewCompany(false);
loadCompanies();
} catch (caught) {
fail('Failed to create hotel', caught);
}
};
const handleUpdateCompany = async (id: number) => {
if (!editingCompanyName.trim()) return;
try {
setError(null);
await fetchApi<{ company: Company }>('/api/companies', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name: editingCompanyName.trim() }),
});
setEditingCompany(null);
loadCompanies();
} catch (caught) {
fail('Failed to update hotel', caught);
}
};
const handleDeleteCompany = async (id: number) => {
if (!confirm('Delete this hotel and all its employees and time entries?')) return;
try {
setError(null);
await fetchApi<{ company: Company }>(`/api/companies?id=${id}`, { method: 'DELETE' });
if (selectedCompany === id.toString()) setSelectedCompany('');
loadCompanies();
} catch (caught) {
fail('Failed to delete hotel', caught);
}
};
const handleCreateEmployee = async () => {
if (!newEmployeeName.trim() || !selectedCompany) return;
try {
setError(null);
await fetchApi<{ employee: Employee }>('/api/employees', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ companyId: parseInt(selectedCompany), name: newEmployeeName.trim() }),
});
setNewEmployeeName('');
setShowNewEmployee(false);
loadEmployees(selectedCompany);
} catch (caught) {
fail('Failed to create employee', caught);
}
};
const handleUpdateEmployee = async (id: number) => {
if (!editingEmployeeName.trim()) return;
try {
setError(null);
await fetchApi<{ employee: Employee }>('/api/employees', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name: editingEmployeeName.trim() }),
});
setEditingEmployee(null);
loadEmployees(selectedCompany);
} catch (caught) {
fail('Failed to update employee', caught);
}
};
const handleToggleActive = async (employee: Employee) => {
try {
setError(null);
await fetchApi<{ employee: Employee }>('/api/employees', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: employee.id, isActive: !employee.isActive }),
});
loadEmployees(selectedCompany);
} catch (caught) {
fail('Failed to update employee status', caught);
}
};
const handleDeleteEmployee = async (id: number) => {
if (!confirm('Delete this employee and all their time entries?')) return;
try {
setError(null);
await fetchApi<{ employee: Employee }>(`/api/employees?id=${id}`, { method: 'DELETE' });
loadEmployees(selectedCompany);
} catch (caught) {
fail('Failed to delete employee', caught);
}
};
return (
<div className="min-h-screen bg-base-200">
<header className="bg-base-100 border-b border-base-300 px-4 py-3 sticky top-0 z-10">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xl"></span>
<h1 className="font-semibold text-lg">Manage Hotels & Employees</h1>
</div>
<div className="flex gap-2">
<Link href="/admin">
<button className="btn btn-outline btn-sm">
<ArrowLeft className="size-4" /> Back to Dashboard
</button>
</Link>
</div>
</div>
</header>
<div className="max-w-5xl mx-auto p-4 space-y-8">
{error && (
<div className="alert alert-error text-sm">
<span>{error}</span>
</div>
)}
{/* Hotels Section */}
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Building2 className="size-5" /> Hotels
</h2>
<button className="btn btn-primary btn-sm" onClick={() => setShowNewCompany(!showNewCompany)}>
<Plus className="size-4" /> Add Hotel
</button>
</div>
{showNewCompany && (
<div className="card bg-base-100 border border-base-300 p-4 mb-4">
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="label" htmlFor="new-company-name">
<span className="label-text">Hotel Name</span>
</label>
<input
id="new-company-name"
className="input input-bordered w-full"
placeholder="e.g. Hyatt Place - Airport"
value={newCompanyName}
onChange={(e) => setNewCompanyName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateCompany()}
/>
</div>
<button className="btn btn-primary" onClick={handleCreateCompany} disabled={!newCompanyName.trim()}>
Create
</button>
<button className="btn btn-ghost" onClick={() => setShowNewCompany(false)}>
Cancel
</button>
</div>
</div>
)}
<div className="overflow-x-auto rounded-box border border-base-300">
<table className="table table-zebra">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th className="text-right w-48">Actions</th>
</tr>
</thead>
<tbody>
{companies.map((c) => (
<tr key={c.id}>
<td>
{editingCompany === c.id ? (
<div className="flex gap-2 items-center">
<input
className="input input-bordered input-sm"
value={editingCompanyName}
onChange={(e) => setEditingCompanyName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleUpdateCompany(c.id)}
/>
<button
className="btn btn-ghost btn-square btn-xs text-success"
onClick={() => handleUpdateCompany(c.id)}
>
<Check className="size-4" />
</button>
<button className="btn btn-ghost btn-square btn-xs" onClick={() => setEditingCompany(null)}>
<X className="size-4" />
</button>
</div>
) : (
<span className="font-medium">{c.name}</span>
)}
</td>
<td className="text-base-content/60">#{c.id}</td>
<td className="text-right">
<div className="flex gap-1 justify-end">
<Link
href={`/kiosk/${c.id}`}
className="btn btn-ghost btn-square btn-xs text-primary"
title={`Open kiosk for ${c.name}`}
aria-label={`Open kiosk for ${c.name}`}
>
<Monitor className="size-4" />
</Link>
<button
className="btn btn-ghost btn-square btn-xs"
onClick={() => {
setEditingCompany(c.id);
setEditingCompanyName(c.name);
}}
>
<Pencil className="size-4" />
</button>
<button
className="btn btn-ghost btn-square btn-xs text-error"
onClick={() => handleDeleteCompany(c.id)}
>
<Trash2 className="size-4" />
</button>
</div>
</td>
</tr>
))}
{companies.length === 0 && (
<tr>
<td colSpan={3} className="text-center text-base-content/40 py-8">
No hotels yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<div className="divider" />
{/* Employees Section */}
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Users className="size-5" /> Employees
</h2>
<div className="flex gap-2 items-center">
<select
className="select select-bordered select-sm w-48"
value={selectedCompany}
onChange={(e) => e.target.value && setSelectedCompany(e.target.value)}
>
<option value="">Select a hotel</option>
{companies.map((c) => (
<option key={c.id} value={c.id.toString()}>
{c.name}
</option>
))}
</select>
<button
className="btn btn-primary btn-sm"
onClick={() => setShowNewEmployee(!showNewEmployee)}
disabled={!selectedCompany}
>
<Plus className="size-4" /> Add Employee
</button>
</div>
</div>
{showNewEmployee && selectedCompany && (
<div className="card bg-base-100 border border-base-300 p-4 mb-4">
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="label" htmlFor="new-employee-name">
<span className="label-text">Name</span>
</label>
<input
id="new-employee-name"
className="input input-bordered w-full"
placeholder="e.g. John Doe"
value={newEmployeeName}
onChange={(e) => setNewEmployeeName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateEmployee()}
/>
</div>
<button className="btn btn-primary" onClick={handleCreateEmployee} disabled={!newEmployeeName.trim()}>
Add
</button>
<button className="btn btn-ghost" onClick={() => setShowNewEmployee(false)}>
Cancel
</button>
</div>
</div>
)}
<div className="overflow-x-auto rounded-box border border-base-300">
<table className="table table-zebra">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th className="text-right w-40">Actions</th>
</tr>
</thead>
<tbody>
{employees.map((emp) => (
<tr key={emp.id} className={!emp.isActive ? 'opacity-50' : ''}>
<td>
{editingEmployee === emp.id ? (
<div className="flex gap-2 items-center">
<input
className="input input-bordered input-sm"
value={editingEmployeeName}
onChange={(e) => setEditingEmployeeName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleUpdateEmployee(emp.id)}
/>
<button
className="btn btn-ghost btn-square btn-xs text-success"
onClick={() => handleUpdateEmployee(emp.id)}
>
<Check className="size-4" />
</button>
<button className="btn btn-ghost btn-square btn-xs" onClick={() => setEditingEmployee(null)}>
<X className="size-4" />
</button>
</div>
) : (
<span className="font-medium">{emp.name}</span>
)}
</td>
<td>
<button
className={`badge ${emp.isActive ? 'badge-success' : 'badge-ghost'}`}
onClick={() => handleToggleActive(emp)}
>
{emp.isActive ? 'Active' : 'Inactive'}
</button>
</td>
<td className="text-right">
<div className="flex gap-1 justify-end">
<button
className="btn btn-ghost btn-square btn-xs"
onClick={() => {
setEditingEmployee(emp.id);
setEditingEmployeeName(emp.name);
}}
>
<Pencil className="size-4" />
</button>
<button
className="btn btn-ghost btn-square btn-xs text-error"
onClick={() => handleDeleteEmployee(emp.id)}
>
<Trash2 className="size-4" />
</button>
</div>
</td>
</tr>
))}
{!selectedCompany && (
<tr>
<td colSpan={3} className="text-center text-base-content/40 py-8">
Select a hotel to see employees
</td>
</tr>
)}
{selectedCompany && employees.length === 0 && (
<tr>
<td colSpan={3} className="text-center text-base-content/40 py-8">
No employees yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</div>
</div>
);
}
+865
View File
@@ -0,0 +1,865 @@
'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]);
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>
);
}
+466
View File
@@ -0,0 +1,466 @@
'use client';
import { useState, useEffect, useCallback, useMemo, Fragment } from 'react';
import { format, subDays, startOfMonth, endOfMonth, setDate } from 'date-fns';
import { ArrowLeft, Copy, Check, Download, RefreshCw, AlertTriangle } from 'lucide-react';
import Link from 'next/link';
import { fetchApi } from '@/lib/api';
import { formatHours } from '@/lib/calculations';
type Company = { id: number; name: string };
type PayrollDay = {
date: string;
inTime: string;
outTime: string;
breakMinutes: number;
clockMinutes: number;
workedMinutes: number;
hasErrors: boolean;
errors: string[];
};
type PayrollEmployee = {
id: number;
name: string;
days: PayrollDay[];
totals: { clockMinutes: number; breakMinutes: number; workedMinutes: number };
};
type PayrollData = {
company: string;
period: { startDate: string; endDate: string };
employees: PayrollEmployee[];
summary: string;
};
function fmtDecimalHours(mins: number): string {
if (mins === 0) return ',';
return (mins / 60).toFixed(2) + ' hrs';
}
// Date range presets specifically for payroll
const DATE_PRESETS = [
{
label: 'Today',
apply: () => {
const today = format(new Date(), 'yyyy-MM-dd');
return { start: today, end: today };
},
},
{
label: 'Yesterday',
apply: () => {
const y = subDays(new Date(), 1);
const d = format(y, 'yyyy-MM-dd');
return { start: d, end: d };
},
},
{
label: 'Last 7 days',
apply: () => ({
start: format(subDays(new Date(), 6), 'yyyy-MM-dd'),
end: format(new Date(), 'yyyy-MM-dd'),
}),
},
{
label: 'Last 14 days',
apply: () => ({
start: format(subDays(new Date(), 13), 'yyyy-MM-dd'),
end: format(new Date(), 'yyyy-MM-dd'),
}),
},
{
label: 'Pay Period 115',
apply: () => {
const today = new Date();
const day = today.getDate();
if (day <= 15) {
return {
start: format(setDate(today, 1), 'yyyy-MM-dd'),
end: format(setDate(today, 15), 'yyyy-MM-dd'),
};
}
return {
start: format(setDate(today, 16), 'yyyy-MM-dd'),
end: format(endOfMonth(today), 'yyyy-MM-dd'),
};
},
},
{
label: 'Pay Period 1631',
apply: () => {
const today = new Date();
const day = today.getDate();
if (day >= 16) {
return {
start: format(setDate(today, 16), 'yyyy-MM-dd'),
end: format(endOfMonth(today), 'yyyy-MM-dd'),
};
}
return {
start: format(setDate(today, 1), 'yyyy-MM-dd'),
end: format(setDate(today, 15), 'yyyy-MM-dd'),
};
},
},
{
label: 'This Month',
apply: () => ({
start: format(startOfMonth(new Date()), 'yyyy-MM-dd'),
end: format(new Date(), 'yyyy-MM-dd'),
}),
},
];
export default function PayrollPage() {
const [companies, setCompanies] = useState<Company[]>([]);
const [selectedCompany, setSelectedCompany] = useState<string>('');
const [startDate, setStartDate] = useState<string>(() => format(subDays(new Date(), 13), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState<string>(() => format(new Date(), 'yyyy-MM-dd'));
const [data, setData] = useState<PayrollData | null>(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchApi<Company[]>('/api/companies')
.then((data) => {
setCompanies(data);
setSelectedCompany((current) => current || data[0]?.id.toString() || '');
})
.catch(() => {});
}, []);
const loadPayroll = useCallback(async () => {
if (!selectedCompany || !startDate || !endDate) return;
setLoading(true);
setError(null);
try {
const result = await fetchApi<PayrollData>(
`/api/payroll?companyId=${selectedCompany}&startDate=${startDate}&endDate=${endDate}`,
);
setData(result);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load payroll data');
setData(null);
throw e;
} finally {
setLoading(false);
}
}, [selectedCompany, startDate, endDate]);
useEffect(() => {
if (selectedCompany) loadPayroll();
}, [selectedCompany, loadPayroll]);
const applyPreset = (preset: (typeof DATE_PRESETS)[0]) => {
const { start, end } = preset.apply();
setStartDate(start);
setEndDate(end);
};
const totalStats = useMemo(() => {
if (!data) return { totalDays: 0, errorDays: 0, employeeCount: 0, totalHours: 0 };
let errorDays = 0;
let totalHours = 0;
for (const emp of data.employees) {
totalHours += emp.totals.workedMinutes;
for (const day of emp.days) {
if (day.hasErrors) errorDays++;
}
}
return {
totalDays: data.employees.reduce((a, e) => a + e.days.filter((d) => d.inTime || d.outTime).length, 0),
errorDays,
employeeCount: data.employees.length,
totalHours,
};
}, [data]);
const copyTSV = async () => {
if (!data?.summary) return;
try {
await navigator.clipboard.writeText(data.summary);
} catch (caught) {
const ta = document.createElement('textarea');
ta.value = data.summary;
document.body.appendChild(ta);
ta.select();
const copied = document.execCommand('copy');
document.body.removeChild(ta);
if (!copied) throw caught;
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const downloadTSV = () => {
if (!data?.summary) return;
const blob = new Blob([data.summary], { type: 'text/tab-separated-values' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `payroll-${data.company}-${data.period.startDate}-to-${data.period.endDate}.tsv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="min-h-screen bg-base-200">
<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">Payroll Export</h1>
</div>
<Link href="/admin">
<button className="btn btn-outline btn-sm">
<ArrowLeft className="size-4" /> Back to Dashboard
</button>
</Link>
</div>
</header>
<div className="max-w-7xl mx-auto p-4 space-y-4">
{/* Controls */}
<div className="card bg-base-100 shadow-sm border border-base-300 p-4">
<div className="flex flex-wrap items-end gap-3">
{/* Hotel */}
<div className="form-control min-w-[200px]">
<label className="label" htmlFor="payroll-company">
<span className="label-text text-xs font-medium">Hotel</span>
</label>
<select
id="payroll-company"
value={selectedCompany}
onChange={(e) => e.target.value && setSelectedCompany(e.target.value)}
className="select select-bordered select-sm"
>
{companies.map((c) => (
<option key={c.id} value={c.id.toString()}>
{c.name}
</option>
))}
{companies.length === 0 && <option value="">No hotels</option>}
</select>
</div>
{/* Date range */}
<div className="form-control">
<label className="label" htmlFor="payroll-start-date">
<span className="label-text text-xs font-medium">From</span>
</label>
<input
id="payroll-start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="input input-bordered input-sm w-36"
/>
</div>
<div className="form-control">
<label className="label" htmlFor="payroll-end-date">
<span className="label-text text-xs font-medium">To</span>
</label>
<input
id="payroll-end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="input input-bordered input-sm w-36"
/>
</div>
{/* Date presets */}
<div className="flex flex-wrap gap-1 self-end">
{DATE_PRESETS.map((p) => (
<button key={p.label} className="btn btn-ghost btn-xs" onClick={() => applyPreset(p)}>
{p.label}
</button>
))}
</div>
{/* Generate button */}
<button className="btn btn-primary btn-sm" onClick={loadPayroll} disabled={loading || !selectedCompany}>
{loading ? <RefreshCw className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{loading ? 'Loading...' : 'Preview'}
</button>
</div>
{error && (
<div className="mt-3 alert alert-error text-sm py-2">
<AlertTriangle className="size-4" /> {error}
</div>
)}
</div>
{/* Summary + Export actions */}
{data && (
<>
{/* Summary strip */}
<div className="card bg-base-100 shadow-sm border border-base-300 p-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap gap-6">
<div className="text-sm">
<div className="text-base-content/40 text-xs uppercase tracking-wider">Hotel</div>
<div className="font-semibold">{data.company}</div>
</div>
<div className="text-sm">
<div className="text-base-content/40 text-xs uppercase tracking-wider">Period</div>
<div className="font-semibold">
{data.period.startDate} {data.period.endDate}
</div>
</div>
<div className="text-sm">
<div className="text-base-content/40 text-xs uppercase tracking-wider">Employees</div>
<div className="font-semibold">{data.employees.length}</div>
</div>
<div className="text-sm">
<div className="text-base-content/40 text-xs uppercase tracking-wider">Total Days</div>
<div className="font-semibold">{totalStats.totalDays}</div>
</div>
<div className="text-sm">
<div className="text-base-content/40 text-xs uppercase tracking-wider">Total Hours</div>
<div className="font-semibold text-success">{formatHours(totalStats.totalHours)}</div>
</div>
{totalStats.errorDays > 0 && (
<div className="text-sm">
<div className="text-base-content/40 text-xs uppercase tracking-wider"> Issues</div>
<div className="font-semibold text-warning">
{totalStats.errorDays} day{totalStats.errorDays !== 1 ? 's' : ''} with errors
</div>
</div>
)}
</div>
{/* Export actions */}
<div className="flex gap-2">
<button className="btn btn-outline btn-sm" onClick={copyTSV}>
{copied ? <Check className="size-4 text-success" /> : <Copy className="size-4" />}
{copied ? 'Copied!' : 'Copy TSV'}
</button>
<button className="btn btn-primary btn-sm" onClick={downloadTSV}>
<Download className="size-4" /> Download .tsv
</button>
</div>
</div>
{/* Issue warning bar */}
{totalStats.errorDays > 0 && (
<div className="mt-3 alert alert-warning text-sm py-2">
<AlertTriangle className="size-4 shrink-0" />
<span>
{totalStats.errorDays} day{totalStats.errorDays !== 1 ? 's' : ''} with validation issues , review
the <span className="font-semibold">Notes</span> column below before exporting to payroll. Errors
include: missing clock-out, unended breaks, or consecutive duplicate entries.
</span>
</div>
)}
</div>
{/* Preview Table */}
{data.employees.length > 0 ? (
<div className="card bg-base-100 shadow-sm border border-base-300 overflow-hidden">
<div className="px-4 py-3 border-b border-base-300 bg-base-200/50">
<h2 className="font-semibold text-sm">Payroll Preview</h2>
<p className="text-xs text-base-content/50 mt-0.5">
TSV columns: Employee Name | Date | Clock In | Clock Out | Break (min) | Net Hours | Notes
</p>
</div>
<div className="overflow-x-auto">
<table className="table table-zebra table-xs w-full">
<thead>
<tr className="text-[10px] uppercase text-base-content/50">
<th className="w-40">Employee</th>
<th>Date</th>
<th className="text-center">In</th>
<th className="text-center">Out</th>
<th className="text-center">Break (min)</th>
<th className="text-center">Net Hours</th>
<th className="w-48">Notes / Issues</th>
</tr>
</thead>
<tbody>
{data.employees.map((emp) => {
const hasAnyError = emp.days.some((d) => d.hasErrors);
const visibleDays = emp.days.filter(
(d) =>
d.inTime || d.outTime || d.clockMinutes > 0 || d.workedMinutes > 0 || d.breakMinutes > 0,
);
return (
<Fragment key={emp.id}>
{visibleDays.map((day, di) => (
<tr key={`${emp.id}-${di}`} className={day.hasErrors ? 'bg-warning/5' : ''}>
{di === 0 && (
<td className="font-medium align-top" rowSpan={visibleDays.length}>
<div className="flex items-center gap-1.5">
<span>{emp.name}</span>
{hasAnyError && <AlertTriangle className="size-3 text-warning shrink-0" />}
</div>
</td>
)}
<td className="text-xs text-base-content/60">{day.date}</td>
<td className="text-center text-xs font-mono">{day.inTime || ','}</td>
<td className="text-center text-xs font-mono">{day.outTime || ','}</td>
<td className="text-center text-xs">{day.breakMinutes > 0 ? day.breakMinutes : ','}</td>
<td
className={`text-center text-sm font-semibold ${day.workedMinutes > 0 ? 'text-success' : 'text-base-content/30'}`}
>
{day.workedMinutes > 0 ? fmtDecimalHours(day.workedMinutes) : ','}
</td>
<td>
{day.hasErrors ? (
<div className="flex flex-col gap-0.5">
{day.errors.map((err, ei) => (
<span key={ei} className="badge badge-warning badge-xs font-normal">
{err}
</span>
))}
</div>
) : day.workedMinutes > 0 ? (
<span className="badge badge-success badge-xs"> OK</span>
) : (
<span className="text-base-content/20 text-xs">,</span>
)}
</td>
</tr>
))}
{/* Totals row */}
<tr className="bg-base-200 font-semibold text-xs">
<td colSpan={2} className="text-right">
<span className="text-base-content/60 mr-2">{emp.name} , TOTAL</span>
</td>
<td className="text-center">,</td>
<td className="text-center">,</td>
<td className="text-center text-warning">{formatHours(emp.totals.breakMinutes)}</td>
<td className="text-center text-success">{fmtDecimalHours(emp.totals.workedMinutes)}</td>
<td />
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
</div>
) : (
<div className="card bg-base-100 shadow-sm border border-base-300 p-12 text-center text-base-content/40">
<div className="text-4xl mb-3">📋</div>
<p>No time entries found for this period</p>
<p className="text-sm mt-1">Try a different date range</p>
</div>
)}
</>
)}
{!data && !loading && (
<div className="text-center py-20 text-base-content/30">
<div className="text-5xl mb-4">💰</div>
<p className="text-lg">Select a hotel and date range, then click Preview</p>
</div>
)}
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPulseSecret, verifyCookie, setAuthCookie, verifySecret } from '@/lib/auth-cookie';
export async function GET(request: NextRequest) {
if (verifyCookie(request)) {
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { secret } = body as { secret?: string };
if (!secret) {
return NextResponse.json({ error: 'Secret is required' }, { status: 400 });
}
if (!getPulseSecret()) {
return NextResponse.json(
{ error: 'Server not configured: PULSE_SECRET must be at least 32 characters' },
{ status: 500 },
);
}
if (!verifySecret(secret)) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
const response = NextResponse.json({ ok: true });
setAuthCookie(response);
return response;
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { companies, employees, timeEntries, auditLog } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { CompanySchema, CompanyUpdateSchema } from '@/lib/schemas';
import { ok, err } from '@/lib/api-response';
export async function GET() {
await dbReady;
const results = await db.select().from(companies).all();
return NextResponse.json(ok(results));
}
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = CompanySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
await dbReady;
const company = await db.insert(companies).values(parsed.data).returning().get();
return NextResponse.json(ok({ company }));
}
export async function PATCH(request: NextRequest) {
const body = await request.json();
const parsed = CompanyUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
await dbReady;
const company = await db
.update(companies)
.set({ name: parsed.data.name })
.where(eq(companies.id, parsed.data.id))
.returning()
.get();
if (!company) {
return NextResponse.json(err('Company not found'), { status: 404 });
}
return NextResponse.json(ok({ company }));
}
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const parsed = id ? parseInt(id) : NaN;
if (isNaN(parsed)) {
return NextResponse.json(err('Invalid id'), { status: 400 });
}
await dbReady;
const deletedCompany = await db.transaction(async (tx) => {
await Promise.all([
tx.delete(auditLog).where(eq(auditLog.companyId, parsed)).run(),
tx.delete(timeEntries).where(eq(timeEntries.companyId, parsed)).run(),
]);
await tx.delete(employees).where(eq(employees.companyId, parsed)).run();
return tx.delete(companies).where(eq(companies.id, parsed)).returning().get();
});
if (!deletedCompany) {
return NextResponse.json(err('Company not found'), { status: 404 });
}
return NextResponse.json(ok({ company: deletedCompany }));
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { employees, companies, timeEntries } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { EmployeeSchema, EmployeeUpdateSchema } from '@/lib/schemas';
import { ok, err } from '@/lib/api-response';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const companyId = searchParams.get('companyId');
if (!companyId) {
return NextResponse.json(err('companyId is required'), { status: 400 });
}
await dbReady;
const result = await db
.select({
id: employees.id,
name: employees.name,
isActive: employees.isActive,
companyId: employees.companyId,
})
.from(employees)
.where(and(eq(employees.companyId, parseInt(companyId)), eq(employees.isActive, true)))
.all();
return NextResponse.json(ok(result));
}
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = EmployeeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
await dbReady;
const company = await db.select().from(companies).where(eq(companies.id, parsed.data.companyId)).get();
if (!company) {
return NextResponse.json(err('Company not found'), { status: 404 });
}
const employee = await db.insert(employees).values(parsed.data).returning().get();
return NextResponse.json(ok({ employee }));
}
export async function PATCH(request: NextRequest) {
const body = await request.json();
const parsed = EmployeeUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
const { id, ...rest } = parsed.data;
const updateData: Partial<typeof employees.$inferInsert> = {};
if (rest.name !== undefined) updateData.name = rest.name;
if (rest.isActive !== undefined) updateData.isActive = rest.isActive;
if (Object.keys(updateData).length === 0) {
return NextResponse.json(err('No fields to update'), { status: 400 });
}
await dbReady;
const employee = await db.update(employees).set(updateData).where(eq(employees.id, id)).returning().get();
if (!employee) {
return NextResponse.json(err('Employee not found'), { status: 404 });
}
return NextResponse.json(ok({ employee }));
}
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(err('id is required'), { status: 400 });
}
const parsedId = parseInt(id);
if (isNaN(parsedId)) {
return NextResponse.json(err('Invalid id'), { status: 400 });
}
await dbReady;
const deletedEmployee = await db.transaction(async (tx) => {
await tx.delete(timeEntries).where(eq(timeEntries.employeeId, parsedId)).run();
return tx.delete(employees).where(eq(employees.id, parsedId)).returning().get();
});
if (!deletedEmployee) {
return NextResponse.json(err('Employee not found'), { status: 404 });
}
return NextResponse.json(ok({ employee: deletedEmployee }));
}
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { employees, timeEntries } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { statusAfter } from '@/lib/validation';
import { ok, err } from '@/lib/api-response';
import { calculateLiveShiftElapsed } from '@/lib/calculations';
import { parseId } from '@/lib/params';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const companyId = parseId(searchParams.get('companyId'));
if (companyId == null) {
return NextResponse.json(err('valid companyId is required'), { status: 400 });
}
await dbReady;
const activeEmps = await db
.select({ id: employees.id, name: employees.name })
.from(employees)
.where(and(eq(employees.companyId, companyId), eq(employees.isActive, true)))
.all();
if (activeEmps.length === 0) {
return NextResponse.json(ok({ employees: [], fetchedAt: Math.floor(Date.now() / 1000) }));
}
const allEntries = await db
.select({
id: timeEntries.id,
employeeId: timeEntries.employeeId,
type: timeEntries.type,
timestamp: timeEntries.timestamp,
isDeleted: timeEntries.isDeleted,
})
.from(timeEntries)
.where(
and(
eq(timeEntries.companyId, companyId),
eq(timeEntries.isDeleted, false),
inArray(
timeEntries.employeeId,
activeEmps.map((emp) => emp.id),
),
),
)
.orderBy(timeEntries.employeeId, timeEntries.timestamp, timeEntries.id)
.all();
const employeeList = activeEmps.map((emp) => {
const entries = allEntries.filter((entry) => entry.employeeId === emp.id);
const latestEntry = entries.at(-1);
return {
id: emp.id,
name: emp.name,
status: latestEntry ? statusAfter(latestEntry.type) : 'OUT',
lastPunchTimestamp: latestEntry?.timestamp ?? null,
...calculateLiveShiftElapsed(entries),
};
});
return NextResponse.json(ok({ employees: employeeList, fetchedAt: Math.floor(Date.now() / 1000) }));
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export function GET() {
return NextResponse.json({ ok: true });
}
+131
View File
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { employees, timeEntries, companies } from '@/db/schema';
import { eq, and, gte, lte } from 'drizzle-orm';
import { calculateDayMinutes, formatHours } from '@/lib/calculations';
import { format, addDays } from 'date-fns';
import { ok, err } from '@/lib/api-response';
import { parseId } from '@/lib/params';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const companyId = parseId(searchParams.get('companyId'));
const startDateStr = searchParams.get('startDate');
const endDateStr = searchParams.get('endDate');
if (companyId == null) {
return NextResponse.json(err('valid companyId is required'), { status: 400 });
}
if (!startDateStr || !endDateStr) {
return NextResponse.json(err('startDate and endDate are required'), { status: 400 });
}
const startDate = new Date(startDateStr + 'T00:00:00');
const endDate = new Date(endDateStr + 'T23:59:59');
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return NextResponse.json(err('Invalid date format'), { status: 400 });
}
const startUnix = Math.floor(startDate.getTime() / 1000);
const endUnix = Math.floor(endDate.getTime() / 1000);
const contextStartUnix = startUnix - 36 * 3600;
const contextEndUnix = endUnix + 36 * 3600;
await dbReady;
const [company, companyEmployees, allEntries] = await Promise.all([
db.select().from(companies).where(eq(companies.id, companyId)).get(),
db
.select()
.from(employees)
.where(and(eq(employees.companyId, companyId), eq(employees.isActive, true)))
.all(),
db
.select({
id: timeEntries.id,
employeeId: timeEntries.employeeId,
type: timeEntries.type,
timestamp: timeEntries.timestamp,
isDeleted: timeEntries.isDeleted,
})
.from(timeEntries)
.where(
and(
eq(timeEntries.companyId, companyId),
eq(timeEntries.isDeleted, false),
gte(timeEntries.timestamp, contextStartUnix),
lte(timeEntries.timestamp, contextEndUnix),
),
)
.orderBy(timeEntries.timestamp)
.all(),
]);
if (!company) {
return NextResponse.json(err('Company not found'), { status: 404 });
}
const days: string[] = [];
let dayDate = startDate;
while (dayDate <= endDate) {
days.push(format(dayDate, 'yyyy-MM-dd'));
dayDate = addDays(dayDate, 1);
}
const resultEmployees = companyEmployees.map((emp) => {
const empEntries = allEntries.filter((e) => e.employeeId === emp.id);
const empDays = days.map((dayStr) => {
const dayStart = new Date(dayStr + 'T00:00:00').getTime() / 1000;
const dayEnd = new Date(dayStr + 'T23:59:59').getTime() / 1000;
const dayEntries = empEntries.filter((e) => e.timestamp >= dayStart && e.timestamp <= dayEnd);
// Single call , errors are returned directly
const { clockMinutes, breakMinutes, workedMinutes, hasErrors, errors } = calculateDayMinutes(empEntries, dayStr);
const inEntry = dayEntries.find((e) => e.type === 'IN' && !e.isDeleted);
const outEntry = dayEntries.find((e) => e.type === 'OUT' && !e.isDeleted);
return {
date: dayStr,
inTime: inEntry ? format(new Date(inEntry.timestamp * 1000), 'h:mm a') : '',
outTime: outEntry ? format(new Date(outEntry.timestamp * 1000), 'h:mm a') : '',
breakMinutes: Math.round(breakMinutes),
clockMinutes: Math.round(clockMinutes),
workedMinutes: Math.round(workedMinutes),
hasErrors,
errors,
};
});
const totals = empDays.reduce(
(acc, day) => ({
clockMinutes: acc.clockMinutes + day.clockMinutes,
breakMinutes: acc.breakMinutes + day.breakMinutes,
workedMinutes: acc.workedMinutes + day.workedMinutes,
}),
{ clockMinutes: 0, breakMinutes: 0, workedMinutes: 0 },
);
return {
id: emp.id,
name: emp.name,
days: empDays,
totals,
};
});
let summary = 'Employee\tDate\tIn\tOut\tBreak\tNet\tNotes\n';
for (const emp of resultEmployees) {
for (const day of emp.days) {
const hasErrors = day.errors.length > 0;
summary += `${emp.name}\t${day.date}\t${day.inTime}\t${day.outTime}\t${formatHours(day.breakMinutes)}\t${formatHours(day.workedMinutes)}\t${hasErrors ? '[WARN] ' + day.errors.join('; ') : ''}\n`;
}
summary += `${emp.name}\tTOTAL\t\t\t${formatHours(emp.totals.breakMinutes)}\t${formatHours(emp.totals.workedMinutes)}\t\n`;
}
return NextResponse.json(
ok({
company: company.name,
period: { startDate: startDateStr, endDate: endDateStr },
employees: resultEmployees,
summary,
}),
);
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { PunchSchema } from '@/lib/schemas';
import { ok, err } from '@/lib/api-response';
import { createValidatedTimeEntry } from '@/lib/time-entry-service';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = PunchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
const { employeeId, companyId, type, photoBase64 } = parsed.data;
const result = await createValidatedTimeEntry({
employeeId,
companyId,
type,
photoBase64,
source: 'kiosk',
});
if (!result.ok) {
return NextResponse.json(err(result.error), { status: result.status });
}
return NextResponse.json(
ok({
entry: {
id: result.entry.id,
employeeId: result.entry.employeeId,
type: result.entry.type,
timestamp: result.entry.timestamp,
},
status: result.status,
}),
);
} catch (error) {
console.error('Punch error:', error);
return NextResponse.json(err('Internal server error'), { status: 500 });
}
}
+135
View File
@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, dbReady } from '@/db';
import { timeEntries, employees } from '@/db/schema';
import { eq, and, gte, lte } from 'drizzle-orm';
import { validateTimestampChronology } from '@/lib/validation';
import { ManualEntrySchema, TimeEntryUpdateSchema } from '@/lib/schemas';
import { ok, err } from '@/lib/api-response';
import { createValidatedTimeEntry } from '@/lib/time-entry-service';
function parseRangeParam(value: string, boundary: 'start' | 'end'): number {
if (/^\d+$/.test(value)) return Number(value);
const suffix = boundary === 'start' ? 'T00:00:00' : 'T23:59:59';
return Math.floor(new Date(`${value}${suffix}`).getTime() / 1000);
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const companyId = searchParams.get('companyId');
const employeeId = searchParams.get('employeeId');
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
if (!companyId) {
return NextResponse.json(err('companyId is required'), { status: 400 });
}
const whereConditions = [eq(timeEntries.companyId, parseInt(companyId))];
if (employeeId) whereConditions.push(eq(timeEntries.employeeId, parseInt(employeeId)));
if (startDate) whereConditions.push(gte(timeEntries.timestamp, parseRangeParam(startDate, 'start')));
if (endDate) whereConditions.push(lte(timeEntries.timestamp, parseRangeParam(endDate, 'end')));
await dbReady;
const results = await db
.select({
id: timeEntries.id,
employeeId: timeEntries.employeeId,
companyId: timeEntries.companyId,
type: timeEntries.type,
timestamp: timeEntries.timestamp,
photoBase64: timeEntries.photoBase64,
isDeleted: timeEntries.isDeleted,
employeeName: employees.name,
})
.from(timeEntries)
.innerJoin(employees, eq(timeEntries.employeeId, employees.id))
.where(and(...whereConditions))
.orderBy(timeEntries.timestamp)
.all();
return NextResponse.json(ok(results));
}
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const parsed = TimeEntryUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
const { id, isDeleted, timestamp, forceOverride } = parsed.data;
await dbReady;
const result = await db.transaction(async (tx) => {
const existing = await tx.select().from(timeEntries).where(eq(timeEntries.id, id)).get();
if (!existing) {
return { ok: false as const, status: 404, error: 'Entry not found' };
}
if (timestamp !== undefined && timestamp !== existing.timestamp) {
const entries = await tx
.select()
.from(timeEntries)
.where(
and(
eq(timeEntries.employeeId, existing.employeeId),
eq(timeEntries.companyId, existing.companyId),
eq(timeEntries.isDeleted, false),
),
)
.all();
const validation = validateTimestampChronology(entries, id, timestamp, !!forceOverride);
if (!validation.valid) {
return { ok: false as const, status: 400, error: validation.error ?? 'Validation failed' };
}
}
const updateData: Partial<typeof timeEntries.$inferInsert> = {};
if (isDeleted !== undefined) updateData.isDeleted = isDeleted;
if (timestamp !== undefined) updateData.timestamp = timestamp;
const updated = await tx.update(timeEntries).set(updateData).where(eq(timeEntries.id, id)).returning().get();
return { ok: true as const, entry: updated };
});
if (!result.ok) {
return NextResponse.json(err(result.error), { status: result.status });
}
return NextResponse.json(ok({ entry: result.entry }));
} catch (error) {
console.error('Update time entry error:', error);
return NextResponse.json(err('Internal server error'), { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = ManualEntrySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(err(parsed.error.issues[0]!.message), { status: 400 });
}
const { employeeId, companyId, type, timestamp, photoBase64, forceOverride } = parsed.data;
const result = await createValidatedTimeEntry({
employeeId,
companyId,
type,
timestamp,
photoBase64: photoBase64 || null,
forceOverride: !!forceOverride,
source: 'manual',
});
if (!result.ok) {
return NextResponse.json(err(result.error), { status: result.status });
}
return NextResponse.json(ok({ entry: result.entry, overridden: result.overridden }));
} catch (error) {
console.error('Add manual entry error:', error);
return NextResponse.json(err('Internal server error'), { status: 500 });
}
}
+2
View File
@@ -0,0 +1,2 @@
@import 'tailwindcss';
@import 'daisyui/daisyui.css';
+390
View File
@@ -0,0 +1,390 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Webcam from 'react-webcam';
import { X, Loader2, Home, RefreshCw } from 'lucide-react';
import Link from 'next/link';
import { fetchApi } from '@/lib/api';
type Company = { id: number; name: string };
type Employee = { id: number; name: string; isActive: boolean; companyId: number };
type PunchType = 'IN' | 'OUT' | 'BREAK_IN' | 'BREAK_OUT';
type EmployeeWithStatus = {
id: number;
name: string;
status: 'IN' | 'OUT' | 'BREAK';
lastPunchTimestamp: number | null;
shiftStartTimestamp: number | null;
activeBreakStartTimestamp: number | null;
completedBreakSeconds: number;
};
type PunchResponse = { status: string; entry: { type: string } };
function useNowTime() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, []);
return now;
}
export default function KioskPage() {
const params = useParams();
const companyId = params.companyId as string;
const [company, setCompany] = useState<Company | null>(null);
const [employees, setEmployees] = useState<EmployeeWithStatus[]>([]);
const [punchModal, setPunchModal] = useState<Employee | null>(null);
const [showSuccess, setShowSuccess] = useState<string | null>(null);
const [isPunching, setIsPunching] = useState(false);
const [punchingType, setPunchingType] = useState<PunchType | null>(null);
const [cameraError, setCameraError] = useState(false);
const [punchError, setPunchError] = useState<string | null>(null);
const webcamRef = useRef<Webcam>(null);
const now = useNowTime();
useEffect(() => {
if (!companyId) return;
fetchApi<Company[]>('/api/companies')
.then((data) => {
const found = data.find((c) => c.id.toString() === companyId);
if (found) setCompany(found);
})
.catch(() => {});
}, [companyId]);
const loadEmployees = useCallback(async () => {
if (!companyId) return;
try {
const data = await fetchApi<{ employees: EmployeeWithStatus[] }>(
`/api/employees/with-status?companyId=${companyId}`,
);
setEmployees(data.employees);
} catch {
setEmployees([]);
}
}, [companyId]);
// Simple polling keeps kiosk tablets in sync without a long-lived stream.
useEffect(() => {
if (!companyId) return;
loadEmployees();
const interval = setInterval(loadEmployees, 5000);
return () => {
clearInterval(interval);
};
}, [companyId, loadEmployees]);
useEffect(() => {
if (!punchModal) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isPunching) setPunchModal(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [punchModal, isPunching]);
const openPunchModal = (employee: Employee) => {
setPunchModal(employee);
setPunchingType(null);
setPunchError(null);
};
const handleAction = async (type: PunchType) => {
if (!punchModal || isPunching) return;
setPunchingType(type);
setIsPunching(true);
setPunchError(null);
try {
const photo = webcamRef.current?.getScreenshot() || null;
const data = await fetchApi<PunchResponse>('/api/punch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
employeeId: punchModal.id,
companyId: parseInt(companyId),
type,
photoBase64: photo || undefined,
}),
});
const labels: Record<string, string> = {
IN: 'Clocked In',
OUT: 'Clocked Out',
BREAK_IN: 'Break Ended',
BREAK_OUT: 'Break Started',
};
setShowSuccess(`${punchModal.name} , ${labels[data.entry.type] || data.entry.type}`);
setPunchModal(null);
loadEmployees();
setTimeout(() => setShowSuccess(null), 2500);
} catch (err) {
console.error('Punch failed:', err);
setPunchError(err instanceof Error ? err.message : 'Connection error , please try again');
} finally {
setIsPunching(false);
}
};
const statusColor = (status?: 'IN' | 'OUT' | 'BREAK') => {
switch (status) {
case 'IN':
return 'border-success bg-success/10 text-success';
case 'BREAK':
return 'border-warning bg-warning/10 text-warning';
default:
return 'border-base-300 bg-base-200 text-base-content/60';
}
};
const statusLabel = (status?: 'IN' | 'OUT' | 'BREAK') => {
switch (status) {
case 'IN':
return 'On Shift';
case 'BREAK':
return 'On Break';
default:
return 'Off Duty';
}
};
const refreshKiosk = () => {
window.location.reload();
};
return (
<div className="min-h-screen bg-base-200 flex flex-col">
{showSuccess && (
<div className="toast toast-top toast-center z-50">
<div className="alert alert-success gap-3">
<span className="text-base"></span>
<span className="font-semibold">{showSuccess}</span>
</div>
</div>
)}
<header className="bg-base-100 border-b border-base-300 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xl"></span>
<h1 className="font-semibold text-lg">Pulse Clock</h1>
{company && <span className="badge badge-outline ml-1">{company.name}</span>}
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-lg font-semibold tabular-nums" suppressHydrationWarning>
{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<button
type="button"
className="btn btn-ghost btn-square btn-sm"
onClick={refreshKiosk}
aria-label="Refresh kiosk"
title="Refresh kiosk"
>
<RefreshCw className="size-4" />
</button>
<Link href="/admin/manage">
<button className="btn btn-ghost btn-square btn-sm" aria-label="Back to admin" title="Back to admin">
<Home className="size-4" />
</button>
</Link>
</div>
</header>
<div className="flex-1 p-4 max-w-5xl mx-auto w-full">
{employees.length === 0 ? (
<div className="text-center py-20 text-base-content/40">
<div className="text-5xl mb-4">👥</div>
<p className="text-lg">No active employees found</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{employees.map((emp) => (
<EmployeeCard
key={emp.id}
employee={{ id: emp.id, name: emp.name, isActive: true, companyId: parseInt(companyId) }}
status={emp.status}
shiftStartUnix={emp.shiftStartTimestamp}
activeBreakStartUnix={emp.activeBreakStartTimestamp}
completedBreakSeconds={emp.completedBreakSeconds}
onClick={() =>
openPunchModal({ id: emp.id, name: emp.name, isActive: true, companyId: parseInt(companyId) })
}
statusColor={statusColor}
statusLabel={statusLabel}
/>
))}
</div>
)}
</div>
{punchModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
aria-label="Close punch modal"
className="fixed inset-0 bg-black/50 cursor-default"
onClick={() => {
if (!isPunching) setPunchModal(null);
}}
/>
<div className="bg-base-100 rounded-2xl shadow-2xl p-6 w-full max-w-md relative z-10">
<button
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => {
if (!isPunching) setPunchModal(null);
}}
>
<X className="size-4" />
</button>
<h3 className="font-semibold text-xl mb-1">{punchModal.name}</h3>
<p className="text-sm text-base-content/60 mb-4">
Status:{' '}
<span
className={`font-semibold ${currentModalStatus(employees, punchModal.id) === 'IN' ? 'text-success' : currentModalStatus(employees, punchModal.id) === 'BREAK' ? 'text-warning' : 'text-base-content/60'}`}
>
{statusLabel(currentModalStatus(employees, punchModal.id))}
</span>
</p>
{punchError && (
<div className="alert alert-error mb-4 shadow-lg">
<div className="flex items-start gap-2 w-full">
<span className="text-lg"></span>
<div className="flex-1">
<div className="font-semibold text-sm">Punch Rejected</div>
<div className="text-xs opacity-80 mt-0.5">{punchError}</div>
</div>
<button className="btn btn-ghost btn-square btn-xs text-error" onClick={() => setPunchError(null)}>
<X className="size-4" />
</button>
</div>
</div>
)}
{isPunching ? (
<div className="flex flex-col items-center justify-center py-8 gap-4">
<Loader2 className="size-12 animate-spin text-primary" />
<p className="text-base-content/60 text-sm">
{punchingType === 'OUT'
? 'Clocking out...'
: punchingType === 'BREAK_OUT'
? 'Starting break…'
: punchingType === 'BREAK_IN'
? 'Ending break…'
: 'Clocking in…'}
</p>
</div>
) : (
<div className="space-y-4">
<div className="relative bg-neutral-950 rounded-lg overflow-hidden">
{cameraError ? (
<div className="h-48 flex items-center justify-center text-base-content/40 text-sm">
Camera unavailable
</div>
) : (
<Webcam
ref={webcamRef}
screenshotFormat="image/jpeg"
videoConstraints={{ width: 320, height: 240, facingMode: 'user' }}
onUserMediaError={() => setCameraError(true)}
className="w-full scale-x-[-1]"
/>
)}
</div>
<div className="flex gap-2 flex-wrap justify-center">
{currentModalStatus(employees, punchModal.id) !== 'IN' &&
currentModalStatus(employees, punchModal.id) !== 'BREAK' && (
<button className="btn btn-success" onClick={() => handleAction('IN')}>
🟢 Clock In
</button>
)}
{(currentModalStatus(employees, punchModal.id) === 'IN' ||
currentModalStatus(employees, punchModal.id) === 'BREAK') && (
<button className="btn btn-error" onClick={() => handleAction('OUT')}>
🔴 Clock Out
</button>
)}
{currentModalStatus(employees, punchModal.id) === 'IN' && (
<button className="btn btn-warning" onClick={() => handleAction('BREAK_OUT')}>
🟡 Start Break
</button>
)}
{currentModalStatus(employees, punchModal.id) === 'BREAK' && (
<button className="btn btn-warning" onClick={() => handleAction('BREAK_IN')}>
🟡 End Break
</button>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}
function currentModalStatus(employees: EmployeeWithStatus[], employeeId: number): EmployeeWithStatus['status'] {
return employees.find((employee) => employee.id === employeeId)?.status ?? 'OUT';
}
// ── Sub-component so hooks are called at the top level (Rules of Hooks) ─────────
type EmployeeCardProps = {
employee: Employee;
status?: 'IN' | 'OUT' | 'BREAK';
shiftStartUnix: number | null;
activeBreakStartUnix: number | null;
completedBreakSeconds: number;
onClick: () => void;
statusColor: (status?: 'IN' | 'OUT' | 'BREAK') => string;
statusLabel: (status?: 'IN' | 'OUT' | 'BREAK') => string;
};
function EmployeeCard({
employee,
status,
shiftStartUnix,
activeBreakStartUnix,
completedBreakSeconds,
onClick,
statusColor,
statusLabel,
}: EmployeeCardProps) {
const elapsed = shiftStartUnix
? formatNetShiftElapsed(shiftStartUnix, completedBreakSeconds, activeBreakStartUnix)
: '';
return (
<button
onClick={onClick}
className={`p-4 rounded-xl border-2 text-center transition-all hover:shadow-lg active:scale-95 ${statusColor(status)}`}
>
<div className="text-2xl mb-1">{status === 'IN' ? '🟢' : status === 'BREAK' ? '🟡' : '⚪'}</div>
<div className="font-semibold text-sm leading-tight">{employee.name}</div>
<div className="text-xs mt-1 opacity-75">{statusLabel(status)}</div>
{status === 'IN' && elapsed && (
<div className="text-xs mt-1 font-mono font-semibold opacity-90">On shift · {elapsed}</div>
)}
{status === 'BREAK' && elapsed && (
<div className="text-xs mt-1 font-mono font-semibold opacity-90">On break · {elapsed} worked</div>
)}
</button>
);
}
function formatNetShiftElapsed(
shiftStartUnix: number,
completedBreakSeconds: number,
activeBreakStartUnix: number | null,
): string {
const nowUnix = Math.floor(Date.now() / 1000);
const activeBreakSeconds = activeBreakStartUnix ? Math.max(nowUnix - activeBreakStartUnix, 0) : 0;
const secs = Math.max(nowUnix - shiftStartUnix - completedBreakSeconds - activeBreakSeconds, 0);
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata, Viewport } from 'next';
import { KioskZoomLock } from '@/components/kiosk-zoom-lock';
export const metadata: Metadata = {
title: 'Kiosk | Pulse Clock',
description: 'Employee kiosk for clocking in, clocking out, and breaks.',
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function KioskLayout({ children }: { children: React.ReactNode }) {
return (
<>
<KioskZoomLock />
{children}
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function KioskIndex() {
redirect('/admin/manage');
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import { AuthGate } from '@/components/auth-gate';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Pulse Clock',
description: 'Multi-tenant hotel time clock system',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">
<AuthGate>{children}</AuthGate>
</body>
</html>
);
}
+100
View File
@@ -0,0 +1,100 @@
'use client';
import { Suspense, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [secret, setSecret] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const nextPath = useMemo(() => {
const requested = searchParams.get('next') ?? '/';
return requested.startsWith('/') && !requested.startsWith('//') ? requested : '/';
}, [searchParams]);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!secret.trim()) return;
setLoading(true);
setError(null);
try {
const response = await fetch('/api/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-pulse-csrf': '1' },
body: JSON.stringify({ secret }),
});
if (!response.ok) {
setError(response.status === 500 ? 'Server auth is not configured' : 'Invalid secret');
setLoading(false);
return;
}
router.replace(nextPath);
router.refresh();
} catch {
setError('Connection error');
setLoading(false);
}
}
return (
<main className="min-h-screen bg-base-200 flex items-center justify-center p-6">
<section className="w-full max-w-sm bg-base-100 border border-base-300 rounded-lg shadow-xl p-6 space-y-5">
<div className="space-y-1 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Pulse Clock</h1>
<p className="text-sm text-base-content/60">Enter the admin secret to continue.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="alert alert-error text-sm py-2">
<span>{error}</span>
</div>
)}
<input
type="password"
className="input input-bordered w-full"
placeholder="Admin secret"
value={secret}
onChange={(event) => setSecret(event.target.value)}
autoFocus
disabled={loading}
autoComplete="current-password"
/>
<button type="submit" className="btn btn-primary w-full" disabled={loading || !secret.trim()}>
{loading ? (
<>
<span className="loading loading-spinner loading-xs" />
Verifying
</>
) : (
'Unlock'
)}
</button>
</form>
</section>
</main>
);
}
export default function LoginPage() {
return (
<Suspense
fallback={
<main className="min-h-screen bg-base-200 flex items-center justify-center">
<span className="loading loading-spinner loading-lg" />
</main>
}
>
<LoginForm />
</Suspense>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/admin');
}