mega-plant/lib/data.ts

362 lines
10 KiB
TypeScript
Raw Permalink Normal View History

// 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;
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
is_active: boolean;
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics 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>
2026-10-08 23:06:27 +00:00
// 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;
Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
// 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;
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics 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>
2026-10-08 23:06:27 +00:00
/**
* 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;
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics 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>
2026-10-08 23:06:27 +00:00
/**
* 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;
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
// 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;
Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
booked_site: string;
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
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;
}
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
// 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;
}
Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
export type Site = {
id: string;
name: string;
code: string;
notes: string;
latitude: number;
longitude: number;
container_asset: string;
container_code: string;
Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
is_active: boolean;
} & RecordModel;
export const MONTH_NAMES = [
"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",
];