mega-plant/components/IncomeView.tsx
David 299c20ae57 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.
2026-10-03 16:45:23 +02:00

329 lines
16 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
import { TrendingUp, TrendingDown, LayoutGrid, Sparkles, ChevronRight } from "lucide-react";
import { Sparkline } from "@/components/Sparkline";
import { MONTH_NAMES } from "@/lib/data";
import type { IncomeRow } from "@/lib/rows";
import { ZAR, ZAR2 } from "@/lib/utils";
import { ScrollFade } from "@/components/ScrollFade";
export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { income: string; profit: string; loss: string } }) {
const [view, setView] = useState<"cards" | "grid">("cards");
const [sort, setSort] = useState<"profit" | "income">("profit");
const [expanded, setExpanded] = useState<string | null>(null);
const sorted = useMemo(() => {
return [...rows].sort((a, b) =>
sort === "profit" ? b.ytdProfit - a.ytdProfit : b.income - a.income
);
}, [rows, sort]);
const totals = useMemo(() => {
const totalIncome = rows.reduce((s, r) => s + r.income, 0);
const totalProfit = rows.reduce((s, r) => s + r.ytdProfit, 0);
const profitable = rows.filter((r) => r.ytdProfit > 0).length;
// VAT already inside the booked figures — booked amounts are VAT-inclusive, so this
// is part of the income total and is called out rather than hidden.
const invoiceVat = rows.reduce((s, r) => s + r.invoiceVat, 0);
const invoiceIncome = rows.reduce((s, r) => s + r.invoiceIncome, 0);
return { totalIncome, totalProfit, profitable, invoiceVat, invoiceIncome };
}, [rows]);
return (
<>
{/* Round 4 Part B.1/B.3/B.5: toolbar wraps onto two rows on mobile, controls collapse gracefully */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3 mb-5">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
<span className="px-2.5 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-medium">
{totals.profitable} profitable
</span>
<span className="px-2.5 py-1 rounded-md bg-[#D1495B]/10 text-[#D1495B] font-medium">
{rows.length - totals.profitable} loss-making
</span>
{totals.invoiceVat > 0 && (
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49] font-medium">
includes {ZAR(totals.invoiceVat)} VAT
</span>
)}
<span className="hidden md:inline">· sorted by {sort}</span>
</div>
{/* Round 4 Part B.3: Sort collapses to a select on mobile. View toggle stays as buttons. */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-border">
<button
onClick={() => setView("cards")}
aria-label="Cards view"
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
view === "cards"
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
}`}
>
<Sparkles className="w-3.5 h-3.5" /> Cards
</button>
<button
onClick={() => setView("grid")}
aria-label="Grid view"
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
view === "grid"
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
}`}
>
<LayoutGrid className="w-3.5 h-3.5" /> Grid
</button>
</div>
<div className="hidden sm:flex items-center gap-1">
<span className="text-xs text-gray-400 dark:text-text-soft mr-1">Sort:</span>
{[
{ id: "profit", label: "Profit" },
{ id: "income", label: "Income" },
].map((s) => (
<button
key={s.id}
onClick={() => setSort(s.id as typeof sort)}
className={`min-h-[44px] px-2 text-xs font-medium rounded transition ${
sort === s.id ? "bg-[#EDAE49] text-white" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{s.label}
</button>
))}
</div>
{/* Part B.3: mobile sort dropdown */}
<div className="sm:hidden">
<label className="sr-only" htmlFor="income-sort">Sort by</label>
<select
id="income-sort"
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
className="min-h-[44px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
>
<option value="profit">Sort by Profit</option>
<option value="income">Sort by Income</option>
</select>
</div>
</div>
</div>
{view === "cards" ? (
<CardsView
rows={sorted}
expanded={expanded}
setExpanded={setExpanded}
palette={palette}
/>
) : (
<GridView rows={sorted} />
)}
</>
);
}
function CardsView({
rows, expanded, setExpanded, palette,
}: {
rows: IncomeRow[];
expanded: string | null;
setExpanded: (id: string | null) => void;
palette: { income: string; profit: string; loss: string };
}) {
return (
<div className="space-y-2">
{rows.map((r) => {
const isOpen = expanded === r.id;
const positive = r.ytdProfit >= 0;
return (
<div
key={r.id}
className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition"
>
<button
onClick={() => setExpanded(isOpen ? null : r.id)}
className="w-full text-left p-4"
>
{/* Row 1: code + name + sparkline + YTD + P/L pill + chevron */}
<div className="flex items-center gap-3 sm:gap-4 min-h-[44px]">
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
{r.code}
</span>
{/* Round 4 Part B.2: name gets its own line on mobile, never truncated to a single char */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white min-w-0">
{positive ? (
<TrendingUp className="w-3.5 h-3.5 text-[#00798C] shrink-0" />
) : (
<TrendingDown className="w-3.5 h-3.5 text-[#D1495B] shrink-0" />
)}
<span className="truncate">{r.name}</span>
</div>
</div>
<div className="hidden sm:block shrink-0">
<Sparkline data={r.months} color={palette.income} />
</div>
<div className="text-right shrink-0 w-32 hidden md:block">
<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>
<div
className={`shrink-0 hidden md:flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
positive
? "bg-[#00798C]/10 text-[#00798C] dark:bg-[#00798C]/20"
: "bg-[#D1495B]/10 text-[#D1495B] dark:bg-[#D1495B]/20"
}`}
>
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
</div>
<ChevronRight
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
</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">
{/* 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>
<div
className={`shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
positive
? "bg-[#00798C]/10 text-[#00798C] dark:bg-[#00798C]/20"
: "bg-[#D1495B]/10 text-[#D1495B] dark:bg-[#D1495B]/20"
}`}
>
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
</div>
</div>
</button>
{isOpen && (
<div className="border-t border-gray-100 dark:border-border px-4 py-4 bg-gray-50/50 dark:bg-white/[0.04]">
{/* 12-col on md+, 3-col on mobile — Part 3: unreadable at 375px otherwise */}
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-12 gap-2 text-xs">
{r.months.map((v, i) => (
<div key={i} className="text-center">
<div className="text-gray-400 dark:text-text-soft text-xs mb-1">{MONTH_NAMES[i]}</div>
<div
className={`py-1.5 rounded tabular-nums ${
(v ?? 0) > 0
? "bg-[#00798C]/5 text-[#00798C] dark:bg-[#00798C]/15"
: "text-gray-300 dark:text-text-soft"
}`}
>
{v == null ? "—" : (v > 0 ? ZAR2(v) : "R 0")}
</div>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-4 mt-4 pt-3 border-t border-gray-100 dark:border-border text-xs">
<div>
<div className="text-gray-400 uppercase tracking-wider text-xs">YTD income</div>
<div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR(r.income)}</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wider text-xs">Fixed + running</div>
<div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">
{ZAR(r.fixed + r.running)}
</div>
</div>
<div>
<div className="text-gray-400 uppercase tracking-wider text-xs">Profit</div>
<div className={`font-semibold tabular-nums ${positive ? "text-[#00798C]" : "text-[#D1495B]"}`}>
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
</div>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
}
function GridView({ rows }: { rows: IncomeRow[] }) {
return (
<div className="relative bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-sm min-w-[720px]">
<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-6 font-medium sticky left-0 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur z-10">Code</th>
<th className="text-left py-3 px-3 font-medium">Machine</th>
{MONTH_NAMES.map((m) => (
<th key={m} className="text-right py-3 px-2 font-medium min-w-[78px]">{m}</th>
))}
<th className="text-right py-3 px-6 font-medium">YTD</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const positive = r.ytdProfit >= 0;
return (
<tr key={r.id} className="border-b border-gray-50 dark:border-border hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
<td className="py-2.5 px-6 sticky left-0 bg-white dark:bg-surface">
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold">
{r.code}
</span>
</td>
<td className="py-2.5 px-3 font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
<div className="flex items-center gap-2">
{positive ? <TrendingUp className="w-3 h-3 text-[#00798C]" /> : <TrendingDown className="w-3 h-3 text-[#D1495B]" />}
{r.name}
</div>
</td>
{r.months.map((v, i) => (
<td key={i} className={`py-2.5 px-2 text-right tabular-nums ${(v ?? 0) > 0 ? "text-gray-700 dark:text-gray-300" : "text-gray-300 dark:text-text-soft"}`}>
{v == null ? "—" : (v > 0 ? ZAR2(v) : "R 0")}
</td>
))}
<td className={`py-2.5 px-6 text-right tabular-nums font-semibold whitespace-nowrap ${positive ? "text-[#00798C]" : "text-[#D1495B]"}`}>
<div className="inline-flex items-center gap-1.5">
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
</div>
</td>
</tr>
);
})}
</tbody>
{/* Totals row: the sum of every machine for each month. */}
<tfoot>
<tr className="border-t-2 border-gray-200 dark:border-white/15 bg-gray-50/80 dark:bg-white/[0.04]">
<td className="py-3 px-6 sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur text-sm font-semibold text-[#003D5B] dark:text-white">
Total
</td>
<td className="py-3 px-3 text-sm font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
All machines
</td>
{MONTH_NAMES.map((m, i) => {
const sum = rows.reduce((s, r) => s + (r.months[i] ?? 0), 0);
return (
<td
key={m}
className="py-3 px-2 text-right tabular-nums whitespace-nowrap text-gray-600 dark:text-gray-300"
>
{sum > 0 ? ZAR(sum) : "—"}
</td>
);
})}
<td className="py-3 px-6 text-right tabular-nums font-semibold whitespace-nowrap text-[#003D5B] dark:text-white">
{ZAR(rows.reduce((s, r) => s + r.income, 0))}
</td>
</tr>
</tfoot>
</table>
</div>
<ScrollFade />
</div>
);
}