Mobile: the top tiles overflowed on /income, hugged text on /sites, and repeated on invoices
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.
This commit is contained in:
parent
aa1e75e399
commit
299c20ae57
4 changed files with 52 additions and 20 deletions
|
|
@ -25,8 +25,13 @@ export default async function IncomePage() {
|
|||
const totalProfit = rows.reduce((s, m) => s + m.ytdProfit, 0);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-[1400px]">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
{/* Stacks until lg, not sm. As a side-by-side row the title block took 371px
|
||||
of the 704px available at 768px, leaving the tile grid 317px — two 152px
|
||||
columns, too narrow for text-2xl money, so the figures spilled over their
|
||||
own tiles. At 375px the same row could not even fit, and the page scrolled
|
||||
sideways by 158px. The tiles may only go 2-up once the title is above them. */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#00798C]/15 to-[#003D5B]/15 grid place-items-center text-[#00798C]">
|
||||
|
|
@ -36,7 +41,11 @@ export default async function IncomePage() {
|
|||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">Project income per machine per month · 2026</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* grid, not flex-wrap: a wrapping flex row sizes tiles by CONTENT, which
|
||||
is the ragged-left-edge trap AGENTS.md records for /income/invoices.
|
||||
Full-width stacked on mobile, two-up from sm — same shape the dashboard
|
||||
cards already use there. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<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">YTD Income</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{ZAR(totalIncome)}</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ export default async function SitesPage() {
|
|||
|
||||
return (
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
||||
{/* Stacks until lg, not sm: at 768px a side-by-side row leaves the tile grid
|
||||
only 317px (the title block takes 371px of 704px), so two columns of 152px
|
||||
are too narrow for text-2xl money and the figures spill over their tiles. */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#EDAE49]/20 to-[#00798C]/15 grid place-items-center text-[#00798C]">
|
||||
|
|
@ -41,7 +44,11 @@ export default async function SitesPage() {
|
|||
{invoices.length === 1 ? "" : "s"} · {year}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-stretch gap-3">
|
||||
{/* grid, not `flex flex-wrap items-stretch`: flex-wrap sizes tiles by
|
||||
CONTENT, so these two hugged their text — 197px inside a 343px row —
|
||||
leaving a ragged left-aligned pair with dead space beside each. Grid
|
||||
items stretch by default, which is what items-stretch was doing here. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Tile label="Revenue (ex VAT)" value={ZAR2(totalExcl)} sub={`+ ${ZAR2(totalVat)} VAT`} />
|
||||
<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">Billed (incl VAT)</div>
|
||||
|
|
|
|||
|
|
@ -183,8 +183,13 @@ function CardsView({
|
|||
</div>
|
||||
{/* Row 2 (mobile only): sparkline + YTD + P/L pill */}
|
||||
<div className="flex sm:hidden items-center justify-between gap-3 pt-2 text-xs">
|
||||
<Sparkline data={r.months} color={palette.income} />
|
||||
<div className="text-right">
|
||||
{/* 72/28, not the 120/36 default: Sparkline's root is a fixed-width
|
||||
shrink-0 box, and the pill beside it is shrink-0 too, so with
|
||||
nothing allowed to give the pill was pushed ~32px outside the
|
||||
card and sat on the viewport edge. /sites already passes 72 here
|
||||
for exactly this reason. */}
|
||||
<Sparkline data={r.months} color={palette.income} width={72} height={28} />
|
||||
<div className="text-right min-w-0">
|
||||
<div className="text-xs text-gray-400 dark:text-text-soft uppercase tracking-wider">YTD income</div>
|
||||
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.income)}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,27 +65,38 @@ export function InvoiceList({
|
|||
|
||||
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">
|
||||
{/* 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. */}
|
||||
<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">
|
||||
{/* 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>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
{/* 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>
|
||||
{/* 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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue