mega-plant/components/JobCardsTable.tsx
David 1ec52961ee Cash float card, Office role + Slips tab, job card PDF, manual close/delete
- Cash Floats: ledger card (opening balance, top-ups, slips), manual entries, slip photos
- Office access level; clock app Slips tab (photograph a slip, popup for supplier/cost
  code/comments/amount); only Office sees it; per-role route table in lib/clock-access
- Job cards: Machine Service PDF (with Oil block), JC numbers, manual Close job, Delete
- Hide Expenses > Vehicle Costs and Finance (Bastion) from the menu

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:09:57 +02:00

226 lines
16 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Download, FileText } from "lucide-react";
import { buildJobCardPdf } from "@/lib/job-card-pdf";
import { downloadPdf } from "@/lib/qr-pdf";
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;
oilLines: string[]; jcNo: number | null; site: 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" }) : "—");
function pdfOf(r: JobRow) {
return buildJobCardPdf({
jcNo: r.jcNo, plantNo: r.asset, plantName: r.assetName, site: r.site, mechanic: r.mechanic,
machineHours: r.odometer != null ? String(r.odometer) : "", startedAt: r.startedAt, endedAt: r.endedAt,
type: r.type, minutes: r.minutes, note: r.note, oil: r.oilLines,
});
}
export function JobCardsTable({ rows }: { rows: JobRow[] }) {
const router = useRouter();
const [closing, setClosing] = useState<JobRow | null>(null);
const [deleting, setDeleting] = useState<JobRow | null>(null);
const [cType, setCType] = useState("");
const [cNote, setCNote] = useState("");
const [cOdo, setCOdo] = useState("");
const [cEnd, setCEnd] = useState("");
const [working, setWorking] = useState(false);
const [dialogError, setDialogError] = useState("");
const [pdfBusy, setPdfBusy] = useState("");
async function download(r: JobRow | null) {
setPdfBusy(r?.id ?? "blank");
try {
const bytes = r ? await pdfOf(r) : await buildJobCardPdf({});
downloadPdf(bytes, r ? `job-card-${String(r.jcNo ?? 0).padStart(4, "0")}-${r.asset || "vehicle"}.pdf` : "job-card-blank.pdf");
} catch { alert("Could not build the PDF. Please try again."); }
finally { setPdfBusy(""); }
}
function openClose(r: JobRow) { setClosing(r); setCType(""); setCNote(""); setCOdo(""); setCEnd(""); setDialogError(""); }
async function submitClose(e: React.FormEvent) {
e.preventDefault();
if (!closing || !cType) return;
setWorking(true); setDialogError("");
try {
const res = await fetch(`/api/job-cards/${closing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_type: cType, note: cNote, odometer: cOdo, ended_at: cEnd ? new Date(cEnd).toISOString() : undefined }) });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || "Could not close the job");
setClosing(null); router.refresh();
} catch (err: any) { setDialogError(err.message); } finally { setWorking(false); }
}
async function confirmDelete() {
if (!deleting) return;
setWorking(true); setDialogError("");
try {
const res = await fetch(`/api/job-cards/${deleting.id}`, { method: "DELETE" });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || "Could not delete the job");
setDeleting(null); router.refresh();
} catch (err: any) { setDialogError(err.message); } finally { setWorking(false); }
}
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>
<button onClick={() => download(null)} disabled={pdfBusy === "blank"} className="ml-auto min-h-[44px] px-3 rounded-lg border border-gray-200 dark:border-border inline-flex items-center gap-2 text-xs font-medium"><FileText className="w-4 h-4" />{pdfBusy === "blank" ? "Building…" : "Blank job card PDF"}</button>
{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-[1240px]">
<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 pr-3 font-medium">Note</th>
<th className="py-2 font-medium">Actions</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 pr-3 min-w-[240px] whitespace-pre-wrap break-words">{r.note || "—"}</td>
<td className="py-2">
<div className="flex items-center gap-1 whitespace-nowrap">
<button onClick={() => download(r)} disabled={pdfBusy === r.id} aria-label={`Download job card PDF for ${r.asset}`} className="min-h-[44px] px-3 rounded-lg border border-gray-200 dark:border-border text-xs font-medium inline-flex items-center gap-1.5"><Download className="w-3.5 h-3.5" />PDF</button>
{r.open && <button onClick={() => openClose(r)} className="min-h-[44px] px-3 rounded-lg bg-[#30638E] text-white text-xs font-medium">Close job</button>}
<button onClick={() => { setDialogError(""); setDeleting(r); }} className="min-h-[44px] px-3 rounded-lg text-xs text-[#D1495B] hover:bg-[#D1495B]/10">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{closing && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick={() => !working && setClosing(null)}>
<form onClick={(e) => e.stopPropagation()} onSubmit={submitClose} role="dialog" aria-modal="true" aria-label="Close job"
className="w-full max-w-md max-h-[90svh] overflow-y-auto rounded-2xl bg-white dark:bg-surface p-5 shadow-xl">
<h3 className="text-base font-semibold text-[#003D5B] dark:text-white">Close job on {closing.asset}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Started by {closing.mechanic || "—"} · {when(closing.startedAt)}. Use this when the mechanic did not scan the second time.</p>
<p className="text-sm font-medium mt-4">Kind of work</p>
<div className="grid grid-cols-3 gap-2 mt-1" role="radiogroup" aria-label="Kind of work">
{JOB_TYPES.map((t) => (
<button key={t.id} type="button" role="radio" aria-checked={cType === t.id} onClick={() => setCType(t.id)}
className={`min-h-[48px] rounded-lg text-xs font-medium border ${cType === t.id ? "bg-[#30638E] text-white border-[#30638E]" : "border-gray-200 dark:border-border"}`}>{t.label}</button>
))}
</div>
<label htmlFor="c-note" className="block text-sm font-medium mt-4">What was done? <span className="font-normal text-gray-500">(optional)</span></label>
<textarea id="c-note" value={cNote} onChange={(e) => setCNote(e.target.value)} maxLength={1000} className="mt-1 w-full min-h-[90px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface p-2 text-sm" />
<label htmlFor="c-odo" className="block text-sm font-medium mt-3">Odometer / hour meter <span className="font-normal text-gray-500">(optional)</span></label>
<input id="c-odo" type="number" inputMode="decimal" min={0} step="any" value={cOdo} onChange={(e) => setCOdo(e.target.value)} className="mt-1 w-full min-h-[44px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm" />
<label htmlFor="c-end" className="block text-sm font-medium mt-3">Finished at <span className="font-normal text-gray-500">(blank = now)</span></label>
<input id="c-end" type="datetime-local" value={cEnd} onChange={(e) => setCEnd(e.target.value)} className="mt-1 w-full min-h-[44px] rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm" />
{dialogError && <p role="alert" className="text-xs text-[#D1495B] mt-3">{dialogError}</p>}
<div className="flex justify-end gap-2 mt-5">
<button type="button" disabled={working} onClick={() => setClosing(null)} className="min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border">Cancel</button>
<button type="submit" disabled={working || !cType} className="min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#30638E] disabled:opacity-60">{working ? "Closing…" : "Close job"}</button>
</div>
</form>
</div>
)}
{deleting && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/50 p-4" onClick={() => !working && setDeleting(null)}>
<div onClick={(e) => e.stopPropagation()} role="alertdialog" aria-modal="true" aria-label="Delete job card" className="w-full max-w-sm rounded-2xl bg-white dark:bg-surface p-5 shadow-xl">
<h3 className="text-base font-semibold text-[#003D5B] dark:text-white">Delete this job card?</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">{deleting.asset} · {deleting.mechanic || "—"} · {when(deleting.startedAt)}{deleting.open ? " (in progress)" : ""}.</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">Any oil booked onto it stays booked out of stock; it just stops belonging to this card. This cannot be undone.</p>
{dialogError && <p role="alert" className="text-xs text-[#D1495B] mt-3">{dialogError}</p>}
<div className="flex justify-end gap-2 mt-5">
<button disabled={working} onClick={() => setDeleting(null)} className="min-h-[44px] px-4 rounded-lg text-sm border border-gray-200 dark:border-border">Cancel</button>
<button disabled={working} onClick={confirmDelete} className="min-h-[44px] px-5 rounded-lg text-sm font-medium text-white bg-[#D1495B] disabled:opacity-60">{working ? "Deleting…" : "Delete"}</button>
</div>
</div>
</div>
)}
</div>
);
}