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
+1 -1
View File
@@ -33,7 +33,7 @@ export const fieldWorkers = pgTable(
name: text("name").notNull(),
pinHash: text("pin_hash").notNull(),
role: text("role", {
enum: ["worker", "time_admin", "irrigator", "water_admin"],
enum: ["worker", "time_admin", "irrigator", "water_admin", "driver", "supervisor"],
})
.notNull()
.default("worker"),
+207 -1
View File
@@ -12,6 +12,9 @@
* `worker_id` column on the downstream tables
* (`time_tracking_logs`, `time_tracking_notification_log`) was
* renamed to `field_worker_id` and the FK repointed.
*
* Cycle 12 (Phase 2): GPS columns + manual entries + audit log +
* timesheet approval workflow (0098_time_tracking_timesheets.sql).
*/
import {
pgTable,
@@ -23,8 +26,10 @@ import {
timestamp,
index,
jsonb,
date,
doublePrecision,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { brands, adminUsers } from "./brands";
import { fieldWorkers } from "./field-workers";
export const timeTrackingSettings = pgTable(
@@ -115,6 +120,32 @@ export const timeTrackingLogs = pgTable(
submittedVia: text("submitted_via", {
enum: ["manual", "field", "import"],
}).notNull().default("manual"),
// ── Migration 0098: GPS + manual entries + edit audit ────────
/** `'clock'` = from the field app, `'manual'` = admin-entered. */
entryKind: text("entry_kind", { enum: ["clock", "manual"] })
.notNull()
.default("clock"),
/** Required when `entry_kind = 'manual'`. Surfaced in payroll exports. */
manualReason: text("manual_reason"),
/** GPS columns — captured at clock-in / clock-out. Nullable: device
* may have denied geolocation, in which case `gpsVerified = false`. */
clockInLat: doublePrecision("clock_in_lat"),
clockInLng: doublePrecision("clock_in_lng"),
clockInAccuracyM: doublePrecision("clock_in_accuracy_m"),
clockOutLat: doublePrecision("clock_out_lat"),
clockOutLng: doublePrecision("clock_out_lng"),
clockOutAccuracyM: doublePrecision("clock_out_accuracy_m"),
/** True iff the device supplied a fix with accuracy ≤ 100m. */
gpsVerified: boolean("gps_verified").notNull().default(false),
/** Edit audit — filled every time a server action updates the row
* after creation. Never cleared. */
editedAt: timestamp("edited_at", { withTimezone: true }),
editedBy: uuid("edited_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
editReason: text("edit_reason"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
@@ -125,6 +156,11 @@ export const timeTrackingLogs = pgTable(
t.fieldWorkerId,
),
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
brandKindIdx: index("time_tracking_logs_brand_kind_idx").on(
t.brandId,
t.entryKind,
t.clockIn,
),
}),
);
@@ -154,6 +190,164 @@ export const timeTrackingNotificationLog = pgTable(
},
);
// ── Migration 0098: Timesheet + approval workflow ─────────────────────────
export const TIMESHEET_STATUSES = [
"draft",
"submitted",
"approved",
"rejected",
] as const;
export type TimesheetStatus = (typeof TIMESHEET_STATUSES)[number];
export const timeTrackingTimesheets = pgTable(
"time_tracking_timesheets",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
fieldWorkerId: uuid("field_worker_id")
.notNull()
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
periodStart: date("period_start").notNull(),
periodEnd: date("period_end").notNull(),
status: text("status", { enum: TIMESHEET_STATUSES })
.notNull()
.default("draft"),
// Submission
submittedAt: timestamp("submitted_at", { withTimezone: true }),
submittedBy: uuid("submitted_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
submittedNotes: text("submitted_notes"),
// Approval (locks the timesheet)
reviewedAt: timestamp("reviewed_at", { withTimezone: true }),
reviewedBy: uuid("reviewed_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
reviewerComment: text("reviewer_comment"),
// Rejection
rejectedAt: timestamp("rejected_at", { withTimezone: true }),
rejectedBy: uuid("rejected_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
rejectionReason: text("rejection_reason"),
// Lock mirror (always equals reviewed_at when status=approved)
lockedAt: timestamp("locked_at", { withTimezone: true }),
lockedBy: uuid("locked_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
// Unlock (rare, admin-only, MUST carry an audit note)
unlockedAt: timestamp("unlocked_at", { withTimezone: true }),
unlockedBy: uuid("unlocked_by").references(() => adminUsers.id, {
onDelete: "set null",
}),
unlockAuditNote: text("unlock_audit_note").notNull().default(""),
// Pre-computed totals (recomputed by recompute_timesheet_totals RPC)
totalMinutes: integer("total_minutes").notNull().default(0),
regularMinutes: integer("regular_minutes").notNull().default(0),
overtimeMinutes: integer("overtime_minutes").notNull().default(0),
breakMinutes: integer("break_minutes").notNull().default(0),
entryCount: integer("entry_count").notNull().default(0),
/** Map of date string ("YYYY-MM-DD") → minutes worked that day. */
dailyTotals: jsonb("daily_totals").notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
workerPeriodUniq: index(
"time_tracking_timesheets_worker_period_uniq",
).on(t.fieldWorkerId, t.periodStart, t.periodEnd),
brandStatusIdx: index("time_tracking_timesheets_brand_status_idx").on(
t.brandId,
t.status,
t.periodStart,
),
brandWorkerIdx: index("time_tracking_timesheets_brand_worker_idx").on(
t.brandId,
t.fieldWorkerId,
),
}),
);
/** Append-only audit log — every mutating action on a timesheet or its
* underlying logs MUST write a row here in the same DB transaction. */
export const timeTrackingAuditLog = pgTable(
"time_tracking_audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
/** Admin user (or null when the actor was the worker themselves). */
actorId: uuid("actor_id").references(() => adminUsers.id, {
onDelete: "set null",
}),
actorLabel: text("actor_label").notNull(),
actorKind: text("actor_kind").notNull().default("admin"),
action: text("action").notNull(),
entityType: text("entity_type").notNull(),
entityId: uuid("entity_id"),
details: jsonb("details").notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandRecentIdx: index("time_tracking_audit_log_brand_recent_idx").on(
t.brandId,
t.createdAt,
),
entityIdx: index("time_tracking_audit_log_entity_idx").on(
t.entityType,
t.entityId,
),
}),
);
/** Supervisor ↔ supervisee edges. The brand admin assigns edges; the
* approval queue filters by them. */
export const timeTrackingSupervisorAssignments = pgTable(
"time_tracking_supervisor_assignments",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
supervisorFieldWorkerId: uuid("supervisor_field_worker_id")
.notNull()
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
superviseeFieldWorkerId: uuid("supervisee_field_worker_id")
.notNull()
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
uniq: index("time_tracking_supervisor_assignments_uniq").on(
t.supervisorFieldWorkerId,
t.superviseeFieldWorkerId,
),
supervisorIdx: index(
"time_tracking_supervisor_assignments_supervisor_idx",
).on(t.supervisorFieldWorkerId),
}),
);
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
// Cycle 10: `TimeTrackingWorker` was removed. Use `FieldWorker` from
// `./field-workers` instead; filter by role = 'worker' or 'time_admin'
@@ -162,6 +356,18 @@ export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
export type TimeTrackingNotificationLog =
typeof timeTrackingNotificationLog.$inferSelect;
export type TimeTrackingTimesheet = typeof timeTrackingTimesheets.$inferSelect;
export type TimeTrackingTimesheetInsert =
typeof timeTrackingTimesheets.$inferInsert;
export type TimeTrackingAuditLogEntry =
typeof timeTrackingAuditLog.$inferSelect;
export type TimeTrackingSupervisorAssignment =
typeof timeTrackingSupervisorAssignments.$inferSelect;
// ── Inferred types ──
// 0098 — daily totals JSON shape stored on the timesheet
export type DailyTotals = Record<string, number>;
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────