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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user