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>
162 lines
No EOL
6.3 KiB
TypeScript
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>
|
|
);
|
|
} |