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>
358 lines
10 KiB
TypeScript
358 lines
10 KiB
TypeScript
// 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;
|
|
// 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<string, string[]> {
|
|
const byId = new Map(invoices.map((i) => [i.id, i]));
|
|
const groups = new Map<string, BookedLine[]>();
|
|
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<string, string[]>();
|
|
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<string>();
|
|
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",
|
|
];
|