Invoices: fix the tablet layout and make booking an unbooked line obvious

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.
This commit is contained in:
David 2026-10-03 09:59:05 +02:00
parent 123484a8c4
commit 8423878b4f
4 changed files with 109 additions and 22 deletions

View file

@ -101,6 +101,32 @@ Other rules worth not re-learning:
The site detail link lives **inside the expanded panel only** — a card is a
`<button>`, so a nested `<a>` would be invalid HTML.
## Nested wide tables — the sticky-column trap
`/income/invoices` expands an invoice row into a line-item table **inside a `<td>` of the
invoice list's own scrollable table**. Two things break if this is not pinned down, and
both look like a rendering glitch rather than a layout bug:
- The inner table's `min-width` propagates up and **stretches the outer table** (820px
became 1234px). The whole list then scrolled sideways whenever a row was open. The
detail is now measured with a `ResizeObserver` and given that pixel width, so it
cannot influence the outer table.
- Both tables' first columns are `sticky left-0`, and **two nested stickies at the same
offset stack on top of each other** — the invoice number, the site and the period all
printed over one another. The detail wrapper is `sticky left-0 z-20` (above the outer
cell's `z-10`) and holds a fixed width, so the inner table scrolls inside its own box
and is the only sticky in it.
**A sticky cell must have an OPAQUE background.** The unbooked rows were tinted
`bg-[#D1495B]/[0.04]` — 96% transparent — so every column they covered bled through them
on scroll. The row tint is now the solid `#FDF3F4` (dark: `#292934`) and the sticky
cells repeat it exactly.
Two more traps on this screen: a `flex-wrap` KPI row 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. And the two pickers in the **Booked to** cell are `shrink-0`; as flex
children they compressed into each other by ~12px instead of sitting side by side.
## Money on invoices — VAT is NOT revenue
On `PHI 166` the totals block reads `SUB TOTAL 161,367.50 / Escalation 0.00 / PLUS VAT

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
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";
@ -25,6 +25,29 @@ export function InvoiceList({
}) {
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);
@ -42,12 +65,17 @@ export function InvoiceList({
return (
<>
<div className="mb-5 flex flex-wrap items-stretch gap-3">
{/* 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="px-5 py-3 rounded-2xl bg-[#00798C]/10 border border-[#00798C]/15">
<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-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
<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">
@ -62,7 +90,7 @@ export function InvoiceList({
<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" }}>
<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]">
@ -103,11 +131,15 @@ export function InvoiceList({
onToggle={() => setOpen(isOpen ? null : inv.id)}
expanded={
isOpen ? (
<div className="p-3 sm:p-4 bg-gray-50/60 dark:bg-white/[0.04]">
<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}
/>
);
})}
@ -122,7 +154,7 @@ export function InvoiceList({
function InvoiceRow({
invoice, lineCount, unbooked,
hasProblem, isOpen, onToggle, expanded,
hasProblem, isOpen, onToggle, expanded, detailW,
}: {
invoice: Invoice;
lineCount: number;
@ -131,18 +163,23 @@ function InvoiceRow({
isOpen: boolean;
onToggle: () => void;
expanded: React.ReactNode;
detailW: number | null;
}) {
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]"
// 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. */}
{/* 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-[#D1495B]/[0.04] dark:bg-[#292934]" : "bg-white dark:bg-surface"
hasProblem ? "bg-[#FDF3F4] dark:bg-[#292934]" : "bg-white dark:bg-surface"
}`}
>
<button
@ -201,9 +238,18 @@ function InvoiceRow({
{isOpen && (
<tr>
{/* Span the full table so the detail panel is not clipped by the sticky column. */}
{/* 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">
{expanded}
<div
className="sticky left-0 z-20"
style={detailW ? { width: detailW } : undefined}
>
{expanded}
</div>
</td>
</tr>
)}
@ -214,16 +260,16 @@ function InvoiceRow({
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="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-2xl font-semibold tabular-nums">{value}</div>
<div className="text-lg sm:text-xl xl: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="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-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{value}</div>
<div className="text-lg sm:text-xl xl:text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{value}</div>
</div>
);
}

View file

@ -254,7 +254,9 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
<tr
key={l.id}
className={`border-b border-gray-50 dark:border-border ${
l.isBooked ? "hover:bg-gray-50/40 dark:hover:bg-white/[0.02]" : "bg-[#D1495B]/[0.04]"
// SOLID, not an alpha tint — see the note in InvoiceRow. A translucent
// sticky cell lets the columns it covers show through it on scroll.
l.isBooked ? "hover:bg-gray-50/40 dark:hover:bg-white/[0.02]" : "bg-[#FDF3F4] dark:bg-[#292934]"
}`}
>
{/* Sticky first column — keeps the machine identifiable while scrolling.
@ -262,9 +264,7 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
unbooked tint; otherwise the flag disappears under the white. */}
<td
className={`py-3 px-5 sticky left-0 z-10 ${
l.isBooked
? "bg-white dark:bg-surface"
: "bg-[#D1495B]/[0.04] dark:bg-[#292934]"
l.isBooked ? "bg-white dark:bg-surface" : "bg-[#FDF3F4] dark:bg-[#292934]"
}`}
>
<div className="flex items-center gap-2 whitespace-nowrap">
@ -282,8 +282,12 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
{ZAR2(l.total_incl)}
</td>
<td className="py-3 px-3">
{/* shrink-0 on both: as flex children they were free to compress, and
the site select ended up overlapping the machine picker by ~12px
instead of sitting beside it. They may not overlap, so neither may
shrink — the cell scrolls with the rest of the table if it must. */}
<div className="flex flex-col sm:flex-row gap-2 sm:items-start">
<div className="min-w-[180px]">
<div className="min-w-[200px] shrink-0">
<MachineCombobox
lineId={l.id}
value={l.booked_asset || ""}
@ -296,6 +300,15 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
<SiteBookingSelect lineId={l.id} value={l.booked_site || ""} />
)}
</div>
{/* Both pickers save the moment you choose — there is no Save button to
miss, which is exactly why an unmatched row read as "nothing happens".
Say so on the row rather than leaving it to be discovered. */}
{!l.isBooked && (
<p className="mt-1.5 text-xs text-[#D1495B]">
Pick a machine above to save it. No match in the register? Book it to the
site instead — it posts no income, because no machine carries it.
</p>
)}
</td>
</tr>
))}

View file

@ -75,7 +75,9 @@ export function SiteBookingSelect({
const currentName = options.find((o) => o.id === current)?.name;
return (
<div className="min-w-[200px]">
// shrink-0: this sits beside the machine picker in a flex row and used to be
// compressed under it, leaving the two controls overlapping mid-glyph.
<div className="min-w-[200px] shrink-0">
<label className="sr-only" htmlFor={`site-${lineId}`}>
Book this line to a site
</label>