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.
148 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
} |