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,91 @@
|
||||
/**
|
||||
* Time Tracking — Offline queue helpers.
|
||||
*
|
||||
* The field app runs in places with patchy cellular (rural Colorado
|
||||
* valley floors). When `clockInWorker` / `clockOutWorker` fail because
|
||||
* the device is offline, the action is enqueued here and retried by
|
||||
* the standard offline dispatcher (`src/actions/offline-dispatcher.ts`).
|
||||
*
|
||||
* The action envelope matches the rest of the queue: an `actionName`
|
||||
* plus a JSON-serialisable `payload`. Server-side replay routes the
|
||||
* payload back to the same field actions.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { enqueueAction, getQueuedActions } from "./queue";
|
||||
|
||||
function uuid(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return (crypto as Crypto).randomUUID();
|
||||
}
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export type QueuedClockEvent =
|
||||
| {
|
||||
kind: "clock_in";
|
||||
brandId: string;
|
||||
taskName: string;
|
||||
gps?: { lat: number; lng: number; accuracy_m?: number } | null;
|
||||
/** Wall-clock time at which the worker hit the button. */
|
||||
clientTs: string;
|
||||
}
|
||||
| {
|
||||
kind: "clock_out";
|
||||
brandId: string;
|
||||
lunchMinutes: number;
|
||||
notes?: string;
|
||||
gps?: { lat: number; lng: number; accuracy_m?: number } | null;
|
||||
clientTs: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Try the action; on network failure, enqueue it. Resolves with the
|
||||
* action's normal success payload, or `{ queued: true, queueId }` when
|
||||
* the call fell back to the offline queue.
|
||||
*/
|
||||
export async function tryOrQueueClock(
|
||||
event: QueuedClockEvent,
|
||||
liveCall: () => Promise<{
|
||||
success: boolean;
|
||||
log_id?: string;
|
||||
clock_in?: string;
|
||||
clock_out?: string;
|
||||
total_minutes?: number;
|
||||
error?: string;
|
||||
}>,
|
||||
): Promise<
|
||||
| { success: true; log_id?: string; queued: false }
|
||||
| { success: false; queued: false; error: string }
|
||||
| { success: true; queued: true; queueId: string }
|
||||
> {
|
||||
try {
|
||||
const r = await liveCall();
|
||||
if (r.success) return { success: true, log_id: r.log_id, queued: false };
|
||||
return { success: false, queued: false, error: r.error ?? "Failed" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!/network|fetch|failed to fetch|offline/i.test(msg)) {
|
||||
return { success: false, queued: false, error: msg };
|
||||
}
|
||||
// Genuine offline — enqueue for later replay.
|
||||
const queueId = uuid();
|
||||
await enqueueAction(
|
||||
event.kind === "clock_in" ? "timeTracking.clockIn" : "timeTracking.clockOut",
|
||||
event,
|
||||
{ id: queueId },
|
||||
);
|
||||
return { success: true, queued: true, queueId };
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspect the queue for any pending clock events. Surfaced in the
|
||||
* admin "Recent Activity" panel as "X events pending sync". */
|
||||
export async function pendingClockEvents(): Promise<number> {
|
||||
const rows = await getQueuedActions();
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.actionName === "timeTracking.clockIn" ||
|
||||
r.actionName === "timeTracking.clockOut",
|
||||
).length;
|
||||
}
|
||||
Reference in New Issue
Block a user