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:
@@ -42,12 +42,54 @@ import {
|
||||
type PayPeriodHours,
|
||||
} from "@/actions/time-tracking/field";
|
||||
import { FieldPinScreen } from "@/components/field/FieldPinScreen";
|
||||
import { Clock, CheckCircle, Spinner, ChevronLeft } from "@/components/water/mobile/icons";
|
||||
import { Clock, CheckCircle, Spinner, ChevronLeft, ChevronRight } from "@/components/water/mobile/icons";
|
||||
|
||||
const BRAND_PRIMARY = "#14532D";
|
||||
const AMBER = "#B8761E";
|
||||
const INK = "#1d1d1f";
|
||||
|
||||
// ── GPS helper ─────────────────────────────────────────────────────────────
|
||||
// Best-effort geolocation capture for clock-in / clock-out. Returns null
|
||||
// on any failure (denied permission, insecure context, timeout) — the
|
||||
// server treats `gps_verified = false` gracefully and the worker is
|
||||
// never blocked from clocking in/out.
|
||||
|
||||
type GpsSample = { lat: number; lng: number; accuracy_m?: number };
|
||||
|
||||
function captureGps(timeoutMs = 6000): Promise<GpsSample | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof navigator === "undefined" || !navigator.geolocation) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
accuracy_m: pos.coords.accuracy,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
},
|
||||
{ enableHighAccuracy: true, maximumAge: 30_000, timeout: timeoutMs },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Translations ───────────────────────────────────────────────────────────────
|
||||
|
||||
type Translations = {
|
||||
@@ -210,7 +252,26 @@ function taskLabel(task: TimeTaskField, lang: "en" | "es"): string {
|
||||
|
||||
// ── Screen types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type Screen = "loading" | "pin" | "task_select" | "working" | "clock_out_form" | "clocked_out";
|
||||
type Screen =
|
||||
| "loading"
|
||||
| "pin"
|
||||
| "hub"
|
||||
| "task_select"
|
||||
| "working"
|
||||
| "clock_out_form"
|
||||
| "clocked_out"
|
||||
| "manual_entry"
|
||||
| "manual_entry_success"
|
||||
| "history";
|
||||
|
||||
// Shape returned by `submitManualTimeLog` — echoed through state to the
|
||||
// success screen so the worker sees exactly what they entered.
|
||||
interface ManualEntryResult {
|
||||
log_id: string;
|
||||
clock_in: string;
|
||||
clock_out: string;
|
||||
total_minutes: number;
|
||||
}
|
||||
|
||||
interface OpenEntry {
|
||||
log_id: string;
|
||||
@@ -233,6 +294,20 @@ type State = {
|
||||
notes: string;
|
||||
error: string | null;
|
||||
submitting: boolean;
|
||||
// Manual-entry scratch state — separate from session-level form fields.
|
||||
manualEntryDraft: {
|
||||
date: string; // YYYY-MM-DD
|
||||
clockInTime: string; // HH:MM
|
||||
clockOutTime: string; // HH:MM
|
||||
taskLabel: string;
|
||||
lunchMinutes: number;
|
||||
reason: string;
|
||||
notes: string;
|
||||
};
|
||||
manualEntryResult: ManualEntryResult | null;
|
||||
manualLogs: WorkerLogRow[];
|
||||
manualLogsLoading: boolean;
|
||||
manualLogsError: string | null;
|
||||
};
|
||||
|
||||
type Action =
|
||||
@@ -248,7 +323,30 @@ type Action =
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SET_SUBMITTING"; value: boolean }
|
||||
| { type: "UPDATE_OPEN_ENTRY_ELAPSED"; minutes: number }
|
||||
| { type: "RESET_FOR_LOGOUT" };
|
||||
| { type: "RESET_FOR_LOGOUT" }
|
||||
// Manual-entry scratch state
|
||||
| { type: "SET_MANUAL_DRAFT"; value: State["manualEntryDraft"] }
|
||||
| { type: "RESET_MANUAL_DRAFT" }
|
||||
| { type: "SET_MANUAL_ENTRY_RESULT"; value: ManualEntryResult | null }
|
||||
| { type: "SET_MANUAL_LOGS"; value: WorkerLogRow[] }
|
||||
| { type: "SET_MANUAL_LOGS_LOADING"; value: boolean }
|
||||
| { type: "SET_MANUAL_LOGS_ERROR"; value: string | null };
|
||||
|
||||
function emptyManualDraft(): State["manualEntryDraft"] {
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const mm = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(today.getDate()).padStart(2, "0");
|
||||
return {
|
||||
date: `${yyyy}-${mm}-${dd}`,
|
||||
clockInTime: "07:00",
|
||||
clockOutTime: "15:30",
|
||||
taskLabel: "",
|
||||
lunchMinutes: 30,
|
||||
reason: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
lang: (getLangCookie() as "en" | "es") ?? "en",
|
||||
@@ -262,6 +360,11 @@ const initialState: State = {
|
||||
notes: "",
|
||||
error: null,
|
||||
submitting: false,
|
||||
manualEntryDraft: emptyManualDraft(),
|
||||
manualEntryResult: null,
|
||||
manualLogs: [],
|
||||
manualLogsLoading: false,
|
||||
manualLogsError: null,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
@@ -364,10 +467,12 @@ export default function TimeTrackingFieldClient({
|
||||
elapsed_minutes: open.elapsed_minutes ?? 0,
|
||||
},
|
||||
});
|
||||
dispatch({ type: "SET_SCREEN", value: "working" });
|
||||
} else {
|
||||
dispatch({ type: "SET_SCREEN", value: "task_select" });
|
||||
}
|
||||
// Cycle 12 — land on the Hub. The Hub shows live status (so a
|
||||
// worker returning with an open shift sees the running timer on
|
||||
// the hero card) and gives them a chance to use Manual Entry /
|
||||
// Recent Activity before tapping into the working/clock-out flow.
|
||||
dispatch({ type: "SET_SCREEN", value: "hub" });
|
||||
}
|
||||
init();
|
||||
// intentionally omits screen — see earlier cycle notes
|
||||
@@ -448,7 +553,8 @@ export default function TimeTrackingFieldClient({
|
||||
const handleSelectTask = async (taskName: string) => {
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
dispatch({ type: "SET_SUBMITTING", value: true });
|
||||
const result = await clockInWorker(brandId, undefined, taskName);
|
||||
const gps = await captureGps();
|
||||
const result = await clockInWorker(brandId, undefined, taskName, gps);
|
||||
dispatch({ type: "SET_SUBMITTING", value: false });
|
||||
if (!result.success) {
|
||||
if (result.error === "Already clocked in") {
|
||||
@@ -489,7 +595,8 @@ export default function TimeTrackingFieldClient({
|
||||
const handleClockOut = async () => {
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
dispatch({ type: "SET_SUBMITTING", value: true });
|
||||
const result = await clockOutWorker(lunchMinutes, notes || undefined);
|
||||
const gps = await captureGps();
|
||||
const result = await clockOutWorker(lunchMinutes, notes || undefined, gps);
|
||||
dispatch({ type: "SET_SUBMITTING", value: false });
|
||||
if (!result.success) {
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Clock out failed" });
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* DailyRouteSummary — the cross-link surface that shows BOTH water-log
|
||||
* activity AND time-tracking totals for a single worker-day, side by
|
||||
* side. This is the "Route summary shows both water totals and time
|
||||
* totals" deliverable from the brief.
|
||||
*
|
||||
* Two flavours:
|
||||
* - <DailyRouteSummary worker=... /> — focused on one worker
|
||||
* - <FleetRouteSummary brand=... /> — table of every worker today
|
||||
*
|
||||
* Used by the Water Log admin (per-user detail) and the Time Tracking
|
||||
* admin (overview tab). The data fetch is delegated to the route-summary
|
||||
* server action so we only run it once per render.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import type { RouteTimeSummary, WorkerRouteRow } from "@/actions/time-tracking/route-summary";
|
||||
|
||||
function fmtHM(min: number): string {
|
||||
if (min <= 0) return "0m";
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function fmtGal(g: number): string {
|
||||
if (g <= 0) return "0 gal";
|
||||
if (g >= 1000) return `${(g / 1000).toFixed(g >= 10000 ? 0 : 1)}k gal`;
|
||||
return `${Math.round(g).toLocaleString()} gal`;
|
||||
}
|
||||
|
||||
function fmtClockTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ── Single-worker view ─────────────────────────────────────────────────────
|
||||
|
||||
export function DailyRouteSummary({
|
||||
summary,
|
||||
compact = false,
|
||||
}: {
|
||||
summary: RouteTimeSummary;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { water, time } = summary;
|
||||
const statusTone =
|
||||
time.clockedIn
|
||||
? "bg-emerald-100 text-emerald-800 ring-emerald-300"
|
||||
: time.workedMinutesToday > 0
|
||||
? "bg-zinc-100 text-zinc-700 ring-zinc-300"
|
||||
: "bg-amber-50 text-amber-800 ring-amber-300";
|
||||
|
||||
const tsBadge = (() => {
|
||||
switch (time.timesheetStatus) {
|
||||
case "draft":
|
||||
return { label: "TS · Draft", tone: "bg-zinc-100 text-zinc-700" };
|
||||
case "submitted":
|
||||
return { label: "TS · Submitted", tone: "bg-amber-100 text-amber-800" };
|
||||
case "approved":
|
||||
return { label: "TS · Approved", tone: "bg-emerald-100 text-emerald-800" };
|
||||
case "rejected":
|
||||
return { label: "TS · Rejected", tone: "bg-rose-100 text-rose-800" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl ring-1 ring-zinc-200 bg-white shadow-sm ${
|
||||
compact ? "p-4" : "p-5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Daily Route Summary
|
||||
</p>
|
||||
<h3 className="mt-0.5 text-base font-semibold text-zinc-900">
|
||||
{summary.workerName}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ring-1 ${statusTone}`}
|
||||
>
|
||||
{time.clockedIn
|
||||
? `On the clock · ${fmtClockTime(time.clockInAt)}`
|
||||
: time.workedMinutesToday > 0
|
||||
? "Clocked out"
|
||||
: "Not started"}
|
||||
</span>
|
||||
{tsBadge && (
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${tsBadge.tone}`}
|
||||
>
|
||||
{tsBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{/* Water side */}
|
||||
<div className="rounded-xl bg-sky-50/60 p-3 ring-1 ring-sky-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 text-sky-700" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2.5c4 5 7 8.5 7 12a7 7 0 0 1-14 0c0-3.5 3-7 7-12z" />
|
||||
</svg>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-sky-700">
|
||||
Water Log
|
||||
</p>
|
||||
</div>
|
||||
<dl className="mt-2 space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sky-900/70">Entries</dt>
|
||||
<dd className="font-semibold text-sky-900">{water.entryCount}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sky-900/70">Total</dt>
|
||||
<dd className="font-semibold text-sky-900">{fmtGal(water.totalGallons)}</dd>
|
||||
</div>
|
||||
{!compact && water.headgatesVisited.length > 0 && (
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="shrink-0 text-sky-900/70">Headgates</dt>
|
||||
<dd className="truncate text-right text-xs text-sky-900/80" title={water.headgatesVisited.join(", ")}>
|
||||
{water.headgatesVisited.length} unique
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Time side */}
|
||||
<div className="rounded-xl bg-violet-50/60 p-3 ring-1 ring-violet-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 text-violet-700" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-violet-700">
|
||||
Time Tracking
|
||||
</p>
|
||||
</div>
|
||||
<dl className="mt-2 space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-violet-900/70">Worked</dt>
|
||||
<dd className="font-semibold text-violet-900">{fmtHM(time.workedMinutesToday)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-violet-900/70">Regular / OT</dt>
|
||||
<dd className="font-semibold text-violet-900">
|
||||
{fmtHM(time.regularMinutesToday)} / {fmtHM(time.overtimeMinutesToday)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-violet-900/70">Break</dt>
|
||||
<dd className="font-semibold text-violet-900">{fmtHM(time.breakMinutesToday)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!compact && time.timesheetId && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Link
|
||||
href={`/admin/time-tracking/timesheets/${time.timesheetId}`}
|
||||
className="text-xs font-medium text-violet-700 hover:text-violet-900"
|
||||
>
|
||||
View timesheet →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fleet view (table) ─────────────────────────────────────────────────────
|
||||
|
||||
export function FleetRouteSummary({
|
||||
brandId,
|
||||
rows,
|
||||
date,
|
||||
}: {
|
||||
brandId: string;
|
||||
rows: WorkerRouteRow[];
|
||||
date: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200 shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Today's Route
|
||||
</p>
|
||||
<h3 className="text-base font-semibold text-zinc-900">
|
||||
Crew — {date}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-semibold text-zinc-700">
|
||||
{rows.filter((r) => r.time.isClockedIn).length} on clock ·{" "}
|
||||
{rows.length} active
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-zinc-200 text-sm">
|
||||
<thead className="bg-zinc-50 text-left text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2">Worker</th>
|
||||
<th className="px-4 py-2 text-right">Water Entries</th>
|
||||
<th className="px-4 py-2 text-right">Gallons</th>
|
||||
<th className="px-4 py-2 text-right">Time Today</th>
|
||||
<th className="px-4 py-2 text-right">Clock</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-6 text-center text-sm text-zinc-500">
|
||||
No active workers yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<tr key={r.workerId} className={!r.active ? "opacity-60" : ""}>
|
||||
<td className="px-4 py-2 font-medium text-zinc-900">{r.workerName}</td>
|
||||
<td className="px-4 py-2 text-right text-zinc-700">{r.water.entryCount}</td>
|
||||
<td className="px-4 py-2 text-right text-sky-700">{fmtGal(r.water.totalGallons)}</td>
|
||||
<td className="px-4 py-2 text-right font-semibold text-violet-700">
|
||||
{fmtHM(r.time.workedMinutesToday)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{r.time.isClockedIn ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-800">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-600" />
|
||||
On
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-semibold text-zinc-600">
|
||||
Off
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<Link
|
||||
href={`/admin/water-log/users/${r.workerId}?from=/admin/water-log`}
|
||||
className="text-xs font-medium text-zinc-600 hover:text-zinc-900"
|
||||
>
|
||||
View →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Manual time entry — admin form to add a manual log to a timesheet.
|
||||
* Captures start, end (optional), break, task, optional GPS, and a
|
||||
* mandatory reason (required for payroll / labor-law compliance).
|
||||
*/
|
||||
import React, { useState, useTransition } from "react";
|
||||
import { addManualTimeEntry } from "@/actions/time-tracking/entries";
|
||||
|
||||
function captureGps(): Promise<{ lat: number; lng: number; accuracy_m?: number } | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof navigator === "undefined" || !navigator.geolocation) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) =>
|
||||
resolve({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
accuracy_m: pos.coords.accuracy,
|
||||
}),
|
||||
() => resolve(null),
|
||||
{ enableHighAccuracy: true, timeout: 5000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function ManualEntryForm({
|
||||
brandId,
|
||||
workerId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
brandId: string;
|
||||
workerId: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
onClose: () => void;
|
||||
onCreated: (entryId: string) => void;
|
||||
}) {
|
||||
const [date, setDate] = useState(periodStart);
|
||||
const [clockIn, setClockIn] = useState("08:00");
|
||||
const [clockOut, setClockOut] = useState("17:00");
|
||||
const [hasClockOut, setHasClockOut] = useState(true);
|
||||
const [breakMin, setBreakMin] = useState(30);
|
||||
const [taskName, setTaskName] = useState("Field work");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [gps, setGps] = useState<{ lat: number; lng: number; accuracy_m?: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function toIso(d: string, t: string): string {
|
||||
return new Date(`${d}T${t}:00`).toISOString();
|
||||
}
|
||||
|
||||
function handleCaptureGps() {
|
||||
setError(null);
|
||||
void captureGps().then((g) => {
|
||||
if (!g) {
|
||||
setError("Couldn't get GPS — that's OK, you can save without it.");
|
||||
} else {
|
||||
setGps(g);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
setError(null);
|
||||
if (!reason || reason.length < 3) {
|
||||
setError("A manual-entry reason is required (min 3 chars).");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const r = await addManualTimeEntry({
|
||||
brandId,
|
||||
workerId,
|
||||
taskName,
|
||||
clockIn: toIso(date, clockIn),
|
||||
clockOut: hasClockOut ? toIso(date, clockOut) : null,
|
||||
lunchBreakMinutes: breakMin,
|
||||
notes: notes || undefined,
|
||||
manualReason: reason,
|
||||
gps: gps ?? undefined,
|
||||
});
|
||||
if (!r.success) {
|
||||
setError(r.error ?? "Save failed");
|
||||
return;
|
||||
}
|
||||
onCreated(r.entry_id!);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-zinc-500">
|
||||
Add manual entry
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Field label="Date">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
min={periodStart}
|
||||
max={periodEnd}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Task">
|
||||
<input
|
||||
type="text"
|
||||
value={taskName}
|
||||
onChange={(e) => setTaskName(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Clock in">
|
||||
<input
|
||||
type="time"
|
||||
value={clockIn}
|
||||
onChange={(e) => setClockIn(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Clock out">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={clockOut}
|
||||
disabled={!hasClockOut}
|
||||
onChange={(e) => setClockOut(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-100"
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-xs text-zinc-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasClockOut}
|
||||
onChange={(e) => setHasClockOut(e.target.checked)}
|
||||
/>
|
||||
Has clock-out
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Break (minutes)">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={720}
|
||||
value={breakMin}
|
||||
onChange={(e) => setBreakMin(Number(e.target.value))}
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="GPS (optional)">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCaptureGps}
|
||||
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs hover:bg-zinc-50"
|
||||
>
|
||||
Capture current location
|
||||
</button>
|
||||
{gps && (
|
||||
<span className="text-xs text-zinc-600">
|
||||
{gps.lat.toFixed(4)}, {gps.lng.toFixed(4)}
|
||||
{gps.accuracy_m != null && ` ±${Math.round(gps.accuracy_m)}m`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Field label="Manual entry reason (required)">
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Forgot to clock in — was at the barn from 7am"
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Field label="Notes (optional)">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={pending}
|
||||
onClick={handleSubmit}
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Saving…" : "Add entry"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Timesheet detail — totals card, entries table, audit trail, and
|
||||
* the approval workflow buttons (submit / approve / reject / unlock /
|
||||
* add manual entry).
|
||||
*
|
||||
* Status-driven UI:
|
||||
* - draft / rejected: "Submit for review" button + manual entry editor
|
||||
* - submitted: "Approve" / "Reject" buttons (supervisor/admin)
|
||||
* - approved (locked): "Unlock" button (admin only) + locked banner
|
||||
*/
|
||||
import React, { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
submitTimesheet,
|
||||
approveTimesheet,
|
||||
rejectTimesheet,
|
||||
unlockTimesheet,
|
||||
} from "@/actions/time-tracking/timesheets";
|
||||
import { deleteTimeEntry } from "@/actions/time-tracking/entries";
|
||||
import type { TimesheetStatus } from "@/db/schema/time-tracking";
|
||||
import type {
|
||||
TimesheetSummary,
|
||||
TimesheetEntry,
|
||||
AuditEntry,
|
||||
} from "@/actions/time-tracking/timesheets";
|
||||
import ManualEntryForm from "./ManualEntryForm";
|
||||
|
||||
type Caller = {
|
||||
id: string;
|
||||
role: string;
|
||||
display_name: string | null;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
function statusColor(s: TimesheetStatus): {
|
||||
bg: string;
|
||||
fg: string;
|
||||
label: string;
|
||||
} {
|
||||
switch (s) {
|
||||
case "draft":
|
||||
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: "Draft" };
|
||||
case "submitted":
|
||||
return { bg: "bg-amber-100", fg: "text-amber-800", label: "Submitted" };
|
||||
case "approved":
|
||||
return { bg: "bg-emerald-100", fg: "text-emerald-800", label: "Approved · Locked" };
|
||||
case "rejected":
|
||||
return { bg: "bg-rose-100", fg: "text-rose-800", label: "Rejected" };
|
||||
default: {
|
||||
const _exhaustive: never = s;
|
||||
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: _exhaustive };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatHM(min: number): string {
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function fmtTs(iso: string): string {
|
||||
return new Date(iso).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function TimesheetDetail({
|
||||
summary,
|
||||
entries,
|
||||
audit,
|
||||
caller,
|
||||
}: {
|
||||
summary: TimesheetSummary;
|
||||
entries: TimesheetEntry[];
|
||||
audit: AuditEntry[];
|
||||
caller: Caller;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
const [unlockNote, setUnlockNote] = useState("");
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const isLocked = !!summary.locked_at;
|
||||
const canSubmit =
|
||||
!isLocked && (summary.status === "draft" || summary.status === "rejected");
|
||||
const canApprove =
|
||||
!isLocked &&
|
||||
summary.status === "submitted" &&
|
||||
(caller.isAdmin || caller.role === "supervisor");
|
||||
const canUnlock = isLocked && caller.isAdmin;
|
||||
const canEditEntries = !isLocked && caller.isAdmin;
|
||||
|
||||
function call(action: () => Promise<{ success: boolean; error?: string }>) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const r = await action();
|
||||
if (!r.success) setError(r.error ?? "Action failed");
|
||||
else router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
const c = statusColor(summary.status);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top bar: status + actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-block rounded-full px-3 py-1 text-xs font-semibold ${c.bg} ${c.fg}`}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
{summary.submitted_at && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
Submitted {fmtTs(summary.submitted_at)}
|
||||
</span>
|
||||
)}
|
||||
{summary.reviewed_at && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
Reviewed by {summary.reviewed_by_label ?? "admin"}{" "}
|
||||
{fmtTs(summary.reviewed_at)}
|
||||
</span>
|
||||
)}
|
||||
{summary.unlocked_at && summary.unlock_audit_note && (
|
||||
<span className="text-xs italic text-rose-600">
|
||||
Unlocked {fmtTs(summary.unlocked_at)} · “{summary.unlock_audit_note}”
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={`/api/time-tracking/timesheets/${summary.id}/export?format=csv`}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||
>
|
||||
CSV
|
||||
</a>
|
||||
<a
|
||||
href={`/api/time-tracking/timesheets/${summary.id}/export?format=pdf`}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||
>
|
||||
PDF
|
||||
</a>
|
||||
{canEditEntries && (
|
||||
<button
|
||||
onClick={() => setShowManual((v) => !v)}
|
||||
className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800"
|
||||
>
|
||||
{showManual ? "Hide manual entry" : "+ Manual entry"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLocked && (
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||
<strong>Locked.</strong> Entries below are read-only.{" "}
|
||||
{canUnlock
|
||||
? "Use Unlock to make changes."
|
||||
: "Ask an admin to unlock if a correction is required."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||
<Stat label="Total" value={formatHM(summary.total_minutes)} />
|
||||
<Stat label="Regular" value={formatHM(summary.regular_minutes)} />
|
||||
<Stat label="Overtime" value={formatHM(summary.overtime_minutes)} />
|
||||
<Stat label="Break" value={formatHM(summary.break_minutes)} />
|
||||
<Stat label="Entries" value={String(summary.entry_count)} />
|
||||
</div>
|
||||
|
||||
{/* Manual entry editor */}
|
||||
{showManual && canEditEntries && (
|
||||
<ManualEntryForm
|
||||
brandId={summary.brand_id}
|
||||
workerId={summary.field_worker_id}
|
||||
periodStart={summary.period_start}
|
||||
periodEnd={summary.period_end}
|
||||
onClose={() => setShowManual(false)}
|
||||
onCreated={() => {
|
||||
setShowManual(false);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Approval actions */}
|
||||
{(canSubmit || canApprove || canUnlock) && (
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white p-4">
|
||||
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-zinc-500">
|
||||
Workflow
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{canSubmit && (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<label className="text-xs text-zinc-500">
|
||||
Notes for reviewer (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="e.g. includes a 2h drive to second field"
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
disabled={pending}
|
||||
/>
|
||||
<button
|
||||
disabled={pending}
|
||||
onClick={() => call(() => submitTimesheet(summary.id, comment || undefined))}
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
Submit for review
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canApprove && (
|
||||
<>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<label className="text-xs text-zinc-500">
|
||||
Approver comment (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Looks good"
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
disabled={pending}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
call(() => approveTimesheet(summary.id, comment || undefined))
|
||||
}
|
||||
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
Approve & lock
|
||||
</button>
|
||||
<button
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
if (rejectReason.length < 3) {
|
||||
setError("A rejection reason (min 3 chars) is required.");
|
||||
return;
|
||||
}
|
||||
call(() => rejectTimesheet(summary.id, rejectReason));
|
||||
}}
|
||||
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-50"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<label className="text-xs text-zinc-500">
|
||||
Rejection reason (required if rejecting)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Tuesday needs lunch break added"
|
||||
className="rounded-md border border-zinc-300 px-3 py-2 text-sm"
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canUnlock && (
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<label className="text-xs font-semibold text-rose-700">
|
||||
Unlock audit note (required, min 5 chars)
|
||||
</label>
|
||||
<textarea
|
||||
value={unlockNote}
|
||||
onChange={(e) => setUnlockNote(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Payroll found an under-reported hour on Wednesday. Re-locking after correction."
|
||||
className="rounded-md border border-rose-300 px-3 py-2 text-sm"
|
||||
disabled={pending}
|
||||
/>
|
||||
<button
|
||||
disabled={pending || unlockNote.length < 5}
|
||||
onClick={() => call(() => unlockTimesheet(summary.id, unlockNote))}
|
||||
className="rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-50"
|
||||
>
|
||||
Unlock timesheet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entries */}
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
||||
<div className="border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-700">
|
||||
Entries ({entries.length})
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-zinc-500">
|
||||
No entries yet for this period.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-white text-left text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Date</th>
|
||||
<th className="px-4 py-3">Task</th>
|
||||
<th className="px-4 py-3">Clock in / out</th>
|
||||
<th className="px-4 py-3 text-right">Break</th>
|
||||
<th className="px-4 py-3 text-right">Total</th>
|
||||
<th className="px-4 py-3">GPS</th>
|
||||
<th className="px-4 py-3">Kind</th>
|
||||
{canEditEntries && <th className="px-4 py-3 text-right">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="px-4 py-3 text-zinc-700">
|
||||
{e.clock_in.slice(0, 10)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-900">{e.task_name}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-zinc-700">
|
||||
{e.clock_in.slice(11, 16)} →{" "}
|
||||
{e.clock_out ? e.clock_out.slice(11, 16) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
|
||||
{e.lunch_break_minutes}m
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium tabular-nums text-zinc-900">
|
||||
{formatHM(e.total_minutes)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{e.gps_verified ? (
|
||||
<span title="GPS verified (≤100m)">📍</span>
|
||||
) : (
|
||||
<span className="text-zinc-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{e.entry_kind === "manual" ? (
|
||||
<span
|
||||
title={e.manual_reason ?? ""}
|
||||
className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800"
|
||||
>
|
||||
manual
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-zinc-500">clock</span>
|
||||
)}
|
||||
</td>
|
||||
{canEditEntries && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
{confirmDelete === e.id ? (
|
||||
<span className="space-x-2">
|
||||
<button
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
call(async () => {
|
||||
const r = await deleteTimeEntry(
|
||||
e.id,
|
||||
"Removed from timesheet detail page",
|
||||
);
|
||||
return r;
|
||||
})
|
||||
}
|
||||
className="rounded bg-rose-600 px-2 py-0.5 text-[11px] font-medium text-white"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="rounded border border-zinc-300 px-2 py-0.5 text-[11px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(e.id)}
|
||||
className="text-[11px] text-rose-600 hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audit trail */}
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
||||
<div className="border-b border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold text-zinc-700">
|
||||
Audit trail ({audit.length})
|
||||
</div>
|
||||
{audit.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-zinc-500">No audit entries.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-100">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider text-zinc-500">
|
||||
{a.action}
|
||||
</span>{" "}
|
||||
<span className="text-zinc-900">{a.actor_label}</span>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{fmtTs(a.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{Object.keys(a.details).length > 0 && (
|
||||
<pre className="mt-1 overflow-x-auto rounded bg-zinc-50 px-3 py-2 text-[11px] text-zinc-600">
|
||||
{JSON.stringify(a.details, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link
|
||||
href="/admin/time-tracking/timesheets"
|
||||
className="text-zinc-600 hover:underline"
|
||||
>
|
||||
← Back to timesheets
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-3">
|
||||
<div className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-zinc-900">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Timesheets list — status filter, worker filter, period filter,
|
||||
* per-row summary + click-through to the detail page. Mobile-friendly.
|
||||
*/
|
||||
import React, { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import type { TimesheetStatus } from "@/db/schema/time-tracking";
|
||||
import type { TimesheetSummary } from "@/actions/time-tracking/timesheets";
|
||||
|
||||
type Worker = { id: string; name: string; role: string };
|
||||
|
||||
type Filters = {
|
||||
status: TimesheetStatus | "all";
|
||||
workerId?: string;
|
||||
periodStart?: string;
|
||||
periodEnd?: string;
|
||||
};
|
||||
|
||||
function statusColor(s: TimesheetStatus): {
|
||||
bg: string;
|
||||
fg: string;
|
||||
label: string;
|
||||
} {
|
||||
switch (s) {
|
||||
case "draft":
|
||||
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: "Draft" };
|
||||
case "submitted":
|
||||
return { bg: "bg-amber-100", fg: "text-amber-800", label: "Submitted" };
|
||||
case "approved":
|
||||
return { bg: "bg-emerald-100", fg: "text-emerald-800", label: "Approved · Locked" };
|
||||
case "rejected":
|
||||
return { bg: "bg-rose-100", fg: "text-rose-800", label: "Rejected" };
|
||||
default: {
|
||||
// exhaustiveness guard
|
||||
const _exhaustive: never = s;
|
||||
return { bg: "bg-zinc-100", fg: "text-zinc-700", label: _exhaustive };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatHM(min: number): string {
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export default function TimesheetsList({
|
||||
rows,
|
||||
total,
|
||||
workers,
|
||||
filters,
|
||||
}: {
|
||||
rows: TimesheetSummary[];
|
||||
total: number;
|
||||
workers: Worker[];
|
||||
filters: Filters;
|
||||
}) {
|
||||
const workerById = useMemo(() => {
|
||||
const m = new Map<string, Worker>();
|
||||
for (const w of workers) m.set(w.id, w);
|
||||
return m;
|
||||
}, [workers]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter bar */}
|
||||
<form className="flex flex-wrap items-end gap-3 rounded-2xl border border-zinc-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={filters.status}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="submitted">Submitted</option>
|
||||
<option value="approved">Approved / Locked</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
Worker
|
||||
</label>
|
||||
<select
|
||||
name="workerId"
|
||||
defaultValue={filters.workerId ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All workers</option>
|
||||
{workers.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name} ({w.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
Period start
|
||||
</label>
|
||||
<input
|
||||
name="periodStart"
|
||||
type="date"
|
||||
defaultValue={filters.periodStart ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">
|
||||
Period end
|
||||
</label>
|
||||
<input
|
||||
name="periodEnd"
|
||||
type="date"
|
||||
defaultValue={filters.periodEnd ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/time-tracking/timesheets"
|
||||
className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
|
||||
>
|
||||
Reset
|
||||
</Link>
|
||||
</form>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-zinc-300 bg-white p-10 text-center text-sm text-zinc-500">
|
||||
No timesheets match the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<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">Worker</th>
|
||||
<th className="px-4 py-3">Period</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3 text-right">Total</th>
|
||||
<th className="px-4 py-3 text-right">OT</th>
|
||||
<th className="px-4 py-3 text-right">Entries</th>
|
||||
<th className="px-4 py-3 text-right">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{rows.map((r) => {
|
||||
const c = statusColor(r.status);
|
||||
return (
|
||||
<tr key={r.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/time-tracking/timesheets/${r.id}`}
|
||||
className="font-medium text-zinc-900 hover:underline"
|
||||
>
|
||||
{r.worker_name}
|
||||
</Link>
|
||||
<span className="ml-2 text-xs text-zinc-500">
|
||||
{r.worker_role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-700">
|
||||
{r.period_start} → {r.period_end}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-semibold ${c.bg} ${c.fg}`}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
|
||||
{formatHM(r.total_minutes)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
|
||||
{formatHM(r.overtime_minutes)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-zinc-700">
|
||||
{r.entry_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-xs text-zinc-500">
|
||||
{new Date(r.updated_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user