mega-plant/components/JobCardsTable.tsx
David c269cd00b4 Add Job Cards (vehicle-QR start/finish) and retire Operators/Mechanics pages
- Plant Clock "Job cards" tab: scan a vehicle QR to start a job, scan again to finish
  with odometer (or no meter), kind of work and a note
- /running/job-cards table with period, work, vehicle and mechanic filters
- Remove /running/operators and /running/mechanics; mechanic badge PDF moves to Employees

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:54:59 +02:00

118 lines
7.2 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { inBounds, presetBounds, saDay, type LogPreset } from "@/lib/oil-log";
import { formatMinutes, JOB_TYPES, jobTypeLabel } from "@/lib/job-cards";
export type JobRow = {
id: string; startedAt: string; endedAt: string; open: boolean; asset: string; assetName: string;
mechanic: string; type: string; minutes: number; odometer: number | null; note: string;
};
const PRESETS: { id: LogPreset; label: string }[] = [
{ id: "today", label: "Today" }, { id: "week", label: "This week" }, { id: "month", label: "This month" },
{ id: "all", label: "Everything" }, { id: "range", label: "Date range" },
];
const fmt = (n: number) => n.toLocaleString("en-ZA", { maximumFractionDigits: 1 });
const when = (s: string) => (s ? new Date(s.replace(" ", "T")).toLocaleString("en-ZA", {
timeZone: "Africa/Johannesburg", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) : "—");
export function JobCardsTable({ rows }: { rows: JobRow[] }) {
const [preset, setPreset] = useState<LogPreset>("month");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [type, setType] = useState("");
const [asset, setAsset] = useState("");
const [mechanic, setMechanic] = useState("");
const assets = useMemo(() => [...new Set(rows.map((r) => r.asset))].filter(Boolean).sort(), [rows]);
const mechanics = useMemo(() => [...new Set(rows.map((r) => r.mechanic))].filter(Boolean).sort(), [rows]);
const shown = useMemo(() => {
const bounds = presetBounds(preset, saDay(new Date().toISOString()), from, to);
return rows.filter((r) => inBounds(saDay(r.startedAt), bounds) && (!type || r.type === type)
&& (!asset || r.asset === asset) && (!mechanic || r.mechanic === mechanic));
}, [rows, preset, from, to, type, asset, mechanic]);
const done = shown.filter((r) => !r.open);
const totalMinutes = done.reduce((s, r) => s + r.minutes, 0);
const field = "min-h-[44px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm";
return (
<div className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl p-4 sm:p-5 shadow-sm">
<div className="flex flex-col lg:flex-row lg:items-center gap-3 mb-3">
<div className="hidden lg:flex gap-1.5" role="group" aria-label="Period">
{PRESETS.map((p) => (
<button key={p.id} onClick={() => setPreset(p.id)} aria-pressed={preset === p.id}
className={`min-h-[44px] px-4 rounded-lg text-xs font-medium border ${preset === p.id ? "bg-[#30638E] text-white border-[#30638E]" : "border-gray-200 dark:border-border text-gray-600 dark:text-gray-300"}`}>
{p.label}
</button>
))}
</div>
<select className={`${field} lg:hidden`} value={preset} onChange={(e) => setPreset(e.target.value as LogPreset)} aria-label="Period">
{PRESETS.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</select>
{preset === "range" && (
<div className="flex items-center gap-2">
<input type="date" className={field} value={from} max={to || undefined} onChange={(e) => setFrom(e.target.value)} aria-label="From" />
<span className="text-xs text-gray-500">to</span>
<input type="date" className={field} value={to} min={from || undefined} onChange={(e) => setTo(e.target.value)} aria-label="To" />
</div>
)}
<div className="flex flex-wrap gap-2 lg:ml-auto">
<select className={field} value={type} onChange={(e) => setType(e.target.value)} aria-label="Kind of work">
<option value="">All work</option>{JOB_TYPES.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
<select className={field} value={asset} onChange={(e) => setAsset(e.target.value)} aria-label="Vehicle">
<option value="">All vehicles</option>{assets.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<select className={field} value={mechanic} onChange={(e) => setMechanic(e.target.value)} aria-label="Mechanic">
<option value="">All mechanics</option>{mechanics.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-3 text-xs font-medium">
<span className="px-3 py-1.5 rounded-lg bg-[#30638E]/10 text-[#30638E]">{shown.length} job{shown.length !== 1 && "s"}</span>
<span className="px-3 py-1.5 rounded-lg bg-[#00798C]/10 text-[#00798C]">{formatMinutes(totalMinutes)} on finished jobs</span>
{shown.some((r) => r.open) && <span className="px-3 py-1.5 rounded-lg bg-[#EDAE49]/20 text-[#8a5d00]">{shown.filter((r) => r.open).length} in progress</span>}
</div>
{shown.length === 0 ? (
<p className="text-xs text-gray-500 dark:text-gray-400 py-4">
{rows.length === 0 ? "No job cards yet. Mechanics start one by scanning a vehicle's QR in the Plant Clock app." : "No jobs match these filters."}
</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="text-left text-xs text-gray-500 dark:text-gray-400">
<th className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 font-medium">Vehicle</th>
<th className="py-2 pr-3 font-medium">Mechanic</th>
<th className="py-2 pr-3 font-medium">Started</th>
<th className="py-2 pr-3 font-medium">Finished</th>
<th className="py-2 pr-3 font-medium text-right">Time</th>
<th className="py-2 pr-3 font-medium">Work</th>
<th className="py-2 pr-3 font-medium text-right">Odometer</th>
<th className="py-2 font-medium">Note</th>
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.id} className="border-t border-gray-100 dark:border-border align-top">
<td className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 whitespace-nowrap"><span className="font-mono font-semibold">{r.asset || "—"}</span><span className="block text-xs text-gray-500">{r.assetName}</span></td>
<td className="py-2 pr-3">{r.mechanic || "—"}</td>
<td className="py-2 pr-3 whitespace-nowrap tabular-nums">{when(r.startedAt)}</td>
<td className="py-2 pr-3 whitespace-nowrap tabular-nums">{r.open ? <span className="px-2 py-0.5 rounded bg-[#EDAE49]/20 text-[#8a5d00] text-xs font-medium">In progress</span> : when(r.endedAt)}</td>
<td className="py-2 pr-3 text-right tabular-nums whitespace-nowrap">{r.open ? "—" : formatMinutes(r.minutes)}</td>
<td className="py-2 pr-3 whitespace-nowrap">{jobTypeLabel(r.type) || "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums">{r.odometer != null ? fmt(r.odometer) : "—"}</td>
<td className="py-2 min-w-[240px] whitespace-pre-wrap break-words">{r.note || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}