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:
parent
123484a8c4
commit
8423878b4f
4 changed files with 109 additions and 22 deletions
26
AGENTS.md
26
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue