mega-plant/components/InvoiceList.tsx
David 80ca4788a2 Give external invoices a supplier name; keep them off the sites page
The name on an invoice means two different things. On an internal invoice it is a
SITE, transcribed from the invoice's own description. On an external invoice it is
the SUPPLIER - and the PDF does not print the supplier's company name at all, so
there is nothing to transcribe and a human has to type it.

`invoices.site` stays the single field; the column header follows the page (Site on
Internal, Supplier on External) and only the external one is editable, via
components/SupplierCell.tsx and PATCH /api/invoices/:id/site. It saves on blur or
Enter, never per keystroke, and an empty value clears the name so a stray
description fragment can be removed rather than displayed as a supplier.

Sites stop taking their names from external invoices, in both places:

- syncSitesFromInvoices only creates sites from internal invoices, so a job
  description can never be registered as a yard.
- buildSiteSummaries only counts internal invoice lines towards a site, which is
  what /sites and the dashboard's per-site chart read.

This is the "Move containers" case. PHI 192's description is "Crane Truck Hire 17
September 2026 Move containers", which had registered a site literally called
"Move containers" - a job, not a yard - on /sites, on the dashboard's per-site
chart and in the plant register's site filter. The record stays in the collection
(sites are never deleted) but is no longer surfaced; the count went 13 -> 12.
External income is not lost by this - it is booked to a machine and still appears on
/income and /charts. It is simply not a site.

The intake route stops resolving an external invoice's text against the site list,
which would have invented a site out of a description, and reports needsSupplier so
the import toast says the name is outstanding.

npm run seed now captures existing invoice names before it wipes the collection and
re-applies them where data/source does not specify one - the same data-loss trap the
uploaded PDFs already had. A JSON name always wins, so internal sites are unchanged.

Verified: typecheck clean, 45 tests pass (4 new, covering the "Move containers" case
directly), production build green, the new PATCH route 401s when anonymous, and
/sites plus both invoice pages pass the mobile checklist at 375x812 and 768x1024. The
supplier edit was exercised end to end and the test value reverted.

Still open: the three external invoices (PHI 178/186/192) have no supplier name yet
and none can be derived from the data. They show "Set supplier" until the client
supplies them.

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.
2026-10-08 12:04:42 +02:00

329 lines
No EOL
16 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { ChevronRight, AlertTriangle, CheckCircle2, FileText } from "lucide-react";
import { SupplierCell, ReadOnlyName } from "@/components/SupplierCell";
import type { Asset, BookedLine, Invoice } from "@/lib/data";
import { BOOK_INCLUSIVE_VAT, BOOKED_LONG_LABEL } from "@/lib/invoice-booking";
import { ZAR, ZAR2 } from "@/lib/utils";
import { bookedAmount } from "@/lib/invoice-booking";
import { InvoiceSummary } from "@/components/InvoiceTable";
import { ScrollFade } from "@/components/ScrollFade";
// One row per invoice. Clicking a row expands the full line-item detail underneath it,
// so the common case (scan for problems) is a single table read and the detail is opt-in.
//
// Anything wrong with an invoice — an unbooked line, no document attached — is surfaced
// on its own row, because a problem buried inside a collapsed detail is a problem you miss.
export function InvoiceList({
invoices,
linesByInvoice,
assets,
partyScope = "internal",
}: {
invoices: Invoice[];
linesByInvoice: Record<string, BookedLine[]>;
assets: Asset[];
/** Which half of the register this is. It decides what the second column means: a SITE
* on the internal page, the SUPPLIER on the external one. The same `site` field backs
* both, but the external name is typed in by a human — see components/SupplierCell. */
partyScope?: "internal" | "external";
}) {
const [open, setOpen] = useState<string | null>(null);
// Names edited in place this session, keyed by invoice id. Held here so a supplier
// rename shows the moment it saves instead of waiting for the server round trip to
// replace this client tree.
const [names, setNames] = useState<Record<string, string>>({});
const nameFor = (inv: Invoice) => names[inv.id] ?? inv.site ?? "";
// The expanded detail lives in a <td> of THIS scrollable table, and it holds its own
// wide line-item table. Without a width of its own, that inner table's min-width
// propagates up and stretches the outer table to 1234px — so the whole list, sticky
// column included, scrolled sideways whenever a row was open, and the two tables'
// `sticky left-0` columns landed on top of each other.
//
// Pinning the detail to the scroller's measured width stops it influencing the outer
// table at all: the outer scrolls only its own 820px, and the inner table scrolls
// inside the detail with its own sticky column, which is now the only one in that
// scroller. z-20 keeps the detail above the outer sticky cell (z-10).
const scrollRef = useRef<HTMLDivElement>(null);
const [detailW, setDetailW] = useState<number | null>(null);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const measure = () => setDetailW(el.clientWidth);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const allLines = Object.values(linesByInvoice).flat();
const bookedLines = allLines.filter((l) => l.isBooked);
const sum = (ls: BookedLine[], k: "amount_excl" | "vat") => ls.reduce((s, l) => s + (l[k] || 0), 0);
const bookedExVat = sum(bookedLines, "amount_excl");
const bookedVat = sum(bookedLines, "vat");
const totals = invoices.reduce(
(acc, i) => ({
exVat: acc.exVat + (i.total_excl || 0),
vat: acc.vat + (i.vat || 0),
incl: acc.incl + (i.total_incl || 0),
}),
{ exVat: 0, vat: 0, incl: 0 },
);
return (
<>
{/* TWO tiles, not three, and a grid rather than flex-wrap. Both reasons are
the same lesson: "Booked" (summed over booked lines) and "Billed" (summed
over every invoice header) are the SAME figure whenever every line is
booked — which is the goal state — so two tiles showing one number read as
a duplicate rather than as a reconciliation. The reading that carries
information is "booked OF billed", so the single tile now says that, and
the two figures separate on their own the moment a line is left unbooked.
Equal columns, not content sizing: this tile is far wider than the invoice
count beside it because of the "of … billed" line and the VAT breakdown, and
flex would size by content and shove the second tile onto its own row well
above the 640px where a tablet has plenty of room. */}
<div className="mb-5 grid grid-cols-1 sm:grid-cols-2 gap-3 items-stretch">
<Stat label="Invoices" value={String(invoices.length)} tone="teal" />
{/* What actually got booked against the machines, per the shared policy,
set against what the debtors have actually been invoiced. */}
<div className="min-w-0 bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-4 sm:px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">{BOOKED_LONG_LABEL}</div>
<div className="text-lg sm:text-xl xl:text-2xl font-semibold tabular-nums">
{ZAR2(bookedAmount(bookedExVat, bookedVat))}
<span className="text-xs sm:text-sm font-normal text-white/75">
{" "}of {ZAR2(totals.incl)} billed
</span>
</div>
{/* VAT stays visible — the ex-VAT/incl-VAT split is the one figure a reader
cannot reconstruct from the headline above. */}
<div className="text-xs text-white/80 tabular-nums">
{BOOK_INCLUSIVE_VAT
? `${ZAR2(bookedExVat)} ex VAT + ${ZAR2(bookedVat)} VAT`
: `${ZAR2(bookedVat)} VAT not counted as income`}
</div>
</div>
</div>
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
{/* Rule 1 + 4: scrolls inside its own container; page never scrolls sideways. */}
<div ref={scrollRef} className="relative overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-sm sm:min-w-[820px]">
<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-3 sm:px-4 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">
Invoice
</th>
<th className="hidden sm:table-cell text-left py-3 px-3 font-medium whitespace-nowrap">
{partyScope === "external" ? "Supplier" : "Site"}
</th>
<th className="hidden sm:table-cell text-left py-3 px-3 font-medium whitespace-nowrap">Period</th>
<th className="hidden sm:table-cell text-right py-3 px-3 font-medium whitespace-nowrap">Lines</th>
<th className="hidden sm:table-cell text-right py-3 px-3 font-medium whitespace-nowrap">Revenue ex VAT</th>
<th className="hidden sm:table-cell 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</th>
<th className="text-left py-3 px-3 sm:px-4 font-medium whitespace-nowrap">Status</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => {
const lines = linesByInvoice[inv.id] ?? [];
const booked = lines.filter((l) => l.isBooked);
const unbooked = lines.filter((l) => !l.isBooked);
// Only an unbooked line is a problem. A machine used at two sites in a
// month is expected, so it must not tint the row or badge it as an error.
const hasProblem = unbooked.length > 0;
const isOpen = open === inv.id;
const sum = (ls: BookedLine[], k: "amount_excl" | "vat") =>
ls.reduce((s, l) => s + (l[k] || 0), 0);
const bookedExVat = sum(booked, "amount_excl");
const bookedVat = sum(booked, "vat");
return (
<InvoiceRow
key={inv.id}
invoice={inv}
name={nameFor(inv)}
partyScope={partyScope}
onNameSaved={(n) => setNames((m) => ({ ...m, [inv.id]: n }))}
lineCount={lines.length}
unbooked={unbooked.length}
hasProblem={hasProblem}
isOpen={isOpen}
onToggle={() => setOpen(isOpen ? null : inv.id)}
expanded={
isOpen ? (
<div
style={detailW ? { width: detailW } : undefined}
className="p-3 sm:p-4 bg-gray-50/60 dark:bg-white/[0.04]"
>
<InvoiceSummary invoice={inv} lines={lines} assets={assets} embedded />
</div>
) : null
}
detailW={detailW}
/>
);
})}
</tbody>
</table>
</div>
<ScrollFade />
</div>
</>
);
}
function InvoiceRow({
invoice, name, partyScope, onNameSaved, lineCount, unbooked,
hasProblem, isOpen, onToggle, expanded, detailW,
}: {
invoice: Invoice;
name: string;
partyScope: "internal" | "external";
onNameSaved: (name: string) => void;
lineCount: number;
unbooked: number;
hasProblem: boolean;
isOpen: boolean;
onToggle: () => void;
expanded: React.ReactNode;
detailW: number | null;
}) {
return (
<>
<tr
className={`border-b border-gray-50 dark:border-border transition ${
// SOLID, not an alpha tint. A sticky cell must be opaque or the columns it
// covers bleed straight through it as the table scrolls — the unbooked row
// showed the period and the site text printed over the invoice number.
hasProblem ? "bg-[#FDF3F4] dark:bg-[#292934]" : "hover:bg-gray-50/60 dark:hover:bg-white/[0.02]"
}`}
>
{/* Sticky first column so the invoice identity survives horizontal scroll.
It repeats the row's own background so the flag never washes out. */}
<td
className={`py-2 px-4 sticky left-0 z-10 ${
hasProblem ? "bg-[#FDF3F4] dark:bg-[#292934]" : "bg-white dark:bg-surface"
}`}
>
<button
type="button"
onClick={onToggle}
aria-expanded={isOpen}
className="flex items-center gap-2 min-h-[44px] w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[#30638E] rounded-lg"
>
<ChevronRight
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
<span className="font-mono text-xs font-semibold text-[#00798C] whitespace-nowrap">
{invoice.invoice_no}
</span>
</button>
</td>
<td className="hidden sm:table-cell py-2 px-3">
{partyScope === "external" ? (
// External: the name is a SUPPLIER and is not in the PDF, so it is typed in.
<SupplierCell invoiceId={invoice.id} value={name} onSaved={onNameSaved} />
) : (
// Internal: the name is a SITE transcribed from the invoice's own description.
<span className="block whitespace-nowrap">
<ReadOnlyName value={name} />
</span>
)}
</td>
<td className="hidden sm:table-cell py-2 px-3 text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap tabular-nums">
{invoice.period_from.slice(0, 10)} → {invoice.period_to.slice(0, 10)}
</td>
<td className="hidden sm:table-cell py-2 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">
{lineCount}
</td>
<td className="hidden sm:table-cell py-2 px-3 text-right tabular-nums font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(invoice.total_excl)}
</td>
<td className="hidden sm:table-cell py-2 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400 whitespace-nnowrap">
{ZAR2(invoice.vat)}
</td>
<td className="py-2 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(invoice.total_incl)}
</td>
<td className="py-2 px-3 sm:px-4 whitespace-nowrap">
{hasProblem ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
{unbooked} unbooked
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[#00798C]/10 text-[#00798C] text-xs font-semibold">
<CheckCircle2 className="w-3.5 h-3.5 shrink-0" />
All booked
</span>
)}
</td>
</tr>
{isOpen && (
<tr>
{/* Span the full table so the detail panel is not clipped by the sticky column.
The panel is then pinned to the scroller's own width: sticky so it holds
still while the list scrolls, and above the outer sticky cell so the two
never overlap. Without the fixed width the inner table re-stretches the
outer one and everything drifts right together. */}
<td colSpan={8} className="p-0 border-b border-gray-100 dark:border-border">
<div
className="sticky left-0 z-20"
style={detailW ? { width: detailW } : undefined}
>
{expanded}
</div>
</td>
</tr>
)}
</>
);
}
function Stat({ label, value, tone }: { label: string; value: string; tone: "teal" | "brand" }) {
if (tone === "brand") {
return (
<div className="min-w-0 bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-4 sm:px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">{label}</div>
<div className="text-lg sm:text-xl xl:text-2xl font-semibold tabular-nums">{value}</div>
</div>
);
}
return (
<div className="min-w-0 px-4 sm:px-5 py-3 rounded-2xl bg-[#00798C]/10 border border-[#00798C]/15">
<div className="text-xs text-[#00798C]">{label}</div>
<div className="text-lg sm:text-xl xl:text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{value}</div>
</div>
);
}
// 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">
{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>
);
}
export { ZAR };