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