// Shared types and pure data transforms. Safe for client components. import type { RecordModel } from "pocketbase"; export type Asset = { id: string; code: string; name: string; category: string; type: string; gl_account: string; supplier: string; monthly_excl: number; monthly_incl: number; purchase_value: number; is_provisional: boolean; is_active: boolean; // TRUE when this machine appears on the client's operator sheet, i.e. it belongs on // /running/operators. Containers, generators and truck trailers are FALSE — they are // in the register but nobody is ever allocated to them. Set by the seed from // data/source/operators.json, not edited by hand. on_operator_sheet?: boolean; // The text on the vehicle's QR sticker, typed in the Plant Register. Empty = the sticker // carries the register code itself. qr_code?: string; // Which yard this machine is parked at. Empty until assigned in // /plant → Edit → Site. cascadeDelete is OFF, so removing a site never // removes the machines that referenced it. site: string; // Plant Map. Null until the on-site scan sheet supplies them. 0/0 counts as // "not located" — see `hasCoords` in components/PlantMap. latitude: number | null; longitude: number | null; } & RecordModel; export type CostLine = { id: string; asset: string; year: number; month: number; kind: "instalment" | "insurance" | "tracking" | "operator" | "running"; amount: number; } & RecordModel; /** * A finance agreement for one plant vehicle: a monthly instalment, a term, and * whether it has been settled. * * Deliberately NOT part of `insurance_rates` and NOT a `cost_lines` row. Insurance * and financing are independent commitments that happen to share a rate sheet; the * date and settled fields here are typed in by a human and must survive a re-seed. * * A year or month of `0` means UNKNOWN — never "January". See `lib/financing.ts`. */ export type FinancingTerm = { id: string; asset: string; installment: number; start_year: number; start_month: number; end_year: number; end_month: number; is_settled: boolean; plant: string; source: string; } & RecordModel; /** * An operator, and which plant vehicle they are allocated to. * * TERM 3 of the fixed-expense formula — the wage that varies per machine per month, * and the last unknown in the workbook cost model (see AGENTS.md). * * An empty `asset` means UNASSIGNED, which is a normal state on this register: two * operators had no machine on the sheet. It never means "deleted". */ export type Operator = { id: string; name: string; employee_code: string; rate: number; asset: string; source: string; } & RecordModel; /** * A mechanic on the payroll, with the employee number their badge QR encodes. * * Not a plant item and not an operator: an operator is allocated TO a machine, a * mechanic is not. The employee code is the identity — it must be unique, because it * is what a scanner reads. */ export type Mechanic = { id: string; name: string; employee_code: string; role: string; photo: string; source: string; } & RecordModel; /** * A tracked consumable oil, held in bulk and drawn down from a drum. * * `store_cost_code` is the code the oil PO prints against the line — "MPLANT, ENGOIL". * It is what tells an uploaded PO line apart as OIL rather than spend against a * machine, and the two namespaces cannot collide because a store is never a machine. */ export type OilItem = { id: string; code: string; name: string; detail: string; supplier: string; material_code: string; store_cost_code: string; drum_litres: number; colour: string; qr_payload: string; source: string; } & RecordModel; /** * One movement in oil stock. "received" when a purchase order delivers oil, "used" * when a drum is drawn down. Stock is the sum of both, so usage stays auditable. */ export type OilMovement = { id: string; item: string; purchase: string; kind: string; litres: number; occurred_on: string; note: string; } & RecordModel; /** A purchase order header: what was ordered, from whom, for how much. */ export type Purchase = { id: string; po_no: string; supplier: string; supplier_code: string; po_date: string; required_by: string; req_no: string; currency: string; subtotal: number; vat: number; total: number; source_pdf: string; source_file: string; } & RecordModel; /** * One line of a purchase order, allocated to a plant vehicle by its COST CODE. * * `cost_code` is kept exactly as printed (hyphenation and all) so the document can * still be checked line by line; `asset` is where it landed. An EMPTY `asset` means the * code matched no machine in the register — money that exists but is not yet on a plant * item. It is never a zero and never a guess. */ export type PurchaseLine = { id: string; purchase: string; line_no: number; material_code: string; cost_code: string; asset: string; description: string; qty: number; rate: number; disc_pct: number; vat_pct: number; amount_excl: number; vat: number; total_incl: number; } & RecordModel; export type MonthlyIncome = { id: string; asset: string; year: number; month: number; income: number; fixed_expenses: number; running_cost: number; } & RecordModel; export type MonthlyRunningCost = { id: string; asset: string; year: number; month: number; amount: number; } & RecordModel; /** * An insurance premium for one plant vehicle over a PERIOD of months. * * This is deliberately a different grain from `cost_lines`: a premium changes * every month or three (client ruling, 2026-10-08), so it is recorded once per * change and the per-month cost is derived from it. `month_to === 0` means the * period is OPEN — this is the premium currently being charged. See `lib/insurance.ts`. */ export type InsuranceRate = { id: string; asset: string; year: number; month_from: number; month_to: number; rate: number; plant: string; source: string; } & RecordModel; export type Overhead = { id: string; section: string; description: string; gl_account: string; supplier: string; qty: number; unit_price: number; amount_excl: number; vat: number; amount_incl: number; } & RecordModel; // Invoice header. `booked_month`/`booked_year` record the period the invoice was // posted into monthly_income; `is_booked` flips once every line has a target asset. export type Invoice = { id: string; invoice_no: string; invoice_date: string; order_no: string; debtor: string; debtor_no: string; period_from: string; period_to: string; description: string; site: string; section: string; category: string; currency: string; vat_reg_no: string; source_file: string; subtotal: number; escalation: number; vat: number; vat_pct: number; total_excl: number; total_incl: number; source_pdf: string; booked_month: number; booked_year: number; is_booked: boolean; } & RecordModel; // Invoice line. `machine_code` is what the invoice prints; `asset_code` is the // register match; `booked_asset` is the machine the income is actually booked to. // These can differ — an invoice line may legitimately be charged to another machine. export type InvoiceLine = { id: string; invoice: string; line_no: number; machine_code: string; asset_code: string; description: string; qty: number; unit: string; rate: number; rate_type: string; amount_excl: number; vat: number; total_incl: number; booked_asset: string; booked_site: string; booked_month: number; booked_year: number; } & RecordModel; // Returns the last month that has any actual income — used to label forecast ranges. export function lastActualMonth(rows: MonthlyIncome[]): number { let m = 0; for (const r of rows) if (r.income > 0 && r.month > m) m = r.month; return m; } // An invoice line joined to the asset it is booked against. Kept as a plain derived // type so the page never has to deal with PocketBase expand payloads. export type BookedLine = InvoiceLine & { bookedCode: string; bookedName: string; isBooked: boolean; }; export function joinBookedLines(lines: InvoiceLine[], assets: Asset[]): BookedLine[] { const byId = new Map(assets.map((a) => [a.id, a])); return lines.map((l) => { const a = l.booked_asset ? byId.get(l.booked_asset) : undefined; return { ...l, bookedCode: a?.code || "", bookedName: a?.name || "", isBooked: Boolean(a), }; }); } // One machine can legitimately be hired to several sites in the same month — that is // multi-site use, not a duplicate. These are flagged so the reader can see the pattern, // NOT as a data error, so nothing here is styled as a problem. // // Returns a map of line id → the other (invoice, site) pairs this same machine code was // billed on over an overlapping period. export function findDuplicateMachineCodes( invoices: Invoice[], lines: BookedLine[], ): Map { const byId = new Map(invoices.map((i) => [i.id, i])); const groups = new Map(); for (const l of lines) { if (!l.machine_code) continue; if (!groups.has(l.machine_code)) groups.set(l.machine_code, []); groups.get(l.machine_code)!.push(l); } const out = new Map(); for (const [, group] of groups) { if (group.length < 2) continue; for (const line of group) { const inv = byId.get(line.invoice); if (!inv) continue; const others = new Set(); for (const other of group) { if (other.id === line.id) continue; const oInv = byId.get(other.invoice); if (!oInv || oInv.id === inv.id) continue; const overlaps = inv.period_from <= oInv.period_to && oInv.period_from <= inv.period_to; if (overlaps) others.add(oInv.invoice_no); } if (others.size) out.set(line.id, [...others]); } } return out; } export type Site = { id: string; name: string; code: string; notes: string; latitude: number; longitude: number; container_asset: string; container_code: string; is_active: boolean; } & RecordModel; export const MONTH_NAMES = [ "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec", ];