feat(time-tracking): timesheet workflow, manual entry, GPS, offline, payroll export (cycle 12)

Phase 2 of the time-tracking buildout — turns the existing clock widget
into a full payroll-ready system for Drivers / Supervisors / Admins.

Roles
- field_workers CHECK extended with 'driver' and 'supervisor' alongside
  the existing worker / time_admin / irrigator / water_admin values.

Schema (migration 0098)
- time_tracking_logs gains clock_in/out lat/lng/accuracy_m + gps_verified
  + entry_kind ('clock' | 'manual') + manual_reason + edit audit columns.
- new time_tracking_timesheets (draft → submitted → approved/locked →
  rejected, with unlock path for admins).
- new time_tracking_audit_log (append-only).
- new time_tracking_supervisor_assignments (supervisor ↔ supervisee edges).
- new SECURITY DEFINER RPCs: recompute_timesheet_totals(),
  get_or_create_timesheet(), plus a time_tracking_approval_queue view.

Workflow + locking (src/actions/time-tracking/timesheets.ts)
- resolveCaller() returns platform_admin / brand_admin / supervisor /
  worker context; supervisor scope is enforced via the assignments table.
- submitTimesheet / approveTimesheet / rejectTimesheet / unlockTimesheet
  all run inside withTx with FOR UPDATE row locks so the status + audit
  row + recompute RPC commit atomically.
- Editing or deleting an entry on an approved timesheet is blocked at
  the action layer; only unlockTimesheet (admin-only, mandatory audit
  note) can re-open it.

Manual entry (src/actions/time-tracking/entries.ts)
- addManualTimeEntry / editTimeEntry / deleteTimeEntry with Zod validation,
  audit-log writes in the same transaction, lock-aware.

GPS + offline (src/actions/time-tracking/field.ts,
  src/lib/offline/time-tracking-queue.ts,
  src/actions/time-tracking/offline-handlers.ts)
- captureGps() in the field client: best-effort geolocation, never blocks
  clock-in/out (gps_verified = accuracy_m <= 100).
- tryOrQueueClock() wraps the action; on network failure the event lands
  in IndexedDB and replays via replayOfflineClock() (PIN re-verified on
  replay, submitted_via = 'offline_sync').

Export (src/actions/time-tracking/export.ts +
  src/app/api/time-tracking/timesheets/[id]/export/route.ts)
- PDF (pdf-lib) + CSV (papaparse) payroll export with signature lines,
  available for any approved timesheet.

UI
- /admin/time-tracking with tab nav (Overview / Timesheets / Approvals /
  Audit).
- TimesheetsList, TimesheetDetail (status-driven workflow buttons,
  manual entry form, audit trail), ManualEntryForm.
- FleetRouteSummary on the time-tracking overview shows the full crew's
  water + time totals for the day.
- DailyRouteSummary on /admin/water-log/users/[id] shows the per-worker
  cross-link badge (water gallons + time hours + timesheet status).

Verification
- npx tsc --noEmit  → 0 errors.
- npm run build     → all 10 time-tracking routes compile.
- npm run lint      → 0 errors/warnings in new files.
- Playwright smoke  → 4/5 pass; the failing case is a pre-existing
  Tuxedo storefront 404 unrelated to this change.
This commit is contained in:
Nora
2026-07-04 00:27:25 -06:00
parent eb0da05037
commit 9e6accb3df
26 changed files with 5020 additions and 23 deletions
@@ -42,12 +42,54 @@ import {
type PayPeriodHours,
} from "@/actions/time-tracking/field";
import { FieldPinScreen } from "@/components/field/FieldPinScreen";
import { Clock, CheckCircle, Spinner, ChevronLeft } from "@/components/water/mobile/icons";
import { Clock, CheckCircle, Spinner, ChevronLeft, ChevronRight } from "@/components/water/mobile/icons";
const BRAND_PRIMARY = "#14532D";
const AMBER = "#B8761E";
const INK = "#1d1d1f";
// ── GPS helper ─────────────────────────────────────────────────────────────
// Best-effort geolocation capture for clock-in / clock-out. Returns null
// on any failure (denied permission, insecure context, timeout) — the
// server treats `gps_verified = false` gracefully and the worker is
// never blocked from clocking in/out.
type GpsSample = { lat: number; lng: number; accuracy_m?: number };
function captureGps(timeoutMs = 6000): Promise<GpsSample | null> {
return new Promise((resolve) => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
resolve(null);
return;
}
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
resolve(null);
}, timeoutMs);
navigator.geolocation.getCurrentPosition(
(pos) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracy_m: pos.coords.accuracy,
});
},
() => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(null);
},
{ enableHighAccuracy: true, maximumAge: 30_000, timeout: timeoutMs },
);
});
}
// ── Translations ───────────────────────────────────────────────────────────────
type Translations = {
@@ -210,7 +252,26 @@ function taskLabel(task: TimeTaskField, lang: "en" | "es"): string {
// ── Screen types ───────────────────────────────────────────────────────────────
type Screen = "loading" | "pin" | "task_select" | "working" | "clock_out_form" | "clocked_out";
type Screen =
| "loading"
| "pin"
| "hub"
| "task_select"
| "working"
| "clock_out_form"
| "clocked_out"
| "manual_entry"
| "manual_entry_success"
| "history";
// Shape returned by `submitManualTimeLog` — echoed through state to the
// success screen so the worker sees exactly what they entered.
interface ManualEntryResult {
log_id: string;
clock_in: string;
clock_out: string;
total_minutes: number;
}
interface OpenEntry {
log_id: string;
@@ -233,6 +294,20 @@ type State = {
notes: string;
error: string | null;
submitting: boolean;
// Manual-entry scratch state — separate from session-level form fields.
manualEntryDraft: {
date: string; // YYYY-MM-DD
clockInTime: string; // HH:MM
clockOutTime: string; // HH:MM
taskLabel: string;
lunchMinutes: number;
reason: string;
notes: string;
};
manualEntryResult: ManualEntryResult | null;
manualLogs: WorkerLogRow[];
manualLogsLoading: boolean;
manualLogsError: string | null;
};
type Action =
@@ -248,7 +323,30 @@ type Action =
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_SUBMITTING"; value: boolean }
| { type: "UPDATE_OPEN_ENTRY_ELAPSED"; minutes: number }
| { type: "RESET_FOR_LOGOUT" };
| { type: "RESET_FOR_LOGOUT" }
// Manual-entry scratch state
| { type: "SET_MANUAL_DRAFT"; value: State["manualEntryDraft"] }
| { type: "RESET_MANUAL_DRAFT" }
| { type: "SET_MANUAL_ENTRY_RESULT"; value: ManualEntryResult | null }
| { type: "SET_MANUAL_LOGS"; value: WorkerLogRow[] }
| { type: "SET_MANUAL_LOGS_LOADING"; value: boolean }
| { type: "SET_MANUAL_LOGS_ERROR"; value: string | null };
function emptyManualDraft(): State["manualEntryDraft"] {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, "0");
const dd = String(today.getDate()).padStart(2, "0");
return {
date: `${yyyy}-${mm}-${dd}`,
clockInTime: "07:00",
clockOutTime: "15:30",
taskLabel: "",
lunchMinutes: 30,
reason: "",
notes: "",
};
}
const initialState: State = {
lang: (getLangCookie() as "en" | "es") ?? "en",
@@ -262,6 +360,11 @@ const initialState: State = {
notes: "",
error: null,
submitting: false,
manualEntryDraft: emptyManualDraft(),
manualEntryResult: null,
manualLogs: [],
manualLogsLoading: false,
manualLogsError: null,
};
function reducer(state: State, action: Action): State {
@@ -364,10 +467,12 @@ export default function TimeTrackingFieldClient({
elapsed_minutes: open.elapsed_minutes ?? 0,
},
});
dispatch({ type: "SET_SCREEN", value: "working" });
} else {
dispatch({ type: "SET_SCREEN", value: "task_select" });
}
// Cycle 12 — land on the Hub. The Hub shows live status (so a
// worker returning with an open shift sees the running timer on
// the hero card) and gives them a chance to use Manual Entry /
// Recent Activity before tapping into the working/clock-out flow.
dispatch({ type: "SET_SCREEN", value: "hub" });
}
init();
// intentionally omits screen — see earlier cycle notes
@@ -448,7 +553,8 @@ export default function TimeTrackingFieldClient({
const handleSelectTask = async (taskName: string) => {
dispatch({ type: "SET_ERROR", value: null });
dispatch({ type: "SET_SUBMITTING", value: true });
const result = await clockInWorker(brandId, undefined, taskName);
const gps = await captureGps();
const result = await clockInWorker(brandId, undefined, taskName, gps);
dispatch({ type: "SET_SUBMITTING", value: false });
if (!result.success) {
if (result.error === "Already clocked in") {
@@ -489,7 +595,8 @@ export default function TimeTrackingFieldClient({
const handleClockOut = async () => {
dispatch({ type: "SET_ERROR", value: null });
dispatch({ type: "SET_SUBMITTING", value: true });
const result = await clockOutWorker(lunchMinutes, notes || undefined);
const gps = await captureGps();
const result = await clockOutWorker(lunchMinutes, notes || undefined, gps);
dispatch({ type: "SET_SUBMITTING", value: false });
if (!result.success) {
dispatch({ type: "SET_ERROR", value: result.error ?? "Clock out failed" });
@@ -0,0 +1,264 @@
/**
* DailyRouteSummary — the cross-link surface that shows BOTH water-log
* activity AND time-tracking totals for a single worker-day, side by
* side. This is the "Route summary shows both water totals and time
* totals" deliverable from the brief.
*
* Two flavours:
* - <DailyRouteSummary worker=... /> — focused on one worker
* - <FleetRouteSummary brand=... /> — table of every worker today
*
* Used by the Water Log admin (per-user detail) and the Time Tracking
* admin (overview tab). The data fetch is delegated to the route-summary
* server action so we only run it once per render.
*/
"use client";
import * as React from "react";
import Link from "next/link";
import type { RouteTimeSummary, WorkerRouteRow } from "@/actions/time-tracking/route-summary";
function fmtHM(min: number): string {
if (min <= 0) return "0m";
const h = Math.floor(min / 60);
const m = min % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function fmtGal(g: number): string {
if (g <= 0) return "0 gal";
if (g >= 1000) return `${(g / 1000).toFixed(g >= 10000 ? 0 : 1)}k gal`;
return `${Math.round(g).toLocaleString()} gal`;
}
function fmtClockTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
}
// ── Single-worker view ─────────────────────────────────────────────────────
export function DailyRouteSummary({
summary,
compact = false,
}: {
summary: RouteTimeSummary;
compact?: boolean;
}) {
const { water, time } = summary;
const statusTone =
time.clockedIn
? "bg-emerald-100 text-emerald-800 ring-emerald-300"
: time.workedMinutesToday > 0
? "bg-zinc-100 text-zinc-700 ring-zinc-300"
: "bg-amber-50 text-amber-800 ring-amber-300";
const tsBadge = (() => {
switch (time.timesheetStatus) {
case "draft":
return { label: "TS · Draft", tone: "bg-zinc-100 text-zinc-700" };
case "submitted":
return { label: "TS · Submitted", tone: "bg-amber-100 text-amber-800" };
case "approved":
return { label: "TS · Approved", tone: "bg-emerald-100 text-emerald-800" };
case "rejected":
return { label: "TS · Rejected", tone: "bg-rose-100 text-rose-800" };
default:
return null;
}
})();
return (
<div
className={`rounded-2xl ring-1 ring-zinc-200 bg-white shadow-sm ${
compact ? "p-4" : "p-5"
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Daily Route Summary
</p>
<h3 className="mt-0.5 text-base font-semibold text-zinc-900">
{summary.workerName}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ring-1 ${statusTone}`}
>
{time.clockedIn
? `On the clock · ${fmtClockTime(time.clockInAt)}`
: time.workedMinutesToday > 0
? "Clocked out"
: "Not started"}
</span>
{tsBadge && (
<span
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${tsBadge.tone}`}
>
{tsBadge.label}
</span>
)}
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
{/* Water side */}
<div className="rounded-xl bg-sky-50/60 p-3 ring-1 ring-sky-100">
<div className="flex items-center gap-2">
<svg viewBox="0 0 24 24" className="h-4 w-4 text-sky-700" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2.5c4 5 7 8.5 7 12a7 7 0 0 1-14 0c0-3.5 3-7 7-12z" />
</svg>
<p className="text-[11px] font-semibold uppercase tracking-wider text-sky-700">
Water Log
</p>
</div>
<dl className="mt-2 space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-sky-900/70">Entries</dt>
<dd className="font-semibold text-sky-900">{water.entryCount}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sky-900/70">Total</dt>
<dd className="font-semibold text-sky-900">{fmtGal(water.totalGallons)}</dd>
</div>
{!compact && water.headgatesVisited.length > 0 && (
<div className="flex justify-between gap-2">
<dt className="shrink-0 text-sky-900/70">Headgates</dt>
<dd className="truncate text-right text-xs text-sky-900/80" title={water.headgatesVisited.join(", ")}>
{water.headgatesVisited.length} unique
</dd>
</div>
)}
</dl>
</div>
{/* Time side */}
<div className="rounded-xl bg-violet-50/60 p-3 ring-1 ring-violet-100">
<div className="flex items-center gap-2">
<svg viewBox="0 0 24 24" className="h-4 w-4 text-violet-700" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<p className="text-[11px] font-semibold uppercase tracking-wider text-violet-700">
Time Tracking
</p>
</div>
<dl className="mt-2 space-y-1 text-sm">
<div className="flex justify-between">
<dt className="text-violet-900/70">Worked</dt>
<dd className="font-semibold text-violet-900">{fmtHM(time.workedMinutesToday)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-violet-900/70">Regular / OT</dt>
<dd className="font-semibold text-violet-900">
{fmtHM(time.regularMinutesToday)} / {fmtHM(time.overtimeMinutesToday)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-violet-900/70">Break</dt>
<dd className="font-semibold text-violet-900">{fmtHM(time.breakMinutesToday)}</dd>
</div>
</dl>
</div>
</div>
{!compact && time.timesheetId && (
<div className="mt-3 flex justify-end">
<Link
href={`/admin/time-tracking/timesheets/${time.timesheetId}`}
className="text-xs font-medium text-violet-700 hover:text-violet-900"
>
View timesheet
</Link>
</div>
)}
</div>
);
}
// ── Fleet view (table) ─────────────────────────────────────────────────────
export function FleetRouteSummary({
brandId,
rows,
date,
}: {
brandId: string;
rows: WorkerRouteRow[];
date: string;
}) {
return (
<div className="overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200 shadow-sm">
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Today's Route
</p>
<h3 className="text-base font-semibold text-zinc-900">
Crew {date}
</h3>
</div>
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-semibold text-zinc-700">
{rows.filter((r) => r.time.isClockedIn).length} on clock ·{" "}
{rows.length} active
</span>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-zinc-200 text-sm">
<thead className="bg-zinc-50 text-left text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2">Worker</th>
<th className="px-4 py-2 text-right">Water Entries</th>
<th className="px-4 py-2 text-right">Gallons</th>
<th className="px-4 py-2 text-right">Time Today</th>
<th className="px-4 py-2 text-right">Clock</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-6 text-center text-sm text-zinc-500">
No active workers yet.
</td>
</tr>
)}
{rows.map((r) => (
<tr key={r.workerId} className={!r.active ? "opacity-60" : ""}>
<td className="px-4 py-2 font-medium text-zinc-900">{r.workerName}</td>
<td className="px-4 py-2 text-right text-zinc-700">{r.water.entryCount}</td>
<td className="px-4 py-2 text-right text-sky-700">{fmtGal(r.water.totalGallons)}</td>
<td className="px-4 py-2 text-right font-semibold text-violet-700">
{fmtHM(r.time.workedMinutesToday)}
</td>
<td className="px-4 py-2 text-right">
{r.time.isClockedIn ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-800">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />
On
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-semibold text-zinc-600">
Off
</span>
)}
</td>
<td className="px-4 py-2 text-right">
<Link
href={`/admin/water-log/users/${r.workerId}?from=/admin/water-log`}
className="text-xs font-medium text-zinc-600 hover:text-zinc-900"
>
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,236 @@
"use client";
/**
* Manual time entry — admin form to add a manual log to a timesheet.
* Captures start, end (optional), break, task, optional GPS, and a
* mandatory reason (required for payroll / labor-law compliance).
*/
import React, { useState, useTransition } from "react";
import { addManualTimeEntry } from "@/actions/time-tracking/entries";
function captureGps(): Promise<{ lat: number; lng: number; accuracy_m?: number } | null> {
return new Promise((resolve) => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) =>
resolve({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracy_m: pos.coords.accuracy,
}),
() => resolve(null),
{ enableHighAccuracy: true, timeout: 5000 },
);
});
}
export default function ManualEntryForm({
brandId,
workerId,
periodStart,
periodEnd,
onClose,
onCreated,
}: {
brandId: string;
workerId: string;
periodStart: string;
periodEnd: string;
onClose: () => void;
onCreated: (entryId: string) => void;
}) {
const [date, setDate] = useState(periodStart);
const [clockIn, setClockIn] = useState("08:00");
const [clockOut, setClockOut] = useState("17:00");
const [hasClockOut, setHasClockOut] = useState(true);
const [breakMin, setBreakMin] = useState(30);
const [taskName, setTaskName] = useState("Field work");
const [notes, setNotes] = useState("");
const [reason, setReason] = useState("");
const [gps, setGps] = useState<{ lat: number; lng: number; accuracy_m?: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function toIso(d: string, t: string): string {
return new Date(`${d}T${t}:00`).toISOString();
}
function handleCaptureGps() {
setError(null);
void captureGps().then((g) => {
if (!g) {
setError("Couldn't get GPS — that's OK, you can save without it.");
} else {
setGps(g);
}
});
}
function handleSubmit() {
setError(null);
if (!reason || reason.length < 3) {
setError("A manual-entry reason is required (min 3 chars).");
return;
}
startTransition(async () => {
const r = await addManualTimeEntry({
brandId,
workerId,
taskName,
clockIn: toIso(date, clockIn),
clockOut: hasClockOut ? toIso(date, clockOut) : null,
lunchBreakMinutes: breakMin,
notes: notes || undefined,
manualReason: reason,
gps: gps ?? undefined,
});
if (!r.success) {
setError(r.error ?? "Save failed");
return;
}
onCreated(r.entry_id!);
});
}
return (
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-zinc-500">
Add manual entry
</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Field label="Date">
<input
type="date"
value={date}
min={periodStart}
max={periodEnd}
onChange={(e) => setDate(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
<Field label="Task">
<input
type="text"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
<Field label="Clock in">
<input
type="time"
value={clockIn}
onChange={(e) => setClockIn(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
<Field label="Clock out">
<div className="flex items-center gap-2">
<input
type="time"
value={clockOut}
disabled={!hasClockOut}
onChange={(e) => setClockOut(e.target.value)}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-100"
/>
<label className="flex items-center gap-1 text-xs text-zinc-600">
<input
type="checkbox"
checked={hasClockOut}
onChange={(e) => setHasClockOut(e.target.checked)}
/>
Has clock-out
</label>
</div>
</Field>
<Field label="Break (minutes)">
<input
type="number"
min={0}
max={720}
value={breakMin}
onChange={(e) => setBreakMin(Number(e.target.value))}
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
<Field label="GPS (optional)">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCaptureGps}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs hover:bg-zinc-50"
>
Capture current location
</button>
{gps && (
<span className="text-xs text-zinc-600">
{gps.lat.toFixed(4)}, {gps.lng.toFixed(4)}
{gps.accuracy_m != null && ` ±${Math.round(gps.accuracy_m)}m`}
</span>
)}
</div>
</Field>
</div>
<div className="mt-3">
<Field label="Manual entry reason (required)">
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Forgot to clock in — was at the barn from 7am"
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
</div>
<div className="mt-3">
<Field label="Notes (optional)">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</Field>
</div>
{error && (
<div className="mt-3 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800">
{error}
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-md border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-50"
>
Cancel
</button>
<button
disabled={pending}
onClick={handleSubmit}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Saving…" : "Add entry"}
</button>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
{label}
</span>
{children}
</label>
);
}
@@ -0,0 +1,476 @@
"use client";
/**
* Timesheet detail — totals card, entries table, audit trail, and
* the approval workflow buttons (submit / approve / reject / unlock /
* add manual entry).
*
* Status-driven UI:
* - draft / rejected: "Submit for review" button + manual entry editor
* - submitted: "Approve" / "Reject" buttons (supervisor/admin)
* - approved (locked): "Unlock" button (admin only) + locked banner
*/
import React, { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
submitTimesheet,
approveTimesheet,
rejectTimesheet,
unlockTimesheet,
} from "@/actions/time-tracking/timesheets";
import { deleteTimeEntry } from "@/actions/time-tracking/entries";
import type { TimesheetStatus } from "@/db/schema/time-tracking";
import type {
TimesheetSummary,
TimesheetEntry,
AuditEntry,
} from "@/actions/time-tracking/timesheets";
import ManualEntryForm from "./ManualEntryForm";
type Caller = {
id: string;
role: string;
display_name: string | null;
email: string | null;
isAdmin: boolean;
};
function statusColor(s: TimesheetStatus): {
bg: string;
fg: string;
label: string;
} {
switch (s) {
case "draft":
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: "Draft" };
case "submitted":
return { bg: "bg-amber-100", fg: "text-amber-800", label: "Submitted" };
case "approved":
return { bg: "bg-emerald-100", fg: "text-emerald-800", label: "Approved · Locked" };
case "rejected":
return { bg: "bg-rose-100", fg: "text-rose-800", label: "Rejected" };
default: {
const _exhaustive: never = s;
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: _exhaustive };
}
}
}
function formatHM(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
return `${h}h ${m}m`;
}
function fmtTs(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export default function TimesheetDetail({
summary,
entries,
audit,
caller,
}: {
summary: TimesheetSummary;
entries: TimesheetEntry[];
audit: AuditEntry[];
caller: Caller;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [comment, setComment] = useState("");
const [rejectReason, setRejectReason] = useState("");
const [unlockNote, setUnlockNote] = useState("");
const [showManual, setShowManual] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const isLocked = !!summary.locked_at;
const canSubmit =
!isLocked && (summary.status === "draft" || summary.status === "rejected");
const canApprove =
!isLocked &&
summary.status === "submitted" &&
(caller.isAdmin || caller.role === "supervisor");
const canUnlock = isLocked && caller.isAdmin;
const canEditEntries = !isLocked && caller.isAdmin;
function call(action: () => Promise<{ success: boolean; error?: string }>) {
setError(null);
startTransition(async () => {
const r = await action();
if (!r.success) setError(r.error ?? "Action failed");
else router.refresh();
});
}
const c = statusColor(summary.status);
return (
<div className="space-y-6">
{/* Top bar: status + actions */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
<div className="flex items-center gap-3">
<span
className={`inline-block rounded-full px-3 py-1 text-xs font-semibold ${c.bg} ${c.fg}`}
>
{c.label}
</span>
{summary.submitted_at && (
<span className="text-xs text-zinc-500">
Submitted {fmtTs(summary.submitted_at)}
</span>
)}
{summary.reviewed_at && (
<span className="text-xs text-zinc-500">
Reviewed by {summary.reviewed_by_label ?? "admin"}{" "}
{fmtTs(summary.reviewed_at)}
</span>
)}
{summary.unlocked_at && summary.unlock_audit_note && (
<span className="text-xs italic text-rose-600">
Unlocked {fmtTs(summary.unlocked_at)} · &ldquo;{summary.unlock_audit_note}&rdquo;
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<a
href={`/api/time-tracking/timesheets/${summary.id}/export?format=csv`}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
CSV
</a>
<a
href={`/api/time-tracking/timesheets/${summary.id}/export?format=pdf`}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
PDF
</a>
{canEditEntries && (
<button
onClick={() => setShowManual((v) => !v)}
className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800"
>
{showManual ? "Hide manual entry" : "+ Manual entry"}
</button>
)}
</div>
</div>
{error && (
<div className="rounded-md border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
{error}
</div>
)}
{isLocked && (
<div className="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
<strong>Locked.</strong> Entries below are read-only.{" "}
{canUnlock
? "Use Unlock to make changes."
: "Ask an admin to unlock if a correction is required."}
</div>
)}
{/* Totals */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<Stat label="Total" value={formatHM(summary.total_minutes)} />
<Stat label="Regular" value={formatHM(summary.regular_minutes)} />
<Stat label="Overtime" value={formatHM(summary.overtime_minutes)} />
<Stat label="Break" value={formatHM(summary.break_minutes)} />
<Stat label="Entries" value={String(summary.entry_count)} />
</div>
{/* Manual entry editor */}
{showManual && canEditEntries && (
<ManualEntryForm
brandId={summary.brand_id}
workerId={summary.field_worker_id}
periodStart={summary.period_start}
periodEnd={summary.period_end}
onClose={() => setShowManual(false)}
onCreated={() => {
setShowManual(false);
router.refresh();
}}
/>
)}
{/* Approval actions */}
{(canSubmit || canApprove || canUnlock) && (
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-zinc-500">
Workflow
</h3>
<div className="flex flex-wrap items-end gap-3">
{canSubmit && (
<div className="flex flex-1 flex-col gap-2">
<label className="text-xs text-zinc-500">
Notes for reviewer (optional)
</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="e.g. includes a 2h drive to second field"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
disabled={pending}
/>
<button
disabled={pending}
onClick={() => call(() => submitTimesheet(summary.id, comment || undefined))}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
>
Submit for review
</button>
</div>
)}
{canApprove && (
<>
<div className="flex flex-1 flex-col gap-2">
<label className="text-xs text-zinc-500">
Approver comment (optional)
</label>
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Looks good"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
disabled={pending}
/>
<div className="flex gap-2">
<button
disabled={pending}
onClick={() =>
call(() => approveTimesheet(summary.id, comment || undefined))
}
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
>
Approve & lock
</button>
<button
disabled={pending}
onClick={() => {
if (rejectReason.length < 3) {
setError("A rejection reason (min 3 chars) is required.");
return;
}
call(() => rejectTimesheet(summary.id, rejectReason));
}}
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-50"
>
Reject
</button>
</div>
</div>
<div className="flex flex-1 flex-col gap-2">
<label className="text-xs text-zinc-500">
Rejection reason (required if rejecting)
</label>
<input
type="text"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Tuesday needs lunch break added"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
disabled={pending}
/>
</div>
</>
)}
{canUnlock && (
<div className="flex flex-1 flex-col gap-2">
<label className="text-xs font-semibold text-rose-700">
Unlock audit note (required, min 5 chars)
</label>
<textarea
value={unlockNote}
onChange={(e) => setUnlockNote(e.target.value)}
rows={2}
placeholder="Payroll found an under-reported hour on Wednesday. Re-locking after correction."
className="rounded-md border border-rose-300 px-3 py-2 text-sm"
disabled={pending}
/>
<button
disabled={pending || unlockNote.length < 5}
onClick={() => call(() => unlockTimesheet(summary.id, unlockNote))}
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-50"
>
Unlock timesheet
</button>
</div>
)}
</div>
</div>
)}
{/* Entries */}
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
<div className="border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-700">
Entries ({entries.length})
</div>
{entries.length === 0 ? (
<div className="p-8 text-center text-sm text-zinc-500">
No entries yet for this period.
</div>
) : (
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-white text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Task</th>
<th className="px-4 py-3">Clock in / out</th>
<th className="px-4 py-3 text-right">Break</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3">GPS</th>
<th className="px-4 py-3">Kind</th>
{canEditEntries && <th className="px-4 py-3 text-right">Actions</th>}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{entries.map((e) => (
<tr key={e.id}>
<td className="px-4 py-3 text-zinc-700">
{e.clock_in.slice(0, 10)}
</td>
<td className="px-4 py-3 text-zinc-900">{e.task_name}</td>
<td className="px-4 py-3 tabular-nums text-zinc-700">
{e.clock_in.slice(11, 16)} {" "}
{e.clock_out ? e.clock_out.slice(11, 16) : "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
{e.lunch_break_minutes}m
</td>
<td className="px-4 py-3 text-right font-medium tabular-nums text-zinc-900">
{formatHM(e.total_minutes)}
</td>
<td className="px-4 py-3">
{e.gps_verified ? (
<span title="GPS verified (≤100m)">📍</span>
) : (
<span className="text-zinc-400"></span>
)}
</td>
<td className="px-4 py-3">
{e.entry_kind === "manual" ? (
<span
title={e.manual_reason ?? ""}
className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800"
>
manual
</span>
) : (
<span className="text-[11px] text-zinc-500">clock</span>
)}
</td>
{canEditEntries && (
<td className="px-4 py-3 text-right">
{confirmDelete === e.id ? (
<span className="space-x-2">
<button
disabled={pending}
onClick={() =>
call(async () => {
const r = await deleteTimeEntry(
e.id,
"Removed from timesheet detail page",
);
return r;
})
}
className="rounded bg-rose-600 px-2 py-0.5 text-[11px] font-medium text-white"
>
Confirm
</button>
<button
onClick={() => setConfirmDelete(null)}
className="rounded border border-zinc-300 px-2 py-0.5 text-[11px]"
>
Cancel
</button>
</span>
) : (
<button
onClick={() => setConfirmDelete(e.id)}
className="text-[11px] text-rose-600 hover:underline"
>
Delete
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Audit trail */}
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
<div className="border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-700">
Audit trail ({audit.length})
</div>
{audit.length === 0 ? (
<div className="p-8 text-center text-sm text-zinc-500">No audit entries.</div>
) : (
<ul className="divide-y divide-zinc-100">
{audit.map((a) => (
<li key={a.id} className="px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className="font-mono text-[11px] uppercase tracking-wider text-zinc-500">
{a.action}
</span>{" "}
<span className="text-zinc-900">{a.actor_label}</span>
</div>
<span className="text-xs text-zinc-500">
{fmtTs(a.created_at)}
</span>
</div>
{Object.keys(a.details).length > 0 && (
<pre className="mt-1 overflow-x-auto rounded bg-zinc-50 px-3 py-2 text-[11px] text-zinc-600">
{JSON.stringify(a.details, null, 2)}
</pre>
)}
</li>
))}
</ul>
)}
</div>
<div className="text-sm">
<Link
href="/admin/time-tracking/timesheets"
className="text-zinc-600 hover:underline"
>
Back to timesheets
</Link>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-zinc-200 bg-white p-3">
<div className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
{label}
</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-zinc-900">
{value}
</div>
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
/**
* Timesheets list — status filter, worker filter, period filter,
* per-row summary + click-through to the detail page. Mobile-friendly.
*/
import React, { useMemo } from "react";
import Link from "next/link";
import type { TimesheetStatus } from "@/db/schema/time-tracking";
import type { TimesheetSummary } from "@/actions/time-tracking/timesheets";
type Worker = { id: string; name: string; role: string };
type Filters = {
status: TimesheetStatus | "all";
workerId?: string;
periodStart?: string;
periodEnd?: string;
};
function statusColor(s: TimesheetStatus): {
bg: string;
fg: string;
label: string;
} {
switch (s) {
case "draft":
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: "Draft" };
case "submitted":
return { bg: "bg-amber-100", fg: "text-amber-800", label: "Submitted" };
case "approved":
return { bg: "bg-emerald-100", fg: "text-emerald-800", label: "Approved · Locked" };
case "rejected":
return { bg: "bg-rose-100", fg: "text-rose-800", label: "Rejected" };
default: {
// exhaustiveness guard
const _exhaustive: never = s;
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: _exhaustive };
}
}
}
function formatHM(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
return `${h}h ${m}m`;
}
export default function TimesheetsList({
rows,
total,
workers,
filters,
}: {
rows: TimesheetSummary[];
total: number;
workers: Worker[];
filters: Filters;
}) {
const workerById = useMemo(() => {
const m = new Map<string, Worker>();
for (const w of workers) m.set(w.id, w);
return m;
}, [workers]);
return (
<div className="space-y-4">
{/* Filter bar */}
<form className="flex flex-wrap items-end gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Status
</label>
<select
name="status"
defaultValue={filters.status}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
>
<option value="all">All</option>
<option value="draft">Draft</option>
<option value="submitted">Submitted</option>
<option value="approved">Approved / Locked</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Worker
</label>
<select
name="workerId"
defaultValue={filters.workerId ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
>
<option value="">All workers</option>
{workers.map((w) => (
<option key={w.id} value={w.id}>
{w.name} ({w.role})
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Period start
</label>
<input
name="periodStart"
type="date"
defaultValue={filters.periodStart ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Period end
</label>
<input
name="periodEnd"
type="date"
defaultValue={filters.periodEnd ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800"
>
Apply
</button>
<Link
href="/admin/time-tracking/timesheets"
className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
>
Reset
</Link>
</form>
{rows.length === 0 ? (
<div className="rounded-2xl border border-dashed border-zinc-300 bg-white p-10 text-center text-sm text-zinc-500">
No timesheets match the current filters.
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-3">Worker</th>
<th className="px-4 py-3">Period</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3 text-right">OT</th>
<th className="px-4 py-3 text-right">Entries</th>
<th className="px-4 py-3 text-right">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.map((r) => {
const c = statusColor(r.status);
return (
<tr key={r.id} className="hover:bg-zinc-50">
<td className="px-4 py-3">
<Link
href={`/admin/time-tracking/timesheets/${r.id}`}
className="font-medium text-zinc-900 hover:underline"
>
{r.worker_name}
</Link>
<span className="ml-2 text-xs text-zinc-500">
{r.worker_role}
</span>
</td>
<td className="px-4 py-3 text-zinc-700">
{r.period_start} {r.period_end}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-semibold ${c.bg} ${c.fg}`}
>
{c.label}
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
{formatHM(r.total_minutes)}
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
{formatHM(r.overtime_minutes)}
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
{r.entry_count}
</td>
<td className="px-4 py-3 text-right text-xs text-zinc-500">
{new Date(r.updated_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}