- Job cards scanner: oil QR books litres + vehicle (no odo/photo), bundled onto the vehicle's job card (open job now, or the next job started the same day) - Fuel tab is diesel only; /api/clock/oil-out added - Users: Full / Clock-in user / Mechanic; mechanics see only Fuel + Job cards - Plant Register: QR code column (double-click to set), kept across re-seeds and used when matching scans Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
361 lines
10 KiB
TypeScript
361 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;
|
|
// 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<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",
|
|
];
|