On /income/invoices at tablet width the expanded invoice detail was unreadable and the booking control was effectively unreachable. - The line-item table is rendered inside a <td> of the invoice list's own scrollable table, so its 1200px width stretched the OUTER table to 1234px and the whole list scrolled sideways whenever a row was open. The detail is now measured with a ResizeObserver and given that pixel width, so it can no longer influence the outer table. - Both tables' first columns are sticky left-0, and two nested stickies at the same offset stack on top of one another: the invoice number, the site and the period printed over each other. The detail wrapper is now sticky left-0 z-20, above the outer cell's z-10, so the inner table scrolls in its own box. - The unbooked row tint was bg-[#D1495B]/[0.04] — 96% transparent — so every column a sticky cell covered bled through it on scroll. The tint is now the solid #FDF3F4 (dark #292934), repeated exactly on the sticky cells. - The KPI row was flex-wrap, which sizes tiles by CONTENT, so the wide "booked to income" tile pushed the third one onto a second row below ~644px. It is a grid-cols-3 now, with the figures sized to the column. - The machine picker and the site dropdown in the Booked to cell were flex children and compressed into each other by ~12px instead of sitting side by side. Both are shrink-0 now. - Booking a line always saved on selection with no Save button, which read as "nothing happened". Unmatched rows now say so. The booking API was already correct: a machine booking clears the site and posts income, a site booking posts none. Verified at 768x1024 and 375x812.
291 lines
No EOL
13 KiB
TypeScript
291 lines
No EOL
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, 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);
|
|
|
|
// 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 (
|
|
<>
|
|
{/* A grid, not flex-wrap. The middle tile is far wider than the other two
|
|
because of its ex-VAT/VAT sub-line, so flex sized them by CONTENT and pushed
|
|
the third one onto a second row the moment the viewport dipped under ~644px —
|
|
which is squarely tablet territory. Equal columns keep all three on one row
|
|
from 640px up, and below that they stack as one clean column. */}
|
|
<div className="mb-5 grid grid-cols-1 sm:grid-cols-3 gap-3 items-stretch">
|
|
<Stat label="Invoices" value={String(invoices.length)} tone="teal" />
|
|
{/* What actually got booked against the machines, per the shared policy. */}
|
|
<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]">{BOOKED_LONG_LABEL}</div>
|
|
<div className="text-lg sm:text-xl xl: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 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">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
|
|
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, lineCount, unbooked,
|
|
hasProblem, isOpen, onToggle, expanded, detailW,
|
|
}: {
|
|
invoice: Invoice;
|
|
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 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.
|
|
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.
|
|
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 }; |