The machine charts plotted the workbook's monthly_income.fixed_expenses, which
is NOT the app's running cost. So the charts and the /running Overview
disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by
R 121 115. The workbook's figure also carries depreciation/ownership and an
operator allocation, which are capital recovery and overhead rather than
running cost.
Every cost value now comes from the running-cost register: fixed = insurance
+ financing, running = purchases. Profit is income - fixed - running, and it
is ONE formula on every surface, so /charts, /income and the dashboard quote
the same figure for the same machine.
R 8 456 243 fleet income, Jan-Oct
- R 4 705 292 fixed (insurance + financing, from the register)
- R 306 387 running (purchases, from the register)
= R 3 444 564 profit (was R 2 510 908 on the workbook's fixed)
The legacy repairs sheet comes out completely. It was the old workbook's
"Plant Monthly Running Cost" — read by no page, maintained by nobody — and a
repair is bought on a purchase order, so its money is already in Purchases.
The collection is still seeded, so the history is not lost, but it is no
longer in any total, any chart or any profit.
Also:
- the profit badge wears the CHART's own green, and profit is green and
loss red on every surface. It was hard-coded to the teal brand colour,
so the label and the line it labelled were two different colours.
- September and October now state a profit instead of a dash: the dash
existed because the workbook had not posted those months' fixed costs,
and the register has the premium and the instalment for every month.
- the containers and the water trailers show profit equal to their income,
because the running-cost model carries no cost for them.
Overheads roll-up — an audit found three things out of order, and the roll-up
total itself reconciles exactly (R 580 485,37, unexplained R 0,00):
- the dashboard's Overhead mix legend named only five of the six sections,
so the figures it listed added up to R 565 489 against a headline of
R 580 485, and the donut drew a slice nobody could name.
- the /overheads note claimed machine instalments were excluded, while the
Bastion Finance block on the same page carried R 115 575,94 of them and
R 11 204,76 of insurance for the same machines. The note now says so,
with the figures read off the rows at render time.
- two Subscriptions rows had VAT that did not balance — the sheet had the
TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly
total. Both are corrected on the client's instruction; the roll-up total
does not move, only the VAT breakdown does, and the seed's VAT gate now
passes instead of exiting non-zero.
Verified: 161 tests pass (4 new ones pin the register join), the production
build is clean, the reconciliation script passes, and the charts, the profit
label and the overhead legend were checked on screen.
512 lines
26 KiB
TypeScript
512 lines
26 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-[var(--color-chart-profit)]/10 text-[var(--color-chart-profit)] dark:bg-[var(--color-chart-profit)]/20"
|
||
: "bg-[var(--color-chart-loss)]/10 text-[var(--color-chart-loss)] dark:bg-[var(--color-chart-loss)]/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-[var(--color-chart-profit)]/10 text-[var(--color-chart-profit)] dark:bg-[var(--color-chart-profit)]/20"
|
||
: "bg-[var(--color-chart-loss)]/10 text-[var(--color-chart-loss)] dark:bg-[var(--color-chart-loss)]/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-2 gap-4 mt-4 pt-3 border-t border-gray-100 dark:border-border text-xs md:grid-cols-4">
|
||
<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>
|
||
{/* Fixed = insurance + finance, from the running-cost register. */}
|
||
<div className="text-gray-400 uppercase tracking-wider text-xs">Fixed</div>
|
||
<div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
||
{ZAR(r.fixed)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
{/* Running = purchases. A repair is a purchase order, so it is here. */}
|
||
<div className="text-gray-400 uppercase tracking-wider text-xs">Running</div>
|
||
<div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
||
{ZAR(r.running)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
{/* Income − fixed − running. The same figure the charts and the
|
||
dashboard quote, so the card adds up to its own profit. */}
|
||
<div className="text-gray-400 uppercase tracking-wider text-xs">Profit</div>
|
||
<div className={`font-semibold tabular-nums ${positive ? "text-[var(--color-chart-profit)]" : "text-[var(--color-chart-loss)]"}`}>
|
||
{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>
|
||
);
|
||
}
|