mega-plant/components/IncomeView.tsx
Dave 7b3ec2fbd8 feat(income): posted income per site, and the LIVE month on the overview
Two changes to the income overview, sharing one rule for what "the month"
means (lib/income-month.ts).

POSTED INCOME PER SITE

/income gains Vehicles / Sites views, and /sites defaults to the SAME
posted-income view with a separate Internal billed invoices view.
buildSiteIncomeRows (lib/site-income.ts) is the shared calculation: actual
monthly_income supplies the amount, year and month, and the linked invoice
supplies its historical site. It never uses assets.site, fuel-log
locations or today's map assignment.

- Unposted invoice lines are billing, not posted income.
- Historical workbook income with no site evidence stays in Unallocated
  historical income; external income, whose site field names the SUPPLIER,
  stays in Unallocated external income.
- Missing source references stay visible as unallocated invoice income.
  No group is dropped to make a site list look cleaner and no site is
  guessed to force a total.
- No site profit is calculated without an explicit cost-allocation rule.
- Invoice writes use revalidateInvoicePages, which invalidates both
  registers, Income, Sites/details, Charts and Dashboard.

THE LIVE MONTH

The tiles show the year-to-date income and profit AND the current month,
and every vehicle card shows the current month income beside its year
figure.

- The month is the live CALENDAR month and it is shown even at R0. Income
  for a month is usually entered near the end of it, so the current month
  is a figure that starts at R0 and grows.
- The month index is computed on the SERVER and passed down as a number.
  The browser never works it out for itself: two clocks disagreeing would
  show two different months on the same page.
- A month profit is a dash until that month costs are posted. September
  and October carry invoice income with no posted fixed costs (their costs
  exist only as forecast rows), so a profit there would be income minus
  zero - a 100% margin that does not tie back to the year-to-date profit.
  It becomes a number by itself the moment the workbook posts that month.
- The 12-month strip shows the live month as R0 rather than a dash, so a
  card cannot contradict itself. Months after it stay a dash.
- The sparkline on a collapsed card is hidden below xl: the content column
  is only ~700px wide between md and xl, and the vehicle name must not be
  truncated to pay for a decorative trend.
2026-11-05 00:19:06 +02:00

502 lines
25 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 { monthIncome, monthProfit } from "@/lib/income-month";
import { ZAR, ZAR2 } from "@/lib/utils";
import { ScrollFade } from "@/components/ScrollFade";
/**
* The text for one month cell.
*
* The LIVE month reads R0 rather than a dash: it is a month that is still filling up,
* not a month with nothing in it. Months AFTER it stay a dash — they have not happened
* yet, and a dash says exactly that. Months before it keep their own value, or a dash
* when nothing was posted.
*/
function monthCellText(value: number | null, index: number, monthIndex: number): string {
if (value != null) return value > 0 ? ZAR2(value) : "R 0";
return index === monthIndex ? "R 0" : "—";
}
export function IncomeView({
rows,
palette,
title,
monthIndex,
}: {
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;
/** Which month the page calls "the month" — the LIVE calendar month while the page's
* year is the current year (see lib/income-month.ts). Computed on the server so the
* server and the browser can never disagree about it. */
monthIndex: number;
}) {
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);
// The LIVE month. It follows the same filter as the year figures above, so the
// headline and the list below it can never disagree. It starts at R0 and grows as
// income is entered — which is the point of showing it (lib/income-month.ts).
const month = monthIncome(visible, monthIndex);
// `null` when that month's costs are not on file yet. A dash, not a fake profit.
const monthProfitValue = monthProfit(visible, monthIndex);
return { totalIncome, totalProfit, profitable, invoiceVat, invoiceIncome, month, monthProfit: monthProfitValue };
}, [visible, monthIndex]);
// The month name, or null when there is no month to show at all.
const monthLabel = monthIndex >= 0 ? MONTH_NAMES[monthIndex] : null;
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>
{/* The live month, under the year figure. It is shown even when it is R0:
the month is still filling up, and R0 is a real statement about it. */}
{monthLabel && (
<div className="mt-1 text-xs text-white/80">
{monthLabel} so far{" "}
<span className="font-semibold text-white tabular-nums">{ZAR(totals.month)}</span>
</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>
{/* The month's profit is only stated once that month's costs are posted.
Until then it is a dash, and the reason is given — a reader must not
mistake "no profit yet" for "no costs yet". */}
{monthLabel && (
<div className="mt-1 text-xs text-white/80">
{monthLabel} profit{" "}
{totals.monthProfit == null ? (
"— costs not posted yet"
) : (
<>
· <span className="font-semibold text-white tabular-nums">{ZAR(totals.monthProfit)}</span>
</>
)}
</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 lg: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="hidden lg: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="lg:hidden">
<label className="sr-only" htmlFor="income-status">Vehicle status</label>
<select id="income-status" value={status} onChange={(e) => setStatus(e.target.value as typeof status)} className="min-h-[44px] min-w-[44px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-surface">
<option value="active">Active ({activeN})</option>
<option value="all">All ({rows.length})</option>
<option value="inactive">Inactive ({inactiveN})</option>
</select>
</div>
<div className="lg: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}
monthIndex={monthIndex}
/>
) : (
<GridView rows={sorted} monthIndex={monthIndex} />
)}
</>
);
}
function CardsView({
rows, expanded, setExpanded, palette, monthIndex,
}: {
rows: IncomeRow[];
expanded: string | null;
setExpanded: (id: string | null) => void;
palette: { income: string; profit: string; loss: string };
monthIndex: number;
}) {
const monthLabel = monthIndex >= 0 ? MONTH_NAMES[monthIndex] : null;
return (
<div className="space-y-2">
{rows.map((r) => {
const isOpen = expanded === r.id;
const positive = r.ytdProfit >= 0;
const monthValue = monthIncome([r], monthIndex);
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>
{/* The sparkline yields its width to the month figure below xl: the
content column is only ~700px wide between md and xl (the sidebar
eats the rest), and the vehicle NAME must not be truncated to pay
for a decorative trend. The same 12 months are on the strip in the
expanded panel, which is one click away. */}
<div className="hidden xl:block shrink-0">
<Sparkline data={r.months} color={palette.income} />
</div>
{/* The money: the year figure and the live month, side by side. */}
<div className="hidden md:flex shrink-0 items-start gap-4">
<div className="text-right w-28">
<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>
{monthLabel && (
<div className="text-right w-28">
<div className="text-xs text-gray-400 dark:text-text-soft uppercase tracking-wider whitespace-nowrap">{monthLabel} so far</div>
<div className="text-sm font-semibold tabular-nums text-[#00798C] dark:text-[#EDAE49]">
{ZAR2(monthValue)}
</div>
</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 (below md): sparkline + YTD + P/L pill. It shows on tablets too,
not just phones — between sm and md the figures used to disappear
entirely, because row 1's money block only appears from md. */}
<div className="flex md: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>
{/* Row 3 (below md): the live month. It has no room beside the other
figures on a phone, so it gets its own line rather than being
squeezed until the label wraps. */}
{monthLabel && (
<div className="flex md:hidden items-center justify-between gap-3 pt-2 text-xs">
<span className="text-gray-400 dark:text-text-soft uppercase tracking-wider">{monthLabel} so far</span>
<span className="font-semibold tabular-nums text-[#00798C] dark:text-[#EDAE49]">{ZAR2(monthValue)}</span>
</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-xs mb-1 ${i === monthIndex ? "text-[#00798C] dark:text-[#EDAE49] font-semibold" : "text-gray-400 dark:text-text-soft"}`}>{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"
}`}
>
{monthCellText(v, i, monthIndex)}
</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, monthIndex }: { rows: IncomeRow[]; monthIndex: number }) {
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, i) => (
<th key={m} className={`text-right py-3 px-2 font-medium min-w-[78px] ${i === monthIndex ? "text-[#00798C] dark:text-[#EDAE49]" : ""}`}>{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"}`}>
{monthCellText(v, i, monthIndex)}
</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) : i === monthIndex ? "R 0" : "—"}
</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>
);
}