mega-plant/components/InvoiceList.tsx
David 14ec899ca9 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 16:24:06 +02:00

245 lines
No EOL
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { ChevronRight, AlertTriangle, CheckCircle2, MapPin, FileText } from "lucide-react";
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,
}: {
invoices: Invoice[];
linesByInvoice: Record<string, BookedLine[]>;
assets: Asset[];
}) {
const [open, setOpen] = useState<string | null>(null);
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 (
<>
<div className="mb-5 flex flex-wrap items-stretch gap-3">
<Stat label="Invoices" value={String(invoices.length)} tone="teal" />
{/* What actually got booked against the machines, per the shared policy. */}
<div className="px-5 py-3 rounded-2xl bg-[#00798C]/10 border border-[#00798C]/15">
<div className="text-xs text-[#00798C]">{BOOKED_LONG_LABEL}</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR2(bookedAmount(bookedExVat, bookedVat))}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
{BOOK_INCLUSIVE_VAT
? `${ZAR2(bookedExVat)} ex VAT + ${ZAR2(bookedVat)} VAT`
: `${ZAR2(bookedVat)} VAT not counted as income`}
</div>
</div>
{/* What the debtors are actually invoiced. */}
<Stat label="Billed (incl VAT)" value={ZAR2(totals.incl)} tone="brand" />
</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 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">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}
lineCount={lines.length}
unbooked={unbooked.length}
hasProblem={hasProblem}
isOpen={isOpen}
onToggle={() => setOpen(isOpen ? null : inv.id)}
expanded={
isOpen ? (
<div 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
}
/>
);
})}
</tbody>
</table>
</div>
<ScrollFade />
</div>
</>
);
}
function InvoiceRow({
invoice, lineCount, unbooked,
hasProblem, isOpen, onToggle, expanded,
}: {
invoice: Invoice;
lineCount: number;
unbooked: number;
hasProblem: boolean;
isOpen: boolean;
onToggle: () => void;
expanded: React.ReactNode;
}) {
return (
<>
<tr
className={`border-b border-gray-50 dark:border-border transition ${
hasProblem ? "bg-[#D1495B]/[0.04] dark:bg-[#292934]" : "hover:bg-gray-50/60 dark:hover:bg-white/[0.02]"
}`}
>
{/* Sticky first column so the invoice identity survives horizontal scroll. */}
<td
className={`py-2 px-4 sticky left-0 z-10 ${
hasProblem ? "bg-[#D1495B]/[0.04] 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 whitespace-nowrap">
{invoice.site ? (
<span className="inline-flex items-center gap-1.5 text-sm text-[#003D5B] dark:text-white">
<MapPin className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
{invoice.site}
</span>
) : (
<span className="text-sm text-gray-400 dark:text-text-soft">—</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. */}
<td colSpan={8} className="p-0 border-b border-gray-100 dark:border-border">
{expanded}
</td>
</tr>
)}
</>
);
}
function Stat({ label, value, tone }: { label: string; value: string; tone: "teal" | "brand" }) {
if (tone === "brand") {
return (
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
<div className="text-xs text-white/80">{label}</div>
<div className="text-2xl font-semibold tabular-nums">{value}</div>
</div>
);
}
return (
<div className="px-5 py-3 rounded-2xl bg-[#00798C]/10 border border-[#00798C]/15">
<div className="text-xs text-[#00798C]">{label}</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{value}</div>
</div>
);
}
// Kept so an empty folder still renders a usable page.
export function EmptyInvoices() {
return (
<div className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-2xl shadow-sm p-10 text-center">
<FileText className="w-8 h-8 mx-auto text-gray-300 dark:text-text-soft mb-3" />
<p className="text-gray-500 dark:text-gray-400">No invoices yet.</p>
<p className="text-sm text-gray-400 dark:text-text-soft mt-1">
Add a <code className="font-mono text-[#00798C]">data/source/invoice-*.json</code> then run{" "}
<code className="font-mono text-[#00798C]">npm run seed</code>.
</p>
</div>
);
}
export { ZAR };