9e6accb3df
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.
50 lines
1.6 KiB
TypeScript
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);
|
|
} |