Split the invoice register into Internal and External Invoices
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.
This commit is contained in:
parent
ad4ac9173b
commit
6f66dff2a6
19 changed files with 438 additions and 86 deletions
42
AGENTS.md
42
AGENTS.md
|
|
@ -37,6 +37,48 @@ durable first — they are not in the repo.
|
|||
(`PHI 166` → "West Park", `PHI 167` → "Halfway Gardens"). There is **no `sites` collection
|
||||
yet** — build one when the client has sites to list, and back-fill `site` from this field.
|
||||
|
||||
## Internal vs external invoices — one collection, two pages
|
||||
|
||||
The register is split in two by the **debtor printed on the invoice**:
|
||||
|
||||
- **`/income/invoices` — Internal Invoices.** The debtor is a Mega group company.
|
||||
- **`/income/external-invoices` — External Invoices.** The debtor is a third party.
|
||||
|
||||
**This is one `invoices` collection filtered two ways, never two collections.** Do not
|
||||
"fix" it by giving each page its own collection — that would let a row exist on neither
|
||||
page and make the totals unable to reconcile against `/income`. The split lives in
|
||||
`lib/invoice-party-policy.json` (the entity list) and `lib/invoice-party.ts` (the matching),
|
||||
and both pages render through the single `components/InvoiceRegisterPage.tsx`, so they
|
||||
cannot drift apart the way two hand-written pages would.
|
||||
|
||||
As of 2026-10-08 this splits the 19 invoices in the register **16 internal / 3 external**:
|
||||
|
||||
- Internal — debtor `Mega Infrastructure`, no `09MIS001`. All the seeded `data/source/invoice-*.json` files.
|
||||
- External — `PHI 178` (`09CIG001`), `PHI 186` (`09DTE001`), `PHI 192` (`09DAS001`).
|
||||
|
||||
⚠️ **A third-party invoice may carry an empty `debtor` name and only a `debtor_no`.** All three
|
||||
external invoices do exactly that. So the policy matches on **name OR debtor number**, never
|
||||
name alone — matching by name only would misfile every one of them. An invoice naming neither is
|
||||
never internal: it lands on External and the page reports it as unclassified.
|
||||
|
||||
Three consequences that are easy to get wrong:
|
||||
|
||||
- **Every write must revalidate BOTH pages.** Booking a line, importing a PDF and retrying an
|
||||
intake can land on either side depending on the debtor, so a bare
|
||||
`revalidatePath("/income/invoices")` leaves the other page showing stale rows. Use
|
||||
`revalidateInvoicePages(...)` from `lib/invoice-revalidate.ts` — never a bare
|
||||
`revalidatePath` for anything touching `invoices` or `invoice_lines`.
|
||||
- **Route paths are client-safe; the revalidate helper is not.** `lib/invoice-routes.ts` holds
|
||||
the path constants and imports no `next/*`, so a client component may import it.
|
||||
`lib/invoice-revalidate.ts` imports `next/cache` and is server-only.
|
||||
- **The two routes are siblings, never nested.** `isGroupActive` in `Sidebar.tsx` matches a
|
||||
child by path prefix, so `/income/invoices/external` would satisfy the prefix test for both
|
||||
leaves and highlight "Internal" and "External" together.
|
||||
|
||||
`invoices.category` is `"External Plant Hire"` on **every** invoice, internal ones included —
|
||||
it is the supplier's own wording transcribed from the PDF, **not** this internal/external
|
||||
classification. Never derive the split from it.
|
||||
|
||||
## Invoice booking — run order matters
|
||||
|
||||
`npm run seed` **wipes `monthly_income`** and re-imports only the workbook, so invoice-derived
|
||||
|
|
|
|||
12
app/(dashboard)/income/external-invoices/page.tsx
Normal file
12
app/(dashboard)/income/external-invoices/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { InvoiceRegisterPage } from "@/components/InvoiceRegisterPage";
|
||||
|
||||
// Reads PocketBase on every request, same as /income — see the note in app/income/page.tsx.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// The external half of the register: invoices billed by a third party outside the Mega
|
||||
// group. Deliberately a SIBLING of /income/invoices, not a child of it: a nested path
|
||||
// would satisfy `isGroupActive`'s prefix test for BOTH leaves at once, so "Internal" and
|
||||
// "External" would highlight together. As siblings, exactly one leaf is ever active.
|
||||
export default function ExternalInvoicesPage() {
|
||||
return <InvoiceRegisterPage scope="external" />;
|
||||
}
|
||||
|
|
@ -1,63 +1,11 @@
|
|||
import { getAssets, getInvoices, getInvoiceLines, joinBookedLines } from "@/lib/server-data";
|
||||
import { FileText } from "lucide-react";
|
||||
import { InvoiceList, EmptyInvoices } from "@/components/InvoiceList";
|
||||
import { AddInvoiceButton, ProblemInbox } from "@/components/InvoiceIntake";
|
||||
import { InvoiceRegisterPage } from "@/components/InvoiceRegisterPage";
|
||||
|
||||
// Reads PocketBase on every request, same as /income — see the note in app/income/page.tsx.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
const [invoices, lines, assets] = await Promise.all([
|
||||
getInvoices(),
|
||||
getInvoiceLines(),
|
||||
getAssets(),
|
||||
]);
|
||||
|
||||
// Group the lines per invoice once, so the client table gets plain data rather than
|
||||
// making it re-derive the grouping on every render.
|
||||
const linesByInvoice: Record<string, ReturnType<typeof joinBookedLines>> = {};
|
||||
const joined = joinBookedLines(lines, assets);
|
||||
for (const l of joined) {
|
||||
(linesByInvoice[l.invoice] ||= []).push(l);
|
||||
}
|
||||
|
||||
const unbookedCount = joined.filter((l) => !l.isBooked).length;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#00798C]/15 to-[#003D5B]/15 grid place-items-center text-[#00798C]">
|
||||
<FileText className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Invoices</h1>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
One row per invoice · click to open the line items and book each to a plant item
|
||||
</p>
|
||||
</div>
|
||||
<AddInvoiceButton />
|
||||
</div>
|
||||
|
||||
<ProblemInbox />
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<EmptyInvoices />
|
||||
) : (
|
||||
<InvoiceList invoices={invoices} linesByInvoice={linesByInvoice} assets={assets} />
|
||||
)}
|
||||
|
||||
{unbookedCount > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
|
||||
{unbookedCount} line{unbookedCount > 1 ? "s" : ""} awaiting a target machine. Rows flagged{" "}
|
||||
<span className="font-medium text-[#D1495B]">unbooked</span> above — open one and pick a plant
|
||||
item in the <span className="font-medium text-[#003D5B] dark:text-white">Booked to</span> column
|
||||
and it posts to that machine's income immediately. To make it the default, set the asset in{" "}
|
||||
<code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> and re-run{" "}
|
||||
<code className="font-mono text-[#00798C]">npm run seed</code>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// The internal half of the register: invoices billed to a Mega group company.
|
||||
// The external half is a sibling route, NOT a child — see the note in Sidebar.tsx on
|
||||
// `isGroupActive` matching a parent's path prefix.
|
||||
export default function InternalInvoicesPage() {
|
||||
return <InvoiceRegisterPage scope="internal" />;
|
||||
}
|
||||
|
|
@ -34,10 +34,26 @@ const navItems: NavItem[] = [
|
|||
icon: <TrendingUp className="w-4 h-4" />,
|
||||
children: [
|
||||
{ href: "/income", label: "Overview" },
|
||||
{ href: "/income/invoices", label: "Invoices" },
|
||||
// The invoice register is split in two by the debtor on the invoice — see
|
||||
// lib/invoice-party-policy.json. The two are SIBLING routes on purpose: a nested
|
||||
// path would satisfy isGroupActive's prefix test for both leaves at once and
|
||||
// highlight both at the same time.
|
||||
{ href: "/income/invoices", label: "Internal Invoices" },
|
||||
{ href: "/income/external-invoices", label: "External Invoices" },
|
||||
],
|
||||
},
|
||||
// Running Costs became a group so Insurance could sit under it. The Overview
|
||||
// leaf stays at the EXISTING /running path — the current page becomes the
|
||||
// overview, it does not move, so no bookmark or GlobalSearch target breaks.
|
||||
{
|
||||
kind: "group",
|
||||
label: "Running Costs",
|
||||
icon: <Wrench className="w-4 h-4" />,
|
||||
children: [
|
||||
{ href: "/running", label: "Overview" },
|
||||
{ href: "/running/insurance", label: "Insurance" },
|
||||
],
|
||||
},
|
||||
{ kind: "link", href: "/running", label: "Running Costs", icon: <Wrench className="w-4 h-4" /> },
|
||||
{
|
||||
kind: "group",
|
||||
label: "Expenses",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import Link from "next/link";
|
|||
import { notFound } from "next/navigation";
|
||||
import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines, MONTH_NAMES } from "@/lib/server-data";
|
||||
import { buildSiteSummaries } from "@/lib/sites";
|
||||
import { INTERNAL_INVOICES_PATH, EXTERNAL_INVOICES_PATH } from "@/lib/invoice-routes";
|
||||
import { ZAR2 } from "@/lib/utils";
|
||||
import { ScrollFade } from "@/components/ScrollFade";
|
||||
import { ArrowLeft, MapPin, FileText } from "lucide-react";
|
||||
|
|
@ -240,7 +241,14 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:
|
|||
machine or a site — {ZAR2(summary.unbookedAmount)} incl VAT.
|
||||
</p>
|
||||
<Link
|
||||
href="/income/invoices"
|
||||
href={
|
||||
// Send the reader to the half of the register that actually holds these
|
||||
// lines. Internal wins a tie because it is where every seeded invoice
|
||||
// lives today, so this preserves the existing destination.
|
||||
summary.unbookedExternalCount > summary.unbookedInternalCount
|
||||
? EXTERNAL_INVOICES_PATH
|
||||
: INTERNAL_INVOICES_PATH
|
||||
}
|
||||
className="inline-flex items-center min-h-[44px] text-sm font-medium text-[#D1495B] hover:underline mt-1"
|
||||
>
|
||||
Go and assign them
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { serverAuth } from "@/lib/pb";
|
||||
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
|
||||
import { takenSiteIdsFor } from "@/lib/sites";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -32,8 +32,7 @@ export async function POST(
|
|||
|
||||
if (body.clear) {
|
||||
await pb.collection("invoice_lines").update(id, { booked_asset: "", booked_site: "" });
|
||||
revalidatePath("/income/invoices");
|
||||
revalidatePath("/sites");
|
||||
revalidateInvoicePages("/sites");
|
||||
return Response.json({ ok: true, booked: false });
|
||||
}
|
||||
|
||||
|
|
@ -57,9 +56,7 @@ export async function POST(
|
|||
invoice_line: id,
|
||||
});
|
||||
await refreshBookedState(pb, line.invoice);
|
||||
revalidatePath("/income/invoices");
|
||||
revalidatePath("/income");
|
||||
revalidatePath("/sites");
|
||||
revalidateInvoicePages("/income", "/sites");
|
||||
return Response.json({
|
||||
ok: true,
|
||||
booked: true,
|
||||
|
|
@ -86,8 +83,7 @@ export async function POST(
|
|||
for (const row of existing) await pb.collection("monthly_income").delete(row.id);
|
||||
await pb.collection("invoice_lines").update(id, { booked_site: body.siteId, booked_asset: "" });
|
||||
await refreshBookedState(pb, line.invoice);
|
||||
revalidatePath("/income/invoices");
|
||||
revalidatePath("/sites");
|
||||
revalidateInvoicePages("/sites");
|
||||
return Response.json({ ok: true, booked: true, target: "site", site: { id: site.id, name: site.name } });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { serverAuth } from "@/lib/pb";
|
||||
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
|
||||
import { canReplaceRetrySource, intakeRetryHeaders } from "@/lib/intake-retry";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -62,15 +62,14 @@ export async function POST(
|
|||
|
||||
if (result.ok) {
|
||||
await pb.collection("invoice_intake").delete(id);
|
||||
revalidatePath("/income/invoices");
|
||||
revalidatePath("/income");
|
||||
revalidateInvoicePages("/income");
|
||||
return Response.json({ ok: true, retried: true, ...result });
|
||||
}
|
||||
|
||||
// Still failing — keep it in the inbox with the newest reason, and drop the
|
||||
// duplicate record the retry just created so the inbox stays one row per file.
|
||||
await pb.collection("invoice_intake").delete(id).catch(() => {});
|
||||
revalidatePath("/income/invoices");
|
||||
revalidateInvoicePages();
|
||||
return Response.json(
|
||||
{ ok: false, retried: true, stillFailing: true, error: result.error ?? "Import failed again" },
|
||||
{ status: 422 },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { serverAuth } from "@/lib/pb";
|
||||
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ export async function DELETE(
|
|||
);
|
||||
}
|
||||
await pb.collection("invoice_intake").delete(id);
|
||||
revalidatePath("/income/invoices");
|
||||
revalidateInvoicePages();
|
||||
return Response.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return Response.json({ ok: false, error: e?.message || "Could not remove it" }, { status: 500 });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { serverAuth } from "@/lib/pb";
|
||||
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
|
||||
import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser";
|
||||
import { syncSitesFromInvoices, findSiteContainer, resolveSiteName } from "@/lib/sites";
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ export async function POST(req: Request) {
|
|||
new File([bytes], file.name.replace(/[^\w.\- ]+/g, "_"), { type: "application/pdf" }),
|
||||
);
|
||||
const rec = await pb.collection("invoice_intake").create(form2);
|
||||
revalidatePath("/income/invoices");
|
||||
revalidateInvoicePages();
|
||||
return Response.json({ ok: false, status: "failed", intakeId: rec.id, error: reason }, { status: 422 });
|
||||
};
|
||||
|
||||
|
|
@ -251,8 +251,7 @@ export async function POST(req: Request) {
|
|||
);
|
||||
await pb.collection("invoice_intake").create(form2);
|
||||
|
||||
revalidatePath("/income/invoices");
|
||||
revalidatePath("/income");
|
||||
revalidateInvoicePages("/income");
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { serverAuth } from "@/lib/pb";
|
||||
import { revalidateInvoicePages } from "@/lib/invoice-revalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ export async function POST(req: Request) {
|
|||
|
||||
const updated = await pb.collection("invoices").update(target.id, form2);
|
||||
|
||||
revalidatePath("/income/invoices");
|
||||
revalidateInvoicePages();
|
||||
|
||||
const stored = updated.source_pdf as string | undefined;
|
||||
return Response.json({
|
||||
|
|
|
|||
|
|
@ -285,15 +285,22 @@ function Stat({ label, value, tone }: { label: string; value: string; tone: "tea
|
|||
);
|
||||
}
|
||||
|
||||
// Kept so an empty folder still renders a usable page.
|
||||
export function EmptyInvoices() {
|
||||
// Kept so an empty folder still renders a usable page. `hint` is supplied by the shared
|
||||
// register page because the two halves are filled differently — internal invoices are
|
||||
// seeded from data/source, external ones arrive through the upload dialog — so the same
|
||||
// "run npm run seed" advice would be wrong on one of them.
|
||||
export function EmptyInvoices({ hint }: { hint?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm p-10 text-center">
|
||||
<FileText className="w-8 h-8 mx-auto text-gray-300 dark:text-text-soft mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">No invoices yet.</p>
|
||||
<p className="text-sm text-gray-400 dark:text-text-soft mt-1">
|
||||
Add a <code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> then run{" "}
|
||||
<code className="font-mono text-[#00798C]">npm run seed</code>.
|
||||
{hint ?? (
|
||||
<>
|
||||
Add a <code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> then run{" "}
|
||||
<code className="font-mono text-[#00798C]">npm run seed</code>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
138
components/InvoiceRegisterPage.tsx
Normal file
138
components/InvoiceRegisterPage.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { Building2, FileText } from "lucide-react";
|
||||
import { getAssets, getInvoices, getInvoiceLines, joinBookedLines } from "@/lib/server-data";
|
||||
import { InvoiceList, EmptyInvoices } from "@/components/InvoiceList";
|
||||
import { AddInvoiceButton, ProblemInbox } from "@/components/InvoiceIntake";
|
||||
import { filterByParty, unclassifiedCount, type PartyScope } from "@/lib/invoice-party";
|
||||
|
||||
// Both invoice screens render through this one component, split by `scope`.
|
||||
//
|
||||
// The register is ONE PocketBase collection. `scope` only decides which half is shown,
|
||||
// so the two pages cannot drift apart the way two hand-written pages would — the AGENTS.md
|
||||
// rule for /sites (keep the cards in step with /income) applies here for the same reason.
|
||||
//
|
||||
// Reads PocketBase on every request, same as /income — see the note in app/income/page.tsx.
|
||||
// `export const dynamic` deliberately does NOT live here: Next.js only honours that flag on
|
||||
// a route entry (page/layout/route), so it is declared in both pages that render this.
|
||||
|
||||
const COPY: Record<PartyScope, {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: typeof FileText;
|
||||
iconFrom: string;
|
||||
iconTo: string;
|
||||
iconText: string;
|
||||
addLabel: string;
|
||||
emptyHint: React.ReactNode;
|
||||
}> = {
|
||||
internal: {
|
||||
title: "Internal Invoices",
|
||||
subtitle:
|
||||
"Billed to a Mega group company · one row per invoice · click to open the line items and book each to a plant item",
|
||||
icon: FileText,
|
||||
iconFrom: "from-[#00798C]/15",
|
||||
iconTo: "to-[#003D5B]/15",
|
||||
iconText: "text-[#00798C]",
|
||||
addLabel: "Add Invoice",
|
||||
emptyHint: (
|
||||
<>
|
||||
Add a <code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> then run{" "}
|
||||
<code className="font-mono text-[#00798C]">npm run seed</code>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
external: {
|
||||
title: "External Invoices",
|
||||
subtitle:
|
||||
"Billed by a third party outside the Mega group · one row per invoice · click to open the line items and book each to a plant item",
|
||||
icon: Building2,
|
||||
iconFrom: "from-[#30638E]/15",
|
||||
iconTo: "to-[#003D5B]/15",
|
||||
iconText: "text-[#30638E]",
|
||||
addLabel: "Add Invoice",
|
||||
emptyHint: (
|
||||
<>
|
||||
No invoice from outside the Mega group yet. Use <strong>Add Invoice</strong> to import
|
||||
one — the party is read from the debtor it names.
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export async function InvoiceRegisterPage({ scope }: { scope: PartyScope }) {
|
||||
const copy = COPY[scope];
|
||||
|
||||
const [allInvoices, lines, assets] = await Promise.all([
|
||||
getInvoices(),
|
||||
getInvoiceLines(),
|
||||
getAssets(),
|
||||
]);
|
||||
|
||||
// Filter BEFORE grouping, so each page only carries its own half to the client. The
|
||||
// unbooked count below is derived from that same half — an unbooked line on the
|
||||
// external page must never inflate the internal page's footer.
|
||||
const invoices = filterByParty(allInvoices, scope);
|
||||
|
||||
// Group the lines per invoice once, so the client table gets plain data rather than
|
||||
// making it re-derive the grouping on every render.
|
||||
const wanted = new Set(invoices.map((i) => i.id));
|
||||
const linesByInvoice: Record<string, ReturnType<typeof joinBookedLines>> = {};
|
||||
const joined = joinBookedLines(lines, assets).filter((l) => wanted.has(l.invoice));
|
||||
for (const l of joined) {
|
||||
(linesByInvoice[l.invoice] ||= []).push(l);
|
||||
}
|
||||
|
||||
const unbookedCount = joined.filter((l) => !l.isBooked).length;
|
||||
|
||||
// An invoice that names no debtor could not be placed by evidence. It is shown here
|
||||
// rather than filed as internal, and counted so the reader knows why it is in this list.
|
||||
const unclassified = scope === "external" ? unclassifiedCount(invoices) : 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${copy.iconFrom} ${copy.iconTo} grid place-items-center ${copy.iconText}`}
|
||||
>
|
||||
<copy.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">{copy.title}</h1>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">{copy.subtitle}</p>
|
||||
</div>
|
||||
<AddInvoiceButton />
|
||||
</div>
|
||||
|
||||
<ProblemInbox />
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<EmptyInvoices hint={copy.emptyHint} />
|
||||
) : (
|
||||
<InvoiceList invoices={invoices} linesByInvoice={linesByInvoice} assets={assets} />
|
||||
)}
|
||||
|
||||
{unclassified > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
|
||||
{unclassified} invoice{unclassified > 1 ? "s" : ""} name no debtor, so{" "}
|
||||
<span className="font-medium text-[#003D5B] dark:text-white">no party</span> could be
|
||||
matched —{" "}
|
||||
{unclassified > 1 ? "they are" : "it is"} listed here rather than filed as internal. Add{" "}
|
||||
the entity to <code className="font-mono text-[#00798C]">lib/invoice-party-policy.json</code>{" "}
|
||||
if {unclassified > 1 ? "they are" : "it is"} in fact a group company.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{unbookedCount > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
|
||||
{unbookedCount} line{unbookedCount > 1 ? "s" : ""} awaiting a target machine. Rows flagged{" "}
|
||||
<span className="font-medium text-[#D1495B]">unbooked</span> above — open one and pick a plant
|
||||
item in the <span className="font-medium text-[#003D5B] dark:text-white">Booked to</span> column
|
||||
and it posts to that machine's income immediately. To make it the default, set the asset in{" "}
|
||||
<code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> and re-run{" "}
|
||||
<code className="font-mono text-[#00798C]">npm run seed</code>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
lib/invoice-party-policy.json
Normal file
12
lib/invoice-party-policy.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"_comment": "SINGLE SOURCE OF TRUTH for splitting the invoice register into INTERNAL and EXTERNAL. An invoice is INTERNAL when the debtor it names is a Mega group entity — billing between our own companies. Everything else is EXTERNAL: a third party outside the group. Read by both invoice pages (components/InvoiceRegisterPage.tsx) through lib/invoice-party.ts, so the two can never disagree about which side of the split a row is on.",
|
||||
"_why": "Every invoice in the register as of 2026-10-08 is billed to Mega Infrastructure (09MIS001) — a Mega group entity — so the whole register was internal. The two pages are a split of ONE collection, never two collections: an invoice can never be invisible on both, and moving a debtor here reclassifies it on both screens at once.",
|
||||
"_matching": "An internal debtor matches on EITHER name or debtor number, case-insensitively, with surrounding whitespace ignored. An invoice naming no debtor at all is NOT internal — it falls to the external page and is marked unclassified rather than quietly filed as internal.",
|
||||
"_addAnEntity": "Add another row when Mega invoices a sister company. Do not add individual sites or projects here — a site is not a legal entity and would misfile the whole invoice.",
|
||||
"internalDebtors": [
|
||||
{
|
||||
"name": "Mega Infrastructure",
|
||||
"debtorNo": "09MIS001"
|
||||
}
|
||||
]
|
||||
}
|
||||
69
lib/invoice-party.test.ts
Normal file
69
lib/invoice-party.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { filterByParty, isClassified, isInternalParty, partyOf, unclassifiedCount } from "./invoice-party";
|
||||
import type { Invoice } from "./data";
|
||||
|
||||
const inv = (debtor: string, debtor_no = "") =>
|
||||
({ debtor, debtor_no }) as Pick<Invoice, "debtor" | "debtor_no">;
|
||||
|
||||
// Every invoice seeded to date names Mega Infrastructure (09MIS001) — a Mega group entity —
|
||||
// so the whole register as it stands is INTERNAL. That is the whole reason the two pages
|
||||
// split cleanly today.
|
||||
const SEEDED = [
|
||||
inv("Mega Infrastructure", "09MIS001"),
|
||||
inv("Mega Infrastructure", "09MIS001"),
|
||||
];
|
||||
|
||||
test("an invoice billed to a group company is internal", () => {
|
||||
for (const i of SEEDED) {
|
||||
assert.equal(isInternalParty(i), true);
|
||||
assert.equal(partyOf(i), "internal");
|
||||
}
|
||||
});
|
||||
|
||||
test("an invoice billed by a third party is external", () => {
|
||||
const ext = inv("Civilsure Plant Hire", "CIV001");
|
||||
assert.equal(isInternalParty(ext), false);
|
||||
assert.equal(partyOf(ext), "external");
|
||||
});
|
||||
|
||||
test("the group entity matches on name OR debtor number alone", () => {
|
||||
// Invoices are transcribed by hand, so one may print the name and the other only the
|
||||
// number. Requiring both would drop a real invoice onto the wrong page.
|
||||
assert.equal(isInternalParty(inv("Mega Infrastructure", "")), true);
|
||||
assert.equal(isInternalParty(inv("", "09MIS001")), true);
|
||||
assert.equal(isInternalParty(inv(" MEGA INFRASTRUCTURE ", "09MIS001")), true);
|
||||
});
|
||||
|
||||
test("a similar name outside the policy is NOT internal", () => {
|
||||
// A near-miss must not be swept in: misfiling a third party's money as our own
|
||||
// intercompany billing is exactly the error the split exists to prevent.
|
||||
assert.equal(isInternalParty(inv("Mega Infrastructure CC", "")), false);
|
||||
assert.equal(isInternalParty(inv("Megainfra", "")), false);
|
||||
});
|
||||
|
||||
test("an invoice naming no debtor is never internal and is reported as unclassified", () => {
|
||||
const blank = inv("", "");
|
||||
assert.equal(isInternalParty(blank), false);
|
||||
assert.equal(isClassified(blank), false);
|
||||
// It still appears on the external page — nothing may be invisible on both screens.
|
||||
assert.equal(partyOf(blank), "external");
|
||||
});
|
||||
|
||||
test("filterByParty splits without losing or duplicating a row", () => {
|
||||
const all = [...SEEDED, inv("Civilsure Plant Hire", "CIV001"), inv("", "")];
|
||||
const internal = filterByParty(all, "internal");
|
||||
const external = filterByParty(all, "external");
|
||||
|
||||
assert.equal(internal.length, 2);
|
||||
assert.equal(external.length, 2);
|
||||
// Every row appears on exactly one page: the halves partition the register.
|
||||
assert.equal(internal.length + external.length, all.length);
|
||||
for (const i of internal) assert.equal(partyOf(i), "internal");
|
||||
for (const e of external) assert.equal(partyOf(e), "external");
|
||||
});
|
||||
|
||||
test("only the no-debtor row is counted as unclassified", () => {
|
||||
const all = [...SEEDED, inv("Civilsure Plant Hire", "CIV001"), inv("", "")];
|
||||
assert.equal(unclassifiedCount(all as Invoice[]), 1);
|
||||
});
|
||||
53
lib/invoice-party.ts
Normal file
53
lib/invoice-party.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import policy from "./invoice-party-policy.json";
|
||||
import type { Invoice } from "./data";
|
||||
|
||||
// Which half of the register an invoice belongs to. Both pages are the SAME PocketBase
|
||||
// collection split by this module — see ./invoice-party-policy.json for why it is one
|
||||
// collection and not two.
|
||||
//
|
||||
// Deliberately pure and import-safe from client components: no server-only, no PB. The
|
||||
// pages filter on the server before handing plain data to the client list, and this
|
||||
// module is the only thing that decides which side a row lands on.
|
||||
|
||||
export type PartyScope = "internal" | "external";
|
||||
|
||||
const norm = (v: unknown) => String(v ?? "").trim().toLowerCase();
|
||||
|
||||
// The group entities that count as internal. Matched on name OR debtor number, so an
|
||||
// invoice that prints "Mega Infrastructure" and one that prints only "09MIS001" both
|
||||
// land on the internal page.
|
||||
const INTERNAL = policy.internalDebtors.map((d) => ({
|
||||
name: norm(d.name),
|
||||
debtorNo: norm(d.debtorNo),
|
||||
}));
|
||||
|
||||
type PartyBearing = Pick<Invoice, "debtor" | "debtor_no">;
|
||||
|
||||
// True when the debtor is a known group entity.
|
||||
export function isInternalParty(inv: PartyBearing): boolean {
|
||||
const name = norm(inv.debtor);
|
||||
const no = norm(inv.debtor_no);
|
||||
if (!name && !no) return false;
|
||||
return INTERNAL.some((d) => (name && name === d.name) || (no && no === d.debtorNo));
|
||||
}
|
||||
|
||||
// An invoice naming no debtor cannot be placed by evidence, so it is reported as
|
||||
// unclassified rather than assumed internal. It still appears on the external page —
|
||||
// nothing may be invisible on both screens.
|
||||
export function isClassified(inv: PartyBearing): boolean {
|
||||
return Boolean(norm(inv.debtor) || norm(inv.debtor_no));
|
||||
}
|
||||
|
||||
export function partyOf(inv: PartyBearing): PartyScope {
|
||||
return isInternalParty(inv) ? "internal" : "external";
|
||||
}
|
||||
|
||||
export function filterByParty<T extends PartyBearing>(invoices: T[], scope: PartyScope): T[] {
|
||||
return invoices.filter((i) => partyOf(i) === scope);
|
||||
}
|
||||
|
||||
// Shown on the external page when a row landed there only because it named no debtor.
|
||||
// Keeps an unclassifiable invoice visible as a question rather than a silent filing.
|
||||
export function unclassifiedCount(invoices: Invoice[]): number {
|
||||
return invoices.filter((i) => partyOf(i) === "external" && !isClassified(i)).length;
|
||||
}
|
||||
19
lib/invoice-revalidate.ts
Normal file
19
lib/invoice-revalidate.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { INVOICE_PATHS } from "./invoice-routes";
|
||||
|
||||
// SERVER-SIDE ONLY — imports `next/cache`, so nothing client-side may pull this in. Route
|
||||
// paths live in ./invoice-routes.ts for exactly that reason.
|
||||
//
|
||||
// Every mutation path must refresh BOTH halves of the register. Booking a line, importing
|
||||
// a PDF and retrying an intake can all land on either page depending on the debtor, so
|
||||
// revalidating only one leaves the other showing stale rows.
|
||||
//
|
||||
// Revalidating a page that did not change costs a re-render, not correctness — so the
|
||||
// asymmetry is free to fix and expensive to miss. Use `revalidateInvoicePages` instead of a
|
||||
// bare `revalidatePath` for anything that writes to `invoices` or `invoice_lines`.
|
||||
|
||||
// `extra` covers other routes a write may have moved, e.g. /income, whose totals shift
|
||||
// when income posts.
|
||||
export function revalidateInvoicePages(...extra: string[]) {
|
||||
for (const p of [...INVOICE_PATHS, ...extra]) revalidatePath(p);
|
||||
}
|
||||
12
lib/invoice-routes.ts
Normal file
12
lib/invoice-routes.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Route paths only — deliberately free of any `next/*` import so a CLIENT component may
|
||||
// import these without dragging server code into the browser bundle. The revalidating
|
||||
// helper lives in ./invoice-revalidate.ts and is server-side.
|
||||
//
|
||||
// The two halves of the invoice register. Both pages read the SAME `invoices` collection
|
||||
// and differ only in which debtors they show (see lib/invoice-party-policy.json). They are
|
||||
// SIBLING routes: a nested path would satisfy `isGroupActive`'s prefix test for both leaves
|
||||
// at once, so "Internal" and "External" would highlight together.
|
||||
|
||||
export const INTERNAL_INVOICES_PATH = "/income/invoices";
|
||||
export const EXTERNAL_INVOICES_PATH = "/income/external-invoices";
|
||||
export const INVOICE_PATHS = [INTERNAL_INVOICES_PATH, EXTERNAL_INVOICES_PATH] as const;
|
||||
14
lib/sites.ts
14
lib/sites.ts
|
|
@ -1,4 +1,10 @@
|
|||
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.
|
||||
|
|
@ -72,6 +78,12 @@ export type SiteSummary = {
|
|||
/** 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.
|
||||
|
|
@ -189,6 +201,8 @@ export function buildSiteSummaries(
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,15 @@ import assert from "node:assert/strict";
|
|||
|
||||
const base = process.env.MEGA_AUTH_BASE_URL || "http://localhost:6007";
|
||||
test("anonymous dashboard and app API requests are denied, including IDs ending in .json", async () => {
|
||||
for (const path of ["/", "/settings", "/plant", "/income/invoices", "/sites"]) {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/settings",
|
||||
"/plant",
|
||||
// Both halves of the invoice register — a new page must be guarded like any other.
|
||||
"/income/invoices",
|
||||
"/income/external-invoices",
|
||||
"/sites",
|
||||
]) {
|
||||
const response = await fetch(`${base}${path}`, { redirect: "manual" });
|
||||
assert.equal(response.status, 307);
|
||||
assert.ok(response.headers.get("location")?.endsWith("/login"));
|
||||
|
|
|
|||
Loading…
Reference in a new issue