mega-plant/components/OilsBoard.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

162 lines
No EOL
6.3 KiB
TypeScript

"use client";
import { QrCode } from "@/components/QrCode";
import type { OilStock } from "@/lib/oil";
import { oilQrPayload } from "@/lib/oil";
import { Download, Droplets } from "lucide-react";
export { downloadPdf } from "@/lib/qr-pdf";
/**
* One drum graphic per oil. A drum is FULL at its capacity and fills proportionally
* below it, so 420 L of a 210 L drum reads as full with "2 drums" beside it — stock
* above one drum's worth is still, visually, a full drum.
*/
function Drum({ stock }: { stock: OilStock }) {
const colour = stock.item.colour || "#EDAE49";
const pct = Math.round(stock.fillRatio * 100);
return (
<div className="flex items-end gap-3">
<svg
width="58"
height="86"
viewBox="0 0 58 86"
role="img"
aria-label={`${stock.item.name}: ${stock.known ? `${pct}% full` : "no stock recorded"}`}
className="shrink-0"
>
{/* body */}
<rect x="14" y="8" width="30" height="72" rx="4" fill="#FFFFFF" stroke="#9CA3AF" strokeWidth="1.5" />
{/* fill rises from the bottom */}
{stock.known && pct > 0 && (
<rect
x="16"
y={78 - (70 * pct) / 100}
width="26"
height={(70 * pct) / 100}
rx="2"
fill={colour}
/>
)}
{/* ribs */}
<line x1="16" y1="32" x2="42" y2="32" stroke="#9CA3AF" strokeWidth="0.8" />
<line x1="16" y1="56" x2="42" y2="56" stroke="#9CA3AF" strokeWidth="0.8" />
{/* bung caps */}
<rect x="22" y="3" width="7" height="6" rx="1" fill="#9CA3AF" />
<rect x="33" y="5" width="5" height="4" rx="1" fill="#9CA3AF" />
</svg>
<div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 truncate">
{stock.item.name}
</div>
<div className="text-2xl font-semibold tabular-nums" style={{ color: colour }}>
{stock.known ? `${stock.litres.toLocaleString("en-ZA")} L` : "—"}
</div>
<div className="text-xs mt-0.5">
{!stock.known ? (
<span className="text-gray-400 dark:text-text-soft italic">no delivery recorded</span>
) : stock.isFull ? (
<span className="font-semibold" style={{ color: colour }}>FULL</span>
) : stock.isEmpty ? (
<span className="text-[#D1495B] font-semibold">EMPTY — order more</span>
) : (
<span className="text-gray-500 dark:text-gray-400">{pct}% full</span>
)}
</div>
</div>
</div>
);
}
export function OilsBoard({
stock,
onDownload,
building,
}: {
stock: OilStock[];
onDownload: () => void;
building: boolean;
}) {
return (
<div>
<div className="flex flex-col sm:flex-row 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">
{stock.length} tracked oil{stock.length !== 1 && "s"}
</span>
<span className="hidden md:inline">· stock comes from the oil purchase orders you upload</span>
</div>
<button
onClick={onDownload}
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: "#30638E" }}
>
<Download className="w-4 h-4" />
{building ? "Building PDF…" : "Download oil labels"}
</button>
</div>
{/* The two drum KPIs, one per oil, in their own colours. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
{stock.map((s) => (
<div
key={s.item.id}
className="bg-white dark:bg-surface rounded-2xl border p-5 shadow-sm"
style={{ borderColor: `${s.item.colour}44` }}
>
<Drum stock={s} />
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border flex flex-wrap items-baseline gap-x-3 text-xs text-gray-500 dark:text-gray-400">
<span>Drum {s.item.drum_litres || 210} L</span>
<span>·</span>
<span>{s.drumsHeld} full drum{s.drumsHeld !== 1 && "s"}</span>
<span>·</span>
<span className="font-mono">{s.item.code}</span>
</div>
</div>
))}
</div>
<div className="space-y-2">
{stock.map((s) => (
<div
key={s.item.id}
className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl p-4 shadow-sm"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Droplets className="w-4 h-4 shrink-0" style={{ color: s.item.colour }} />
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">
{s.item.name}
</span>
<span className="font-mono text-xs px-2 py-0.5 rounded bg-[#00798C]/10 text-[#00798C]">
{s.item.code}
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{s.item.detail} · store code{" "}
<span className="font-mono">{s.item.store_cost_code}</span>
</div>
<div className="text-xs text-gray-400 dark:text-text-soft mt-1">
QR scans to {oilQrPayload(s.item)}
</div>
</div>
{/* The QR on the drum/dispenser label — NOT on Purchases. A purchase
order is not a consumable; a scan at the drum needs to say which
oil it is. */}
<div className="shrink-0">
<QrCode
value={oilQrPayload(s.item)}
size={104}
className="border border-gray-200 rounded-xl p-2"
label={`${s.item.name} label code`}
/>
</div>
</div>
</div>
))}
</div>
</div>
);
}