mega-plant/components/IncomeView.tsx
David afa95a9389 Income: the tiles and the machine list were sharing the title's flex row
def147b moved the YTD tiles into IncomeView so they would follow the
active/inactive filter, and in doing so moved the whole component into the
page's `lg:flex-row` header. IncomeView returns a FRAGMENT, so its three
sections — the toolbar, the tiles and the entire machine list — became flex
siblings of the title and competed for one line.

Measured at 1440px before the fix: the tile grid was 124px wide, two 56px
columns holding text-2xl money that needs 138px. The "YTD Income" label was
smeared across the full 1440px viewport, and the machine list was pushed right
and clipped off the edge. After: the tile grid is 369px, the money fits, and
the last card ends inside the viewport.

It hid from the mobile checklist because the header only becomes a row at lg —
below that it is a column and stacks correctly, so 375 and 768 both passed
while every desktop width was broken.

The heading now renders inside IncomeView, in the same row as the tiles. The
tiles stay in the client component, so they still follow the filter, which is
what def147b was after. /sites uses the same header pattern and was always
correct: its tiles are a plain div, not a fragment-returning component.
2026-10-06 11:05:37 +02:00

412 lines
20 KiB
TypeScript

"use client";
import { useState, useMemo, useEffect } 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,
title,
}: {
rows: IncomeRow[];
palette: { income: string; profit: string; loss: string };
/** The page heading. Rendered here rather than by the page because the YTD tiles have
* to sit beside it, and the tiles can only follow the status filter from in here. */
title: React.ReactNode;
}) {
const [view, setView] = useState<"cards" | "grid">("cards");
const [sort, setSort] = useState<"profit" | "income">("profit");
const [expanded, setExpanded] = useState<string | null>(null);
// Machines the client has stood down show nothing but a flat row of zeros — they are
// out of the fleet, not idle. Hidden by default, reachable behind the filter.
const [status, setStatus] = useState<"active" | "all" | "inactive">("active");
const visible = useMemo(
() =>
rows.filter((r) =>
status === "all" ? true : status === "active" ? r.isActive : !r.isActive,
),
[rows, status],
);
useEffect(() => {
// A machine hidden by the filter must not stay stuck expanded.
if (expanded && !visible.some((r) => r.id === expanded)) setExpanded(null);
}, [visible, expanded]);
const activeN = useMemo(() => rows.filter((r) => r.isActive).length, [rows]);
const inactiveN = rows.length - activeN;
const sorted = useMemo(() => {
return [...visible].sort((a, b) =>
sort === "profit" ? b.ytdProfit - a.ytdProfit : b.income - a.income
);
}, [visible, sort]);
const totals = useMemo(() => {
// Summed over what is VISIBLE, so the headline never disagrees with the list below it.
const totalIncome = visible.reduce((s, r) => s + r.income, 0);
const totalProfit = visible.reduce((s, r) => s + r.ytdProfit, 0);
const profitable = visible.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 = visible.reduce((s, r) => s + r.invoiceVat, 0);
const invoiceIncome = visible.reduce((s, r) => s + r.invoiceIncome, 0);
return { totalIncome, totalProfit, profitable, invoiceVat, invoiceIncome };
}, [visible]);
return (
<>
{/* The heading row lives HERE, not in the page. This component returns a fragment,
so when the page put it inside its own `lg:flex-row` header, the toolbar, these
tiles and the whole machine list all became flex SIBLINGS of the title and shared
one line: the tiles were squeezed to ~150px columns whose text-2xl money spilled
over itself, and the list was pushed off the side. Stacking until lg is what
hides it — below lg this row is a column, so the mobile pass never saw it. */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
{title}
{/* 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(totals.totalIncome)}</div>
</div>
<div
className={`bg-gradient-to-br ${
totals.totalProfit >= 0 ? "from-[#EDAE49] to-[#D1495B]" : "from-[#D1495B] to-[#003D5B]"
} rounded-2xl px-5 py-3 text-white shadow-lg`}
>
<div className="text-xs text-white/80">YTD Profit</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(totals.totalProfit)}</div>
</div>
</div>
</div>
{/* 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">
{visible.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>
{/* Status filter. Stands down machines are hidden by default; this is the only
way to see them, so the count is stated rather than leaving a silent gap. */}
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-white/5">
{([
{ id: "active", label: "Active", n: activeN },
{ id: "all", label: "All", n: rows.length },
{ id: "inactive", label: "Inactive", n: inactiveN },
] as const).map((o) => (
<button
key={o.id}
onClick={() => setStatus(o.id)}
aria-pressed={status === o.id}
className={`min-h-[44px] px-3 rounded-md text-xs font-medium transition ${
status === o.id
? o.id === "inactive"
? "bg-[#D1495B] text-white"
: "bg-[#003D5B] text-white dark:bg-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{o.label} <span className="opacity-60">({o.n})</span>
</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>
);
}