mega-plant/components/InvoiceList.tsx
David dc7024d98a Fix: the mobile table was mislabelled and clipping its own totals
A screenshot at phone width showed an "INVOICE | LINES | TOTAL | STATUS"
header row while the data under LINES was money. The header and its data cell
had drifted apart: the earlier pass hid the Lines <td> but not the matching
<th>, because the loop that added the hide classes only matched text-left
headers and Lines is text-right. So the Total values rendered under a heading
that named a column which no longer existed.

The same screenshot showed the totals clipped — "41,710.50" reading as
"710,50". With only three columns left, the 520px min-width was still forcing
a sideways scroll, and the opaque sticky first column then sat on top of the
left of the money values. The mobile floor is gone, so the three remaining
columns size to their content, and the sticky and status gutters are trimmed
at mobile widths to let them fit 375px.

Below sm the table is now exactly Invoice, Total, Status — and every header
matches a visible cell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 15:34:19 +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]" : "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-[#1a1319]" : "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 };