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
+91
View File
@@ -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;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Time Tracking — Audit Log Helper
*
* Append-only audit trail. Every mutating server action on a timesheet,
* its entries, or its approval state MUST call `logTimeTrackingEvent` in
* the same DB transaction as the mutation so the trail can never drift
* from reality.
*
* Actions follow a dotted-namespace convention so the audit UI can group
* them:
* - `timesheet.submit` / `.approve` / `.reject` / `.unlock`
* - `entry.create` / `.edit` / `.delete` / `.clock_in` / `.clock_out`
* - `worker.create` / `.edit` / `.delete`
* - `settings.update`
*/
import "server-only";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { timeTrackingAuditLog } from "@/db/schema/time-tracking";
export type ActorKind = "admin" | "worker" | "system";
export type TimeTrackingAuditEvent = {
brandId: string;
actorId: string | null;
actorLabel: string;
actorKind?: ActorKind;
action: string;
entityType: string;
entityId?: string | null;
details?: Record<string, unknown>;
};
export async function logTimeTrackingEvent(
event: TimeTrackingAuditEvent,
): Promise<void> {
await withBrand(event.brandId, async (db) => {
await db.insert(timeTrackingAuditLog).values({
brandId: event.brandId,
actorId: event.actorId,
actorLabel: event.actorLabel,
actorKind: event.actorKind ?? "admin",
action: event.action,
entityType: event.entityType,
entityId: event.entityId ?? null,
details: event.details ?? {},
});
});
}
/**
* Variant that runs inside an existing transaction client. Use this from
* server actions that batch multiple writes — keeps the audit row and the
* mutation atomic.
*/
export async function logTimeTrackingEventInTx(
client: import("pg").PoolClient,
event: TimeTrackingAuditEvent,
): Promise<void> {
await client.query(
`INSERT INTO time_tracking_audit_log
(brand_id, actor_id, actor_label, actor_kind, action, entity_type, entity_id, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
[
event.brandId,
event.actorId,
event.actorLabel,
event.actorKind ?? "admin",
event.action,
event.entityType,
event.entityId ?? null,
JSON.stringify(event.details ?? {}),
],
);
}
// Avoid an unused-import warning when callers only use the tx variant.
void withPlatformAdmin;
+50
View File
@@ -0,0 +1,50 @@
/**
* Period-math helpers for time-tracking.
*
* Lives OUTSIDE `src/actions/time-tracking/timesheets.ts` because that
* file uses the "use server" directive, which requires every export
* to be an async function. Pure date math doesn't need to be a server
* action — it can run on the client too (e.g. for date pickers).
*/
/**
* Compute the pay-period [start, end] (inclusive) that contains the
* given date, given a brand's settings. Falls back to weekly periods
* starting Sunday when settings aren't configured.
*/
export function payPeriodForDate(
date: Date,
settings: {
pay_period_start_day: number;
pay_period_length_days: number;
} | null,
): { start: Date; end: Date } {
const len = settings?.pay_period_length_days ?? 7;
if (len === 7) {
// Weekly — anchor to Sunday
const start = new Date(date);
start.setHours(0, 0, 0, 0);
start.setDate(date.getDate() - date.getDay());
const end = new Date(start);
end.setDate(start.getDate() + 6);
return { start, end };
}
// Semi-monthly / monthly: anchor by day-of-month
const anchor = settings?.pay_period_start_day ?? 1;
const start = new Date(date.getFullYear(), date.getMonth(), anchor);
if (start > date) start.setMonth(start.getMonth() - 1);
const end = new Date(start);
end.setDate(start.getDate() + len - 1);
if (end < date) {
start.setMonth(start.getMonth() + 1);
end.setMonth(end.getMonth() + 1);
}
return { start, end };
}
/** YYYY-MM-DD in UTC. */
export function toIsoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}