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

88 lines
3.1 KiB
TypeScript

import Link from "next/link";
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
import { FleetRouteSummary } from "@/components/time-tracking/admin/DailyRouteSummary";
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/admin/design-system";
import { getFleetRouteSummary } from "@/actions/time-tracking/route-summary";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
export const dynamic = "force-dynamic";
const TABS = [
{ href: "/admin/time-tracking", label: "Overview" },
{ href: "/admin/time-tracking/timesheets", label: "Timesheets" },
{ href: "/admin/time-tracking/approvals", label: "Approvals" },
{ href: "/admin/time-tracking/audit", label: "Audit" },
];
export default async function AdminTimeTrackingPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const isPlatformAdmin = adminUser.role === "platform_admin";
const isTuxedoAdmin =
adminUser.role === "brand_admin" && adminUser.brand_id === TUXEDO_BRAND_ID;
const isIRDAdmin =
adminUser.role === "brand_admin" && adminUser.brand_id === IRD_BRAND_ID;
if (!isPlatformAdmin && !isTuxedoAdmin && !isIRDAdmin) {
redirect("/admin/pickup");
}
const effectiveBrandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
const sp = await searchParams;
const activeTab = sp.tab ?? "overview";
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<PageHeader
title="Time Tracking"
subtitle="Manage workers, tasks, timesheets, and approvals"
className="px-6 pt-6"
/>
<div className="px-6 pb-8">
{/* Tab nav */}
<nav className="mb-6 flex gap-1 rounded-xl border border-zinc-200 bg-white p-1">
{TABS.map((t) => {
const active =
t.href === "/admin/time-tracking"
? activeTab === "overview" || t.href === `/admin/time-tracking${activeTab === "overview" ? "" : ""}`
: activeTab && t.href.endsWith(activeTab);
return (
<Link
key={t.href}
href={t.href}
className={`flex-1 rounded-lg px-4 py-2 text-center text-sm font-medium transition-colors ${
active
? "bg-zinc-900 text-white"
: "text-zinc-600 hover:bg-zinc-100"
}`}
>
{t.label}
</Link>
);
})}
</nav>
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
<div className="mt-6">
<FleetRouteSummaryClient brandId={effectiveBrandId} />
</div>
</div>
</div>
);
}
async function FleetRouteSummaryClient({ brandId }: { brandId: string }) {
const today = new Date().toISOString().slice(0, 10);
const rows = await getFleetRouteSummary(brandId, today);
return <FleetRouteSummary brandId={brandId} rows={rows} date={today} />;
}