- Job cards scanner: oil QR books litres + vehicle (no odo/photo), bundled onto the vehicle's job card (open job now, or the next job started the same day) - Fuel tab is diesel only; /api/clock/oil-out added - Users: Full / Clock-in user / Mechanic; mechanics see only Fuel + Job cards - Plant Register: QR code column (double-click to set), kept across re-seeds and used when matching scans Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
120 lines
7.4 KiB
TypeScript
120 lines
7.4 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; oil: 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-[1040px]">
|
|
<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 pr-3 font-medium">Oil used</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 pr-3 min-w-[140px]">{r.oil || "—"}</td>
|
|
<td className="py-2 min-w-[240px] whitespace-pre-wrap break-words">{r.note || "—"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|