2026-09-12 07:35:35 +00:00
|
|
|
import { serverAuth } from "./pb";
|
|
|
|
|
import type { RecordModel } from "pocketbase";
|
|
|
|
|
|
|
|
|
|
export type Asset = {
|
|
|
|
|
id: string;
|
|
|
|
|
code: string;
|
|
|
|
|
name: string;
|
|
|
|
|
category: string;
|
2026-09-12 08:24:26 +00:00
|
|
|
type: string;
|
2026-09-12 07:35:35 +00:00
|
|
|
gl_account: string;
|
|
|
|
|
supplier: string;
|
|
|
|
|
monthly_excl: number;
|
|
|
|
|
monthly_incl: number;
|
|
|
|
|
purchase_value: number;
|
2026-09-12 08:24:26 +00:00
|
|
|
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;
|
2026-10-02 16:32:12 +00:00
|
|
|
// 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;
|
2026-09-12 08:24:26 +00:00
|
|
|
} & RecordModel;
|
|
|
|
|
|
|
|
|
|
export type CostLine = {
|
|
|
|
|
id: string;
|
|
|
|
|
asset: string;
|
|
|
|
|
year: number;
|
|
|
|
|
month: number;
|
|
|
|
|
kind: "instalment" | "insurance" | "tracking" | "operator" | "running";
|
|
|
|
|
amount: number;
|
2026-09-12 07:35:35 +00:00
|
|
|
} & 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;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-09-12 07:35:35 +00:00
|
|
|
export async function getAssets(): Promise<Asset[]> {
|
|
|
|
|
const pb = await serverAuth();
|
|
|
|
|
return pb.collection("assets").getFullList<Asset>({ sort: "category,name" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getAssetsByCategory(category: string): Promise<Asset[]> {
|
|
|
|
|
const all = await getAssets();
|
|
|
|
|
return all.filter((a) => a.category === category);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getMonthlyIncome(year = 2026): Promise<MonthlyIncome[]> {
|
|
|
|
|
const pb = await serverAuth();
|
|
|
|
|
return pb.collection("monthly_income").getFullList<MonthlyIncome>({
|
|
|
|
|
filter: `year = ${year}`,
|
|
|
|
|
sort: "month",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Part 1.2: YTD aggregations operate on actuals only by default.
|
|
|
|
|
// `is_forecast` is a boolean field on monthly_income; rows where it's true are excluded.
|
|
|
|
|
export async function getActualMonthlyIncome(year = 2026): Promise<MonthlyIncome[]> {
|
|
|
|
|
const all = await getMonthlyIncome(year);
|
|
|
|
|
return all.filter((r) => !(r as any).is_forecast);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getRunningCosts(year = 2026): Promise<MonthlyRunningCost[]> {
|
|
|
|
|
const pb = await serverAuth();
|
|
|
|
|
return pb.collection("monthly_running_costs").getFullList<MonthlyRunningCost>({
|
|
|
|
|
filter: `year = ${year}`,
|
|
|
|
|
sort: "month",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getOverheads(): Promise<Overhead[]> {
|
|
|
|
|
const pb = await serverAuth();
|
|
|
|
|
return pb.collection("overheads").getFullList<Overhead>({ sort: "section,description" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getOverheadsBySection(section: string): Promise<Overhead[]> {
|
|
|
|
|
const all = await getOverheads();
|
|
|
|
|
return all.filter((o) => o.section === section);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
export async function getInvoices(): Promise<Invoice[]> {
|
|
|
|
|
const pb = await serverAuth();
|
|
|
|
|
return pb.collection("invoices").getFullList<Invoice>({ sort: "-invoice_date,-invoice_no" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getInvoiceLines(invoiceId?: string): Promise<InvoiceLine[]> {
|
|
|
|
|
const pb = await serverAuth();
|
|
|
|
|
return pb.collection("invoice_lines").getFullList<InvoiceLine>({
|
|
|
|
|
filter: invoiceId ? `invoice = "${invoiceId}"` : "",
|
|
|
|
|
sort: "line_no",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
Sites: give every site coordinates
`assets` already carried latitude/longitude, but `sites` had nowhere to put a
pin — so there was no way to locate a site. Both fields added, matching the
plant collection's shape so the same map component can plot either.
Coordinates are entered as two plain number fields with an "open in maps" link
to verify the point, rather than a drag-to-pin map: the in-app Leaflet map is
still being worked on, and a coordinate is usually copied off some other map
anyway. Clearing removes the pin rather than leaving half a coordinate, which
would plot in the Gulf of Guinea and read as "located".
Bounds are checked server-side, not just in the form: a point outside South Africa
is refused with the numbers echoed back, because a swapped pair or a dropped
digit is far likelier than a machine abroad. 0,0 is rejected by the same rule.
The sites list marks each row as pinned or "no pin", with a banner counting the
gaps, so an unlocated site is visible without opening it.
While checking this, found that all 44 assets carry the SAME coordinate
(-26.1016, 28.3487) — the Plant Map currently stacks every marker on one pin
while reporting "44 located on the map". Left alone, but worth knowing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:33:43 +00:00
|
|
|
latitude: number;
|
|
|
|
|
longitude: number;
|
2026-10-02 16:32:12 +00:00
|
|
|
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 async function getSites(): Promise<Site[]> {
|
|
|
|
|
const pb = await serverAuth();
|
|
|
|
|
return pb.collection("sites").getFullList<Site>({ sort: "name" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-12 07:35:35 +00:00
|
|
|
export const MONTH_NAMES = [
|
|
|
|
|
"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",
|
|
|
|
|
];
|