mega-plant/components/InvoiceTable.tsx

302 lines
14 KiB
TypeScript
Raw Normal View History

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
import type { Asset, BookedLine, Invoice } from "@/lib/data";
import { ZAR2 } from "@/lib/utils";
import { AlertTriangle, CheckCircle2 } from "lucide-react";
import { BookingSelect } from "@/components/BookingSelect";
import { ScrollFade } from "@/components/ScrollFade";
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 { SiteBookingSelect, SiteBookingLegend } from "@/components/SiteBookingSelect";
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
import { InvoiceUploader, AttachedDocument } from "@/components/InvoiceUploader";
export function InvoiceSummary({
invoice,
lines,
assets,
embedded,
}: {
invoice: Invoice;
lines: BookedLine[];
assets: Asset[];
// Inside the expandable invoice row the surrounding card already shows the number and
// site, so the header block is dropped to avoid saying the same thing twice.
embedded?: boolean;
}) {
const unbooked = lines.filter((l) => !l.isBooked);
// Two different numbers, both worth showing.
// ex-VAT = what actually lands in the P&L as income for the machine.
// incl-VAT= what the debtor actually pays (ex-VAT + SARS VAT).
// Booking is done ex-VAT: VAT is collected for SARS, never income.
const sum = (ls: BookedLine[], k: "amount_excl" | "vat") => ls.reduce((s, l) => s + (l[k] || 0), 0);
const bookedExVat = sum(lines.filter((l) => l.isBooked), "amount_excl");
const bookedVat = sum(lines.filter((l) => l.isBooked), "vat");
const unbookedExVat = sum(unbooked, "amount_excl");
const unbookedVat = sum(unbooked, "vat");
const period = `${invoice.period_from.slice(0, 10)} → ${invoice.period_to.slice(0, 10)}`;
const periodMonth = new Date(invoice.period_to).toLocaleDateString("en-ZA", {
month: "long",
year: "numeric",
timeZone: "UTC",
});
return (
<div
className={
embedded
? "bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl shadow-sm overflow-hidden"
: "bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm overflow-hidden mb-6"
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
}
>
{!embedded && (
<div className="p-5 border-b border-gray-100 dark:border-border">
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
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm font-semibold px-2.5 py-1 rounded-md bg-[#00798C]/10 text-[#00798C]">
{invoice.invoice_no}
</span>
{/* Site is transcribed from the invoice's own description. There is no
sites collection yet — this is a plain label until one exists. */}
{invoice.site && (
<span className="text-sm px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49]">
{invoice.site}
</span>
)}
<span className="text-sm font-medium text-[#003D5B] dark:text-white">{invoice.debtor}</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">{invoice.description}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-sm text-gray-500 dark:text-gray-400">
<span>Order {invoice.order_no || "—"}</span>
<span>Debtor {invoice.debtor_no || "—"}</span>
<span>{period}</span>
<span>{invoice.currency || "ZAR"}</span>
</div>
</div>
{/* The headline is the VAT-inclusive invoice total — what the debtor pays. */}
<div className="text-right shrink-0">
<div className="text-sm text-gray-500 dark:text-gray-400">Invoice total (incl VAT)</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(invoice.total_incl)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 tabular-nums">
{ZAR2(invoice.total_excl)} + {ZAR2(invoice.vat)} VAT
{invoice.vat_pct ? ` @ ${invoice.vat_pct}%` : ""}
</div>
</div>
</div>
</div>
)}
{embedded && (
<div className="px-4 py-3 border-b border-gray-100 dark:border-border flex flex-wrap items-center justify-between gap-3">
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
<div className="min-w-0">
<div className="text-sm font-medium text-[#003D5B] dark:text-white">
{invoice.debtor} · {invoice.description}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-sm text-gray-500 dark:text-gray-400">
<span>Order {invoice.order_no || "—"}</span>
<span>Debtor {invoice.debtor_no || "—"}</span>
<span>{period}</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="text-sm text-gray-500 dark:text-gray-400">Invoice total (incl VAT)</div>
<div className="text-xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(invoice.total_incl)}
</div>
</div>
</div>
)}
{/* Booking status. An unassigned line is never guessed at — it stays visible
and the shortfall is stated, rather than being charged to a wrong machine. */}
{unbooked.length > 0 ? (
<div className="flex items-start gap-3 px-5 py-3 bg-[#D1495B]/5 border-b border-[#D1495B]/15">
<AlertTriangle className="w-4 h-4 text-[#D1495B] shrink-0 mt-0.5" />
<div className="text-sm text-[#D1495B] dark:text-brick-ink">
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
<p>
{unbooked.length} line{unbooked.length > 1 ? "s" : ""} not yet booked to a machine — choose a
plant item below.
</p>
<BookingMoney bookedExVat={bookedExVat} bookedVat={bookedVat} unbookedExVat={unbookedExVat} unbookedVat={unbookedVat} />
</div>
</div>
) : (
<div className="flex items-start gap-3 px-5 py-3 bg-[#00798C]/5 border-b border-[#00798C]/15">
<CheckCircle2 className="w-4 h-4 text-[#00798C] shrink-0 mt-0.5" />
<div className="text-sm text-[#00798C]">
<p>All {lines.length} lines booked to {periodMonth} income.</p>
<BookingMoney bookedExVat={bookedExVat} bookedVat={bookedVat} />
</div>
</div>
)}
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
<div className="px-5 py-3 border-b border-gray-100 dark:border-white/5">
<SiteBookingLegend />
</div>
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
<InvoiceTable lines={lines} assets={assets} />
<div className="p-5 border-t border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
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
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6">
<div className="lg:max-w-sm w-full">
<div className="text-sm font-medium text-[#003D5B] dark:text-white mb-2">
Source document
</div>
{invoice.source_pdf ? (
<AttachedDocument invoiceNo={invoice.invoice_no} invoiceId={invoice.id} />
) : (
<InvoiceUploader invoiceNo={invoice.invoice_no} />
)}
</div>
<dl className="w-full lg:w-80 shrink-0 space-y-2">
<TotalRow label="Hire (excl VAT)" value={invoice.total_excl} />
{invoice.escalation > 0 && <TotalRow label="Escalation" value={invoice.escalation} />}
<TotalRow
label={`VAT${invoice.vat_pct ? ` @ ${invoice.vat_pct}%` : ""}`}
value={invoice.vat}
/>
<div className="border-t border-gray-200 dark:border-white/10 pt-2">
<TotalRow label="Invoice total (incl VAT)" value={invoice.total_incl} strong />
</div>
</dl>
</div>
</div>
</div>
);
}
// Shows the booked money three ways at once, so nobody has to guess which figure the
// dashboard is using:
//
// booked to income (ex VAT) → what lands in the P&L for the machine
// VAT → collected for SARS, NOT income
// billed to customer → ex-VAT + VAT, what the debtor actually pays
function BookingMoney({
bookedExVat,
bookedVat,
unbookedExVat = 0,
unbookedVat = 0,
}: {
bookedExVat: number;
bookedVat: number;
unbookedExVat?: number;
unbookedVat?: number;
}) {
return (
<div className="mt-1.5 flex flex-wrap gap-x-5 gap-y-1">
<Item label="Booked to income (ex VAT)" value={bookedExVat} strong />
<Item label="VAT (to SARS, not income)" value={bookedVat} />
<Item label="Billed to customer" value={bookedExVat + bookedVat} strong />
{unbookedExVat > 0 && (
<>
<Item label="Still unbooked (ex VAT)" value={unbookedExVat} strong />
<Item label="Still unbooked (billed)" value={unbookedExVat + unbookedVat} />
</>
)}
</div>
);
}
function Item({ label, value, strong }: { label: string; value: number; strong?: boolean }) {
return (
<span className="inline-flex items-baseline gap-1.5">
<span className="opacity-80">{label}</span>
<span className={`font-semibold tabular-nums ${strong ? "underline decoration-dotted underline-offset-2" : ""}`}>
{ZAR2(value)}
</span>
</span>
);
}
function TotalRow({ label, value, strong }: { label: string; value: number; strong?: boolean }) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-gray-500 dark:text-gray-400">{label}</dt>
<dd
className={`text-sm tabular-nums ${
strong ? "font-semibold text-[#003D5B] dark:text-white" : "text-gray-700 dark:text-gray-300"
}`}
>
{ZAR2(value || 0)}
</dd>
</div>
);
}
export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: Asset[] }) {
const options = assets
.filter((a) => a.is_active !== false)
.map((a) => ({ id: a.id, code: a.code || "", name: a.name, category: a.category }));
return (
// Rule 1 + 4: the table scrolls inside its own container, so the page itself never
// scrolls sideways at 375px. `-webkit-overflow-scrolling` is not a Tailwind utility —
// written as a class it is inert — so it is set inline to actually take effect.
<div className="group relative">
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
<th className="text-left py-3 px-5 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">
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
Machine
</th>
<th className="text-left py-3 px-3 font-medium">Description</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Qty</th>
<th className="text-left py-3 px-3 font-medium">Unit</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Rate</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Hire ex VAT</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">VAT</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Total incl</th>
<th className="text-left py-3 px-3 font-medium whitespace-nowrap">Booked to</th>
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr
key={l.id}
className={`border-b border-gray-50 dark:border-border ${
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
l.isBooked ? "hover:bg-gray-50/40 dark:hover:bg-white/[0.02]" : "bg-[#D1495B]/[0.04]"
}`}
>
{/* Sticky first column — keeps the machine identifiable while scrolling.
It needs its own opaque background, so it has to repeat the row's
unbooked tint; otherwise the flag disappears under the white. */}
<td
className={`py-3 px-5 sticky left-0 z-10 ${
l.isBooked
? "bg-white dark:bg-surface"
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
: "bg-[#D1495B]/[0.04] dark:bg-[#292934]"
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
}`}
>
<div className="flex items-center gap-2 whitespace-nowrap">
<span className="text-xs text-gray-400 dark:text-text-soft tabular-nums">{l.line_no}</span>
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
<span className="font-mono text-xs font-semibold text-[#00798C]">{l.machine_code}</span>
</div>
</td>
<td className="py-3 px-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">{l.description}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{l.qty}</td>
<td className="py-3 px-3 text-gray-500 dark:text-gray-400">{l.unit}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.rate)}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.amount_excl)}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{ZAR2(l.vat)}</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(l.total_incl)}
</td>
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
<td className="py-3 px-3">
<div className="flex flex-col sm:flex-row gap-2 sm:items-start">
<div className="min-w-[180px]">
<BookingSelect lineId={l.id} value={l.booked_asset || ""} assets={options} />
</div>
{(!l.isBooked || l.booked_site) && (
<SiteBookingSelect lineId={l.id} value={l.booked_site || ""} />
)}
</div>
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
</td>
</tr>
))}
</tbody>
</table>
</div>
<ScrollFade />
</div>
);
}