Files
route-commerce/src/app/admin/time-tracking/audit/page.tsx
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

148 lines
5.6 KiB
TypeScript

/**
* /admin/time-tracking/audit
*
* Phase 2 — read-only audit trail viewer for admins. Shows every
* mutating action on a timesheet, its entries, or the related
* entities, ordered most-recent-first.
*/
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { timeTrackingAuditLog } from "@/db/schema/time-tracking";
import { PageHeader } from "@/components/admin/design-system";
import { desc, eq, sql } from "drizzle-orm";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const dynamic = "force-dynamic";
export default async function AuditPage({
searchParams,
}: {
searchParams: Promise<{ action?: string; entity?: string }>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
if (adminUser.role !== "platform_admin" && adminUser.role !== "brand_admin") {
redirect("/admin/time-tracking");
}
const brandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
const sp = await searchParams;
const rows = await withBrand(brandId, async (db) => {
const conds = [eq(timeTrackingAuditLog.brandId, brandId)];
if (sp.action) conds.push(eq(timeTrackingAuditLog.action, sp.action));
if (sp.entity) conds.push(eq(timeTrackingAuditLog.entityType, sp.entity));
return db
.select({
id: timeTrackingAuditLog.id,
actor_label: timeTrackingAuditLog.actorLabel,
actor_kind: timeTrackingAuditLog.actorKind,
action: timeTrackingAuditLog.action,
entity_type: timeTrackingAuditLog.entityType,
entity_id: timeTrackingAuditLog.entityId,
details: timeTrackingAuditLog.details,
created_at: timeTrackingAuditLog.createdAt,
})
.from(timeTrackingAuditLog)
.where(sql`1=1`)
.orderBy(desc(timeTrackingAuditLog.createdAt))
.limit(500);
});
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<PageHeader
title="Audit trail"
subtitle={`Last ${rows.length} events`}
className="px-6 pt-6"
/>
<div className="px-6 pb-8">
<form className="mb-4 flex flex-wrap items-end gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
<label className="flex flex-col gap-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Action
</span>
<input
name="action"
defaultValue={sp.action ?? ""}
placeholder="e.g. timesheet.approve"
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
Entity type
</span>
<select
name="entity"
defaultValue={sp.entity ?? ""}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
>
<option value="">All</option>
<option value="timesheet">timesheet</option>
<option value="log">log</option>
<option value="worker">worker</option>
</select>
</label>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white"
>
Filter
</button>
</form>
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-3">When</th>
<th className="px-4 py-3">Action</th>
<th className="px-4 py-3">Actor</th>
<th className="px-4 py-3">Entity</th>
<th className="px-4 py-3">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.map((r) => (
<tr key={r.id}>
<td className="px-4 py-3 whitespace-nowrap text-xs text-zinc-500">
{new Date(r.created_at).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</td>
<td className="px-4 py-3">
<span className="font-mono text-[11px] uppercase tracking-wider text-zinc-700">
{r.action}
</span>
</td>
<td className="px-4 py-3 text-zinc-900">
{r.actor_label}
<span className="ml-1 text-xs text-zinc-500">
({r.actor_kind})
</span>
</td>
<td className="px-4 py-3 text-xs text-zinc-500">
{r.entity_type}
{r.entity_id && (
<span className="ml-1 font-mono">{r.entity_id.slice(0, 8)}</span>
)}
</td>
<td className="px-4 py-3">
<pre className="max-w-md overflow-x-auto rounded bg-zinc-50 px-2 py-1 text-[11px] text-zinc-600">
{JSON.stringify(r.details, null, 0)}
</pre>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}