mega-plant/components/MechanicsBoard.tsx
David f85b9378a2 Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.

Insurance (R503 330/yr, 20 vehicles)
  A premium can move every month or three, so it is stored as a RATE PERIOD in
  insurance_rates and each month's cost is resolved against the rate in force
  that month. cost_lines insurance rows are derived from those periods. An absent
  rate is null, never 0 — nobody has entered the date, which is not the same as
  being insured for free.

Financing (20 agreements, R428 585/mo)
  Instalment from the sheet; start date, paid-off date and the settled toggle are
  human-entered and preserved across re-seeds. Months-left and outstanding are
  null until a date is entered, never R0 on a live debt. The two paid-off
  agreements (WB02, DT01) show as settled and are excluded from the run-rate.

Purchases + Oil Stock
  Uploads PO PDFs the way /income/invoices does. The parser reads columns by
  x-position, because plain text extraction reorders them, and refuses any PO
  whose lines do not sum to the sub-total. A cost code either names a machine
  ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
  guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
  stock is the sum of movements so the first usage is a subtraction from history.

Operators (19) and Mechanics (2)
  Both are people with an employee number, separately reconcilable by the seed so
  someone added in the app survives. Mechanics get a badge QR; a mechanic with no
  employee number cannot be printed, because the code is what the QR encodes.

Cross-cutting
  - The seed no longer deletes operators, mechanics or oil items: people are added
    in the app, and those rows exist in no file.
  - Re-pointing allocations that PocketBase clears because `assets` is rebuilt
    with new ids each seed (operators, purchase lines).
  - One QR encoder for screen and PDF, so a printed code cannot drift from the one
    on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
  - Every figure is null-safe: unknown is never rendered as zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 01:06:27 +02:00

288 lines
No EOL
13 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Download, Wrench, X, TriangleAlert, Loader2, UserPlus } from "lucide-react";
import type { Mechanic } from "@/lib/data";
import { mechanicQrPayload, isBadgeReady } from "@/lib/mechanics";
import { QrCode } from "@/components/QrCode";
import { buildBadgesPdf, downloadPdf } from "@/lib/qr-pdf";
import { cn } from "@/lib/utils";
const ACCENT = "#30638E";
/**
* A mechanic's badge. The same component renders the small inline QR on the list, the
* large one in the popup and the one on the print sheet — they CANNOT drift, because
* there is only one implementation and one payload builder.
*
* The QR comes from `QrCode`, which draws SVG rects from the shared matrix in
* `lib/qr.ts` — NOT a canvas. A printed badge is the whole point, and vector modules
* stay sharp at any printer resolution while a canvas rasterises at its drawn size.
*/
export function MechanicBadge({
mechanic,
size = 96,
className,
}: {
mechanic: Mechanic;
size?: number;
className?: string;
}) {
const ready = isBadgeReady(mechanic);
return (
<div className={cn("bg-white p-2 rounded-xl border border-gray-200", className)}>
{ready ? (
// Drawn from the SAME matrix the PDF uses (see lib/qr.ts) — one encoder, two
// surfaces, so a printed badge can never carry a different code than the screen.
<QrCode value={mechanicQrPayload(mechanic)} size={size} label={`${mechanic.name} badge code`} />
) : (
<div
style={{ width: size, height: size }}
className="grid place-items-center text-center text-[10px] text-gray-400 border border-dashed border-gray-300"
>
no employee
<br />
number
</div>
)}
</div>
);
}
export function MechanicsBoard({ mechanics }: { mechanics: Mechanic[] }) {
const router = useRouter();
const [openId, setOpenId] = useState<string | null>(null);
const [building, setBuilding] = useState(false);
const [adding, setAdding] = useState(false);
const [addingBusy, setAddingBusy] = useState(false);
async function addMechanic(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
setAddingBusy(true);
try {
const res = await fetch("/api/mechanics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: fd.get("name"),
employeeCode: fd.get("employeeCode"),
role: fd.get("role"),
}),
});
const body = await res.json();
if (body.ok) {
setAdding(false);
router.refresh();
} else {
alert(body.error || "Could not add the mechanic");
}
} catch {
alert("Could not save — check your connection");
} finally {
setAddingBusy(false);
}
}
// A REAL PDF file, generated in the browser. Not a print dialog, not the HTML page:
// the client asked for a file they can send to a printer, so that is what they get.
async function downloadAll() {
setBuilding(true);
try {
const bytes = await buildBadgesPdf(mechanics);
downloadPdf(bytes, "mega-plant-mechanic-badges.pdf");
} catch {
alert("Could not build the PDF. Please try again.");
} finally {
setBuilding(false);
}
}
async function downloadOne(m: Mechanic) {
setBuilding(true);
try {
const bytes = await buildBadgesPdf([m]);
downloadPdf(bytes, `badge-${m.employee_code}.pdf`);
} catch {
alert("Could not build the PDF. Please try again.");
} finally {
setBuilding(false);
}
}
const open = mechanics.find((m) => m.id === openId) ?? null;
const notReady = mechanics.filter((m) => !isBadgeReady(m));
return (
<>
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3 mb-5">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
<span className="px-2.5 py-1 rounded-md bg-[#30638E]/10 text-[#30638E] font-medium">
{mechanics.length} badge{mechanics.length !== 1 && "s"}
</span>
<span className="hidden md:inline">· tap a badge to enlarge it</span>
</div>
<button
onClick={downloadAll}
disabled={building}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white transition disabled:opacity-60"
style={{ background: ACCENT }}
>
{building ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
{building ? "Building PDF…" : "Download QR codes"}
</button>
</div>
{!adding && (
<button
onClick={() => setAdding(true)}
className="mb-5 min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 border transition"
style={{ borderColor: `${ACCENT}55`, color: ACCENT }}
>
<UserPlus className="w-4 h-4" /> Add mechanic
</button>
)}
{adding && (
<section className="mb-5 rounded-2xl border p-4" style={{ borderColor: `${ACCENT}55`, background: `${ACCENT}0d` }}>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-sm font-medium text-[#003D5B] dark:text-white">Add a mechanic</span>
<button
onClick={() => setAdding(false)}
aria-label="Close the add-mechanic panel"
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-500 hover:bg-white dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={addMechanic} className="flex flex-col sm:flex-row sm:items-end gap-3">
<div className="flex-1 min-w-[150px]">
<label htmlFor="mech-name" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Name</label>
<input id="mech-name" name="name" required placeholder="e.g. THABO MOTSEPE"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200" />
</div>
<div className="flex-1 min-w-[130px]">
<label htmlFor="mech-code" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Employee number</label>
<input id="mech-code" name="employeeCode" required placeholder="MCW###"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 font-mono" />
</div>
<div className="flex-1 min-w-[120px]">
<label htmlFor="mech-role" className="block text-xs text-gray-500 dark:text-gray-400 mb-1.5">Role</label>
<input id="mech-role" name="role" defaultValue="Mechanic"
className="min-h-[44px] w-full px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200" />
</div>
<button
type="submit" disabled={addingBusy}
className="min-h-[44px] px-4 rounded-lg text-xs font-semibold text-white transition disabled:opacity-60 inline-flex items-center gap-2"
style={{ background: ACCENT }}
>
{addingBusy ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Add
</button>
</form>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
The employee number goes straight into the badge QR and must be unique. Added here,
this person is <span className="font-medium">not</span> on the mechanics sheet — they
will be kept when the seed runs again.
</p>
</section>
)}
{notReady.length > 0 && (
<div className="mb-5 flex items-start gap-2 rounded-2xl border border-[#D1495B]/30 bg-[#D1495B]/[0.06] p-4 text-xs text-gray-600 dark:text-gray-400">
<TriangleAlert className="w-4 h-4 mt-0.5 shrink-0 text-[#D1495B] dark:text-[#D94F60]" />
<span>
<span className="font-semibold text-[#003D5B] dark:text-white">
{notReady.length} mechanic{notReady.length !== 1 && "s"} without an employee number
</span>{" "}
— {notReady.map((m) => m.name).join(", ")}. A badge QR encodes the employee
number, so these cannot be printed until one is entered.
</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{mechanics.map((m) => (
<button
key={m.id}
onClick={() => setOpenId(m.id)}
className="text-left bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm hover:shadow-md hover:border-[#30638E]/40 transition"
>
<div className="flex items-start gap-4">
<MechanicBadge mechanic={m} size={84} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-semibold text-[#003D5B] dark:text-white">
<Wrench className="w-3.5 h-3.5 shrink-0" style={{ color: ACCENT }} />
<span className="truncate">{m.name}</span>
</div>
<div className="mt-1.5 font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] inline-block">
{m.employee_code}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{m.role || "Mechanic"}
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border text-xs text-gray-400 dark:text-text-soft">
Tap to enlarge · scans to {m.name} + {m.employee_code}
</div>
</button>
))}
</div>
{open && (
<div
className="fixed inset-0 z-[1400] bg-black/50 grid place-items-center p-4"
onClick={() => setOpenId(null)}
role="dialog"
aria-modal="true"
aria-label={`${open.name} badge`}
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-white dark:bg-surface rounded-2xl border border-gray-200 dark:border-border shadow-2xl p-6 w-full max-w-sm relative"
>
<button
onClick={() => setOpenId(null)}
aria-label="Close"
className="absolute top-3 right-3 min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5"
>
<X className="w-4 h-4" />
</button>
<div className="text-center">
<div className="text-xs uppercase tracking-wider text-gray-400 dark:text-text-soft mb-3">
{open.role || "Mechanic"}
</div>
<MechanicBadge mechanic={open} size={220} className="inline-block" />
<div className="mt-4 text-lg font-semibold text-[#003D5B] dark:text-white">{open.name}</div>
<div className="mt-1 font-mono text-sm text-[#00798C]">{open.employee_code}</div>
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400 break-all">
Encodes: {mechanicQrPayload(open)}
</div>
<div className="mt-5 flex gap-2 justify-center flex-wrap">
<button
onClick={() => downloadOne(open)}
disabled={building}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white transition disabled:opacity-60"
style={{ background: ACCENT }}
>
{building ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Download this badge
</button>
<button
onClick={downloadAll}
disabled={building}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 border border-gray-200 dark:border-white/10 text-gray-700 dark:text-gray-300 transition disabled:opacity-60"
>
<Download className="w-4 h-4" /> All badges
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}