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>
288 lines
No EOL
13 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
} |