Files
route-commerce/src/lib/time-tracking/period.ts
T
Nora 9e6accb3df 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.
2026-07-04 00:27:25 -06:00

50 lines
1.6 KiB
TypeScript

/**
* 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);
}