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>
62 lines
No EOL
2.2 KiB
TypeScript
62 lines
No EOL
2.2 KiB
TypeScript
import QRCode from "qrcode";
|
|
|
|
/**
|
|
* The single QR encoder for this project.
|
|
*
|
|
* ONE encoder feeds two surfaces — the SVG badge on screen and the vector PDF — so a
|
|
* badge can never be printed with a different code than the one displayed. That drift
|
|
* is the one failure this whole feature must not have: a badge whose printed code
|
|
* differs from its on-screen code identifies the wrong person.
|
|
*
|
|
* `qrcode` is used rather than `react-qr-code` because we need the raw MODULE MATRIX,
|
|
* not a rendered widget: the PDF draws each dark module as a real rectangle, which
|
|
* keeps the printed QR vector-sharp at any printer resolution.
|
|
*/
|
|
|
|
// High correction on purpose: a badge is scanned off a lanyard, and the realistic
|
|
// failure modes are a scratch, a smear of mud, or a fold. H tolerates ~30% damage.
|
|
const EC_LEVEL = "H" as const;
|
|
|
|
export type QrMatrix = {
|
|
/** Modules per side, e.g. 29. */
|
|
size: number;
|
|
/** Row-major, 1 = dark. */
|
|
data: Uint8Array;
|
|
};
|
|
|
|
export function qrMatrix(payload: string): QrMatrix {
|
|
const qr = QRCode.create(payload, { errorCorrectionLevel: EC_LEVEL });
|
|
return { size: qr.modules.size, data: qr.modules.data };
|
|
}
|
|
|
|
/** Is a module at (x, y) dark? */
|
|
export function isDark(m: QrMatrix, x: number, y: number): boolean {
|
|
return m.data[y * m.size + x] === 1;
|
|
}
|
|
|
|
/**
|
|
* A QR as an inline SVG path, with the quiet zone baked in.
|
|
*
|
|
* `d` is a run of `M x y h w v h ... z` rectangles — one per dark module, each a
|
|
* single filled square. One path element instead of hundreds of <rect>s keeps the DOM
|
|
* small when a page shows a badge per mechanic.
|
|
*/
|
|
export function qrSvgPath(m: QrMatrix, quiet = 2): { d: string; viewBox: number } {
|
|
const span = m.size + quiet * 2;
|
|
const parts: string[] = [];
|
|
for (let y = 0; y < m.size; y++) {
|
|
let run = 0;
|
|
for (let x = 0; x <= m.size; x++) {
|
|
const dark = x < m.size && isDark(m, x, y);
|
|
if (dark) {
|
|
run++;
|
|
} else if (run > 0) {
|
|
// Merge horizontally adjacent dark modules into one rect — far fewer path
|
|
// commands, and visually identical.
|
|
parts.push(`M${x - run + quiet} ${y + quiet}h${run}v1h-${run}z`);
|
|
run = 0;
|
|
}
|
|
}
|
|
}
|
|
return { d: parts.join(""), viewBox: span };
|
|
} |