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
|
|
|
import type { Asset, BookedLine, Invoice, Site } from "./data";
|
|
|
|
|
|
|
|
|
|
// Sites are DERIVED from the invoices: each distinct invoice site becomes a site.
|
|
|
|
|
// They are not typed in by hand, so this is the only place that creates them.
|
|
|
|
|
//
|
|
|
|
|
// Existing records are never deleted and never overwritten — a site may carry notes
|
|
|
|
|
// and may have lines booked to it, so removing one would orphan that history. An
|
|
|
|
|
// invoice site that disappears simply stops appearing in new invoices.
|
|
|
|
|
export async function syncSitesFromInvoices(pb: any, invoices: Invoice[]): Promise<Site[]> {
|
|
|
|
|
const existing = (await pb.collection("sites").getFullList()) as Site[];
|
|
|
|
|
const byName = new Map(existing.map((s: Site) => [(s.name || "").toLowerCase(), s]));
|
|
|
|
|
|
|
|
|
|
const wanted = [...new Set(invoices.map((i) => (i.site || "").trim()).filter(Boolean))];
|
|
|
|
|
for (const name of wanted) {
|
|
|
|
|
if (byName.has(name.toLowerCase())) continue;
|
|
|
|
|
try {
|
|
|
|
|
const created = await pb.collection("sites").create({ name, is_active: true });
|
|
|
|
|
byName.set(name.toLowerCase(), created as Site);
|
|
|
|
|
} catch {
|
|
|
|
|
// A concurrent upload may have created it first; the catch is a no-op.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return byName.size ? Array.from(byName.values()) : existing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type SiteSummary = {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
/** Income, ex VAT — what actually lands in the P&L. */
|
|
|
|
|
revenueExcl: number;
|
|
|
|
|
vat: number;
|
|
|
|
|
/** What the debtors were billed in total. */
|
|
|
|
|
revenueIncl: number;
|
|
|
|
|
invoices: { id: string; invoiceNo: string; invoiceDate: string; totalIncl: number }[];
|
|
|
|
|
machineCount: number;
|
|
|
|
|
lineCount: number;
|
|
|
|
|
/** Lines with neither a plant item nor a site — the work still outstanding. */
|
|
|
|
|
unbookedCount: number;
|
|
|
|
|
unbookedAmount: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Per-site revenue and VAT, derived by walking invoice lines through their invoice.
|
|
|
|
|
export function buildSiteSummaries(
|
|
|
|
|
sites: Site[],
|
|
|
|
|
invoices: Invoice[],
|
|
|
|
|
lines: BookedLine[],
|
|
|
|
|
assets: Asset[],
|
|
|
|
|
): SiteSummary[] {
|
|
|
|
|
const invById = new Map(invoices.map((i) => [i.id, i]));
|
|
|
|
|
const assetById = new Map(assets.map((a) => [a.id, a]));
|
|
|
|
|
|
|
|
|
|
return sites
|
|
|
|
|
.map((site) => {
|
|
|
|
|
const siteLines = lines.filter((l) => invById.get(l.invoice)?.site === site.name);
|
|
|
|
|
const revExcl = siteLines.reduce((s, l) => s + (l.amount_excl || 0), 0);
|
|
|
|
|
const vat = siteLines.reduce((s, l) => s + (l.vat || 0), 0);
|
|
|
|
|
const incl = siteLines.reduce((s, l) => s + (l.total_incl || 0), 0);
|
|
|
|
|
|
|
|
|
|
const invMap = new Map<string, { id: string; invoiceNo: string; invoiceDate: string; totalIncl: number }>();
|
|
|
|
|
for (const l of siteLines) {
|
|
|
|
|
const inv = invById.get(l.invoice);
|
|
|
|
|
if (inv) invMap.set(inv.id, { id: inv.id, invoiceNo: inv.invoice_no, invoiceDate: inv.invoice_date, totalIncl: inv.total_incl });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const unbooked = siteLines.filter((l) => !l.isBooked && !l.booked_site);
|
|
|
|
|
return {
|
|
|
|
|
id: site.id,
|
|
|
|
|
name: site.name,
|
|
|
|
|
revenueExcl: revExcl,
|
|
|
|
|
vat,
|
|
|
|
|
revenueIncl: incl,
|
|
|
|
|
invoices: Array.from(invMap.values()).sort((a, b) => b.invoiceDate.localeCompare(a.invoiceDate)),
|
|
|
|
|
machineCount: new Set(siteLines.map((l) => l.booked_asset).filter(Boolean)).size,
|
|
|
|
|
lineCount: siteLines.length,
|
|
|
|
|
unbookedCount: unbooked.length,
|
|
|
|
|
unbookedAmount: unbooked.reduce((s, l) => s + (l.total_incl || 0), 0),
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((s) => s.invoices.length > 0 || s.lineCount > 0)
|
|
|
|
|
.sort((a, b) => b.revenueIncl - a.revenueIncl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sites already taken by another line that has no plant item.
|
|
|
|
|
// One container per site: once a site holds a container, the next container's
|
|
|
|
|
// picker must not offer it, so the outstanding containers spread across sites
|
|
|
|
|
// instead of piling onto the first one.
|
|
|
|
|
export function takenSiteIdsFor(lines: BookedLine[], exceptLineId: string): Set<string> {
|
|
|
|
|
const taken = new Set<string>();
|
|
|
|
|
for (const l of lines) {
|
|
|
|
|
if (l.id === exceptLineId) continue;
|
|
|
|
|
if (l.booked_site && !l.booked_asset) taken.add(l.booked_site);
|
|
|
|
|
}
|
|
|
|
|
return taken;
|
2026-10-02 16:32:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-allocation for a line that matches no plant item.
|
|
|
|
|
//
|
|
|
|
|
// The invoices print a generic code for hire containers ("MPLANT05 Storage Container")
|
|
|
|
|
// that the plant register does not carry. Each site records the container it hires, and
|
|
|
|
|
// the code the invoice uses for it. So a container line arriving on an invoice for a site
|
|
|
|
|
// that has a matching container is booked to that container rather than left stuck.
|
|
|
|
|
//
|
|
|
|
|
// The lookup is against THIS invoice's site only — two sites can both hire
|
|
|
|
|
// "MPLANT05" units, and each line must go to the unit actually on its own site.
|
|
|
|
|
export function findSiteContainer(
|
|
|
|
|
sites: Site[],
|
|
|
|
|
siteName: string,
|
|
|
|
|
machineCode: string,
|
|
|
|
|
): Site | null {
|
|
|
|
|
const wanted = (machineCode || "").trim().toLowerCase();
|
|
|
|
|
if (!wanted || !siteName) return null;
|
|
|
|
|
return (
|
|
|
|
|
sites.find(
|
|
|
|
|
(s) =>
|
|
|
|
|
(s.name || "").toLowerCase() === (siteName || "").toLowerCase() &&
|
|
|
|
|
(s.container_code || "").trim().toLowerCase() === wanted &&
|
|
|
|
|
Boolean(s.container_asset),
|
|
|
|
|
) ?? null
|
|
|
|
|
);
|
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
|
|
|
}
|