"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(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. */}
{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. */}
YTD Income
{ZAR(totals.totalIncome)}
= 0 ? "from-[#EDAE49] to-[#D1495B]" : "from-[#D1495B] to-[#003D5B]" } rounded-2xl px-5 py-3 text-white shadow-lg`} >
YTD Profit
{ZAR(totals.totalProfit)}
{/* Round 4 Part B.1/B.3/B.5: toolbar wraps onto two rows on mobile, controls collapse gracefully */}
{totals.profitable} profitable {visible.length - totals.profitable} loss-making {totals.invoiceVat > 0 && ( includes {ZAR(totals.invoiceVat)} VAT )} · sorted by {sort}
{/* Round 4 Part B.3: Sort collapses to a select on mobile. View toggle stays as buttons. */}
Sort: {[ { id: "profit", label: "Profit" }, { id: "income", label: "Income" }, ].map((s) => ( ))}
{/* 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. */}
{([ { id: "active", label: "Active", n: activeN }, { id: "all", label: "All", n: rows.length }, { id: "inactive", label: "Inactive", n: inactiveN }, ] as const).map((o) => ( ))}
{/* Part B.3: mobile sort dropdown */}
{view === "cards" ? ( ) : ( )} ); } function CardsView({ rows, expanded, setExpanded, palette, }: { rows: IncomeRow[]; expanded: string | null; setExpanded: (id: string | null) => void; palette: { income: string; profit: string; loss: string }; }) { return (
{rows.map((r) => { const isOpen = expanded === r.id; const positive = r.ytdProfit >= 0; return (
{isOpen && (
{/* 12-col on md+, 3-col on mobile — Part 3: unreadable at 375px otherwise */}
{r.months.map((v, i) => (
{MONTH_NAMES[i]}
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")}
))}
YTD income
{ZAR(r.income)}
Fixed + running
{ZAR(r.fixed + r.running)}
Profit
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
)}
); })}
); } function GridView({ rows }: { rows: IncomeRow[] }) { return (
{MONTH_NAMES.map((m) => ( ))} {rows.map((r) => { const positive = r.ytdProfit >= 0; return ( {r.months.map((v, i) => ( ))} ); })} {/* Totals row: the sum of every machine for each month. */} {MONTH_NAMES.map((m, i) => { const sum = rows.reduce((s, r) => s + (r.months[i] ?? 0), 0); return ( ); })}
Code Machine{m}YTD
{r.code}
{positive ? : } {r.name}
0 ? "text-gray-700 dark:text-gray-300" : "text-gray-300 dark:text-text-soft"}`}> {v == null ? "—" : (v > 0 ? ZAR2(v) : "R 0")}
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
Total All machines {sum > 0 ? ZAR(sum) : "—"} {ZAR(rows.reduce((s, r) => s + r.income, 0))}
); }