Renames the existing /income/invoices page to "Internal Invoices" and adds a sibling /income/external-invoices page, splitting ONE `invoices` collection by the debtor printed on the invoice: a Mega group company is internal, anything else is external. Never two collections. An invoice must not be able to exist on neither page, and the halves must stay reconcilable against /income. Both routes render through the single components/InvoiceRegisterPage.tsx so they cannot drift apart. lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts does the matching. Matching is on debtor name OR debtor_no, never name alone: all three external invoices (PHI 178/186/192) carry an empty debtor name and only a third-party number, so a name-only match would misfile every one of them. An invoice naming neither is never internal - it lands on External and is reported as unclassified rather than silently filed. The split is 16 internal / 3 external across the 19 invoices in the register. Also fixes three things the split would otherwise break: - Booking a line, importing a PDF and retrying an intake can all land on either side depending on the debtor, so the five write paths only revalidated /income/invoices and would have left the other page stale. They now call revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in lib/invoice-routes.ts, which imports no next/* so a client component may import it; the revalidate helper imports next/cache and is server-only. - A site's "Go and assign them" link hard-coded the internal path, so a site whose outstanding lines sit on external invoices would dead-end. SiteSummary now carries the outstanding lines split by party. - verify-auth-routes.mjs now covers the new route. The two routes are siblings by design: isGroupActive matches a child by path prefix, so a nested /income/invoices/external would highlight both leaves at once. Verified: typecheck clean, 41 tests pass (7 new), production build green, anonymous access to both routes 307s to /login, and the mobile checklist passes at 375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no text under 12px. The nested line-item table still scrolls inside its own container without stretching the outer table. Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are someone else's in-flight work and were deliberately left uncommitted here.
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
import type { Asset, BookedLine, Invoice, Site } from "./data";
|
|
import { isInternalParty } from "./invoice-party";
|
|
|
|
// A line whose invoice is missing cannot be classified, so it must not be counted as
|
|
// internal by accident. An invoice with no debtor is never internal — see
|
|
// lib/invoice-party.ts — so an empty placeholder counts as external.
|
|
const EMPTY_INVOICE = { debtor: "", debtor_no: "" } as Pick<Invoice, "debtor" | "debtor_no">;
|
|
|
|
// 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 SiteInvoiceRef = {
|
|
id: string;
|
|
invoiceNo: string;
|
|
invoiceDate: string;
|
|
periodFrom: string;
|
|
periodTo: string;
|
|
/** This site's share of the invoice — the sum of the site's lines, not the header
|
|
* total. Equal to the header total today, because an invoice carries one site, but
|
|
* summing lines keeps it right if an invoice ever spans two. */
|
|
totalIncl: number;
|
|
hasPdf: boolean;
|
|
};
|
|
|
|
/** One month of billing for a site. A month holds one or more invoices. */
|
|
export type SiteMonth = {
|
|
key: string; // "2026-09"
|
|
year: number;
|
|
month: number; // 1-12
|
|
totalExcl: number;
|
|
vat: number;
|
|
totalIncl: number;
|
|
invoices: SiteInvoiceRef[];
|
|
};
|
|
|
|
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 }[];
|
|
/** The calendar year the `months` series covers, so the chart is never ambiguous. */
|
|
year: number;
|
|
/** 12 slots of billed amount incl VAT. null = no invoice that month, which is
|
|
* different from a billed zero. Same convention as IncomeRow.months. */
|
|
months: (number | null)[];
|
|
/** Only the months that actually have an invoice, ascending. Drives the expanded
|
|
* panel, so a month with no billing is simply absent rather than a zero row. */
|
|
monthly: SiteMonth[];
|
|
machineCount: number;
|
|
lineCount: number;
|
|
/** True once the site has map coordinates. Carried on the summary so the table —
|
|
* a client component — can render the marker without also receiving the site list. */
|
|
pinned: boolean;
|
|
/** Lines with neither a plant item nor a site — the work still outstanding. */
|
|
unbookedCount: number;
|
|
unbookedAmount: number;
|
|
/** The outstanding lines split by which half of the register they live on. The site's
|
|
* "assign them" link has to pick a page, and the register is split in two — a site
|
|
* whose only unbooked lines are on external invoices must be sent to the external
|
|
* page, or the link lands on a page that does not contain them. */
|
|
unbookedInternalCount: number;
|
|
unbookedExternalCount: number;
|
|
};
|
|
|
|
/** The year the chart should show: the most recent one any invoice was booked into.
|
|
* Scrolling forward a year re-bases every series without a schema change. */
|
|
export function latestInvoiceYear(invoices: Invoice[]): number {
|
|
return invoices.reduce((y, i) => (i.booked_year > y ? i.booked_year : y), new Date().getUTCFullYear());
|
|
}
|
|
|
|
// Which month an invoice is billed in.
|
|
//
|
|
// `booked_month`/`booked_year` are already written by the seed from the month of
|
|
// `period_to` (scripts/seed.mjs), which is the same rule that posts the income into
|
|
// monthly_income. Reusing that field is what keeps a site's month here equal to the
|
|
// same month on /income — a site chart that disagreed with the income screen would be
|
|
// worse than no chart. The date fallback only covers a record imported before those
|
|
// fields existed.
|
|
function invoiceMonth(inv: Invoice): { year: number; month: number } {
|
|
if (inv.booked_month >= 1 && inv.booked_month <= 12 && inv.booked_year) {
|
|
return { year: inv.booked_year, month: inv.booked_month };
|
|
}
|
|
const d = inv.period_to || inv.invoice_date;
|
|
if (d) {
|
|
const dt = new Date(d);
|
|
if (!Number.isNaN(dt.getTime())) return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1 };
|
|
}
|
|
return { year: 0, month: 0 };
|
|
}
|
|
|
|
// 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 year = latestInvoiceYear(invoices);
|
|
|
|
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 });
|
|
}
|
|
|
|
// Bucket the same lines by billing month. One invoice contributes several lines to
|
|
// a site (PHI 166 has five), so an invoice is listed ONCE per month and its amount
|
|
// accumulates across those lines — otherwise a site would show a card per line.
|
|
const buckets = new Map<string, SiteMonth>();
|
|
const refsByMonth = new Map<string, Map<string, SiteInvoiceRef>>();
|
|
for (const l of siteLines) {
|
|
const inv = invById.get(l.invoice);
|
|
if (!inv) continue;
|
|
const m = invoiceMonth(inv);
|
|
if (!m.year || !m.month) continue;
|
|
const key = `${m.year}-${String(m.month).padStart(2, "0")}`;
|
|
let bucket = buckets.get(key);
|
|
if (!bucket) {
|
|
bucket = { key, year: m.year, month: m.month, totalExcl: 0, vat: 0, totalIncl: 0, invoices: [] };
|
|
buckets.set(key, bucket);
|
|
refsByMonth.set(key, new Map());
|
|
}
|
|
bucket.totalExcl += l.amount_excl || 0;
|
|
bucket.vat += l.vat || 0;
|
|
bucket.totalIncl += l.total_incl || 0;
|
|
|
|
const refs = refsByMonth.get(key)!;
|
|
const seen = refs.get(inv.id);
|
|
if (seen) {
|
|
seen.totalIncl += l.total_incl || 0;
|
|
} else {
|
|
refs.set(inv.id, {
|
|
id: inv.id,
|
|
invoiceNo: inv.invoice_no,
|
|
invoiceDate: inv.invoice_date,
|
|
periodFrom: inv.period_from,
|
|
periodTo: inv.period_to,
|
|
totalIncl: l.total_incl || 0,
|
|
hasPdf: Boolean(inv.source_pdf),
|
|
});
|
|
}
|
|
}
|
|
|
|
const monthly = Array.from(buckets.values())
|
|
.sort((a, b) => a.key.localeCompare(b.key))
|
|
.map((b) => ({
|
|
...b,
|
|
invoices: Array.from(refsByMonth.get(b.key)!.values()).sort((x, y) => x.invoiceNo.localeCompare(y.invoiceNo)),
|
|
}));
|
|
|
|
// 12-slot series for the sparkline. Months from OTHER years are dropped rather
|
|
// than folded in — a Dec-2025 invoice must not paint a point on Jan.
|
|
const months: (number | null)[] = Array(12).fill(null);
|
|
for (const b of monthly) if (b.year === year) months[b.month - 1] = b.totalIncl;
|
|
|
|
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)),
|
|
year,
|
|
months,
|
|
monthly,
|
|
machineCount: new Set(siteLines.map((l) => l.booked_asset).filter(Boolean)).size,
|
|
lineCount: siteLines.length,
|
|
pinned: Boolean(site.latitude),
|
|
unbookedCount: unbooked.length,
|
|
unbookedAmount: unbooked.reduce((s, l) => s + (l.total_incl || 0), 0),
|
|
unbookedInternalCount: unbooked.filter((l) => isInternalParty(invById.get(l.invoice) ?? EMPTY_INVOICE)).length,
|
|
unbookedExternalCount: unbooked.filter((l) => !isInternalParty(invById.get(l.invoice) ?? EMPTY_INVOICE)).length,
|
|
};
|
|
})
|
|
.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;
|
|
}
|
|
|
|
// 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
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fold the site name an invoice prints onto the name the register already uses.
|
|
*
|
|
* The supplier abbreviates: PHI 179 reads "Crane Truck Hire Sep 2026 Cosmo" for the
|
|
* site every other invoice calls "Cosmo City". Taking that literally would create a
|
|
* SECOND site called "Cosmo" — `syncSitesFromInvoices` derives sites from the invoice
|
|
* wording — and the same customer would then split across two places on /sites, with
|
|
* neither row showing the whole picture.
|
|
*
|
|
* Only an unambiguous whole-word match folds, and only in ONE direction: the invoice's
|
|
* name must be shorter and be a whole-word prefix of a site already in the register.
|
|
* That is what an abbreviation looks like. The reverse — folding "Cosmo City Extension"
|
|
* onto "Cosmo City" — would silently merge two genuinely different places, so it is not
|
|
* done; an unknown name keeps the invoice's own wording and a new site is still recorded
|
|
* as new.
|
|
*/
|
|
export function resolveSiteName(raw: string, known: string[]): string {
|
|
const t = (raw || "").trim();
|
|
if (!t) return "";
|
|
const low = t.toLowerCase();
|
|
for (const k of known) {
|
|
const kk = (k || "").trim().toLowerCase();
|
|
if (!kk) continue;
|
|
if (kk === low) return k;
|
|
if (kk.startsWith(`${low} `)) return k;
|
|
}
|
|
return t;
|
|
}
|