def147bmoved 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 whatdef147bwas after. /sites uses the same header pattern and was always correct: its tiles are a plain div, not a fragment-returning component.
412 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|