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.
88 lines
3.1 KiB
TypeScript
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} />;
|
|
} |