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.
502 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|