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
@@ -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>
);
}