mega-plant/components/InvoiceTable.tsx

290 lines
13 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";
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>
)}
<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"
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
: "bg-[#D1495B]/[0.04] dark:bg-[#1a1319]"
}`}
>
<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>
<td className="py-3 px-3 w-[220px]">
<BookingSelect lineId={l.id} value={l.booked_asset || ""} assets={options} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<ScrollFade />
</div>
);
}