Three separate layout faults in the header rows, all reported from a phone.
/income scrolled sideways by 158px. Three causes stacked, none of them responsive:
the wrapper was p-8 (32px each side at 375px), the header row was
`flex items-end justify-between` with no breakpoint, and the KPI row was
`flex items-center gap-3` with no wrap, so the two tiles physically could not
stack. Measured: main sat at x=36 w=339 at 375px, and the YTD Profit tile began
at x=355 in a 375px viewport — about 20px of its 178px was on screen.
- Wrapper is p-4 sm:p-8, and the header row stacks until lg, not sm. This one
matters: at 768px a side-by-side row leaves the tile grid only 317px of the
704px available (the title block takes 371px), so two columns of 152px are too
narrow for text-2xl money and the figures spilled out over their own tiles.
Note the DOM check said pageHScroll:false at 768 — the page did not report
overflow even though the text was visibly broken, and only the screenshot
caught it. The tiles may not go 2-up until the title is above them.
- The KPI row is grid grid-cols-1 sm:grid-cols-2, so the two tiles fill the row
instead of being sized by their content.
The machine card's P/L pill hung 32px outside its card (pill ended at x=374, the
card at x=342), jammed against the viewport edge. IncomeView's mobile row 2 is
sparkline + YTD + pill, and both ends are shrink-0 with nothing allowed to give:
Sparkline's root is a fixed 120px box and the pill is shrink-0. /sites already
passed width={72} for exactly this reason (see the note in Sparkline.tsx);
/income never did. It does now, with min-w-0 on the YTD block. The pill now sits
16px inside the card.
/sites was not broken — it never overflowed — it just looked wrong. `flex
flex-wrap items-stretch` wraps, so nothing broke, but flex-wrap sizes tiles by
CONTENT: "Revenue (ex VAT)" measured 197px inside a 343px row and the second
tile wrapped to a line narrower still, both left-aligned, leaving a ragged edge
and dead space. That is the same anti-pattern AGENTS.md already records as fixed
on /income/invoices, never carried over here. It is a grid now.
/income/invoices showed "Booked to income" and "Billed (incl VAT)" as two tiles
with the same figure, which read as a duplicate. Not a bug: one sums booked
lines, the other sums every invoice header, and all 10 invoices are currently
fully booked (0 unbooked, problem inbox empty), so they are legitimately equal.
They separate the moment a line is left unbooked — which is the whole point of
the screen. Merged into one tile that says what the pair actually means,
"R 780 003,61 of R 780 003,61 billed", with the VAT breakdown kept underneath
because AGENTS.md requires VAT to stay visible. The gradient moved onto the
merged tile so the money figure keeps its emphasis.
Verified in the browser at 375, 768 and 1280: no page overflow, no element past
the viewport edge outside the table's own scroller, nothing clipped inside the
merged tile, and both desktop layouts unchanged. Typecheck and build green.
302 lines
No EOL
14 KiB
TypeScript
302 lines
No EOL
14 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 (
|
|
<>
|
|
{/* 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">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 }; |