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.
425 lines
20 KiB
TypeScript
425 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo, useRef, useEffect } from "react";
|
||
import { ScrollFade } from "@/components/ScrollFade";
|
||
import { Wrench, LayoutGrid, Sparkles, ChevronRight } from "lucide-react";
|
||
import { Sparkline } from "@/components/Sparkline";
|
||
import { MONTH_NAMES } from "@/lib/data";
|
||
import { COST_KINDS, COST_KIND_LABELS } from "@/lib/running-costs";
|
||
import type { RunningRow } from "@/lib/rows";
|
||
import { ZAR, ZAR2 } from "@/lib/utils";
|
||
|
||
/** Month total split, for the cell tooltip. Rounded — raw float sums read as
|
||
* "41944.159999999996", which looks like a bug even though it is arithmetic. */
|
||
function kindTitle(byKind: { purchases: number[]; insurance: number[]; finance: number[] }, i: number) {
|
||
const r2 = (n: number) => n.toFixed(2).replace(/\.00$/, "");
|
||
return (
|
||
`Purchases R${r2(byKind.purchases[i])}` +
|
||
` · Insurance R${r2(byKind.insurance[i])} · Finance R${r2(byKind.finance[i])}`
|
||
);
|
||
}
|
||
|
||
/** The three cost kinds. These ARE a vehicle's running cost — there is no fourth source —
|
||
* so the parts always add up to the vehicle's spend and the vehicles add up to the
|
||
* Overview. Showing them is what makes that checkable rather than a claim.
|
||
* ⚠️ No "repairs" kind: a repair is bought on a purchase order and lands under
|
||
* Purchases (client ruling, 2026-11-05). */
|
||
const KINDS = COST_KINDS;
|
||
|
||
export function RunningView({
|
||
rows,
|
||
palette,
|
||
monthTotals,
|
||
monthTotalsExcl,
|
||
byKind,
|
||
visibleMonths,
|
||
monthIndex,
|
||
}: {
|
||
rows: RunningRow[];
|
||
palette: { income: string; profit: string; loss: string };
|
||
/** Per month, VAT-INCLUSIVE, all cost kinds combined. */
|
||
monthTotals: number[];
|
||
/** The same months net of VAT, shown UNDER each incl figure. */
|
||
monthTotalsExcl?: number[];
|
||
/** Per-month split behind the total, offered on hover rather than as columns. */
|
||
byKind?: { purchases: number[]; insurance: number[]; finance: number[] };
|
||
/**
|
||
* How many months of the year to RENDER. A month ahead cannot be calculated, only
|
||
* guessed at, so the columns for it are not shown at all rather than shown empty
|
||
* (client ruling, 2026-10-09).
|
||
*/
|
||
visibleMonths?: number;
|
||
/**
|
||
* The LIVE month, 0-based, decided on the server (lib/income-month.ts). It is the
|
||
* month that is still filling up, so its figure grows as purchase orders are
|
||
* entered against a vehicle.
|
||
*/
|
||
monthIndex?: number;
|
||
}) {
|
||
const [view, setView] = useState<"cards" | "grid">("cards");
|
||
const [sort, setSort] = useState<"ytd" | "code">("ytd");
|
||
const [expanded, setExpanded] = useState<string | null>(null);
|
||
|
||
const sorted = useMemo(() => {
|
||
return [...rows].sort((a, b) =>
|
||
sort === "ytd" ? b.ytd - a.ytd : a.code.localeCompare(b.code)
|
||
);
|
||
}, [rows, sort]);
|
||
|
||
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
|
||
|
||
return (
|
||
<>
|
||
<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-[#EDAE49]/10 text-[#EDAE49] font-medium">
|
||
{rows.filter((r) => r.ytd > 0).length} machines with costs
|
||
</span>
|
||
<span className="hidden md:inline">· sorted by {sort === "ytd" ? "total spend" : "code"}</span>
|
||
</div>
|
||
<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: "ytd", label: "Total" },
|
||
{ id: "code", label: "Code" },
|
||
].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>
|
||
<div className="sm:hidden">
|
||
<label className="sr-only" htmlFor="running-sort">Sort by</label>
|
||
<select
|
||
id="running-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="ytd">Sort by Total</option>
|
||
<option value="code">Sort by Code</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{view === "cards" ? (
|
||
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} palette={palette} visibleMonths={visibleMonths} monthIndex={monthIndex} />
|
||
) : (
|
||
<GridView rows={sorted} monthTotals={monthTotals} monthTotalsExcl={monthTotalsExcl} byKind={byKind} visibleMonths={visibleMonths} grandTotal={grandTotal} grandExcl={(monthTotalsExcl || []).reduce((s, v) => s + v, 0)} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function CardsView({
|
||
rows, expanded, setExpanded, palette, visibleMonths, monthIndex,
|
||
}: {
|
||
rows: RunningRow[];
|
||
visibleMonths?: number;
|
||
expanded: string | null;
|
||
setExpanded: (id: string | null) => void;
|
||
palette: { income: string; profit: string; loss: string };
|
||
monthIndex?: number;
|
||
}) {
|
||
const monthLabel = monthIndex != null && monthIndex >= 0 ? MONTH_NAMES[monthIndex] : null;
|
||
return (
|
||
<div className="space-y-2">
|
||
{rows.map((r) => {
|
||
const isOpen = expanded === r.id;
|
||
const hasData = r.ytd > 0;
|
||
const monthValue = monthIndex != null ? r.months[monthIndex] ?? 0 : 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 flex items-center gap-4 p-4 text-left"
|
||
>
|
||
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
|
||
{r.code}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white">
|
||
<Wrench className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
|
||
{/* On a phone the name wraps onto its own line rather than being
|
||
cut short — the same rule the income cards follow. */}
|
||
<span className="sm:truncate">{r.name}</span>
|
||
</div>
|
||
</div>
|
||
{/* The sparkline yields its width to the month figure below xl: the
|
||
content column is only ~670px 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, 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 — the same
|
||
shape the income cards use. */}
|
||
<div className="hidden md:flex shrink-0 items-start gap-4">
|
||
<div className="text-right w-28">
|
||
<div className="text-xs text-gray-400 uppercase tracking-wider">YTD spend</div>
|
||
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
||
{hasData ? ZAR2(r.ytd) : "—"}
|
||
</div>
|
||
</div>
|
||
{monthLabel && (
|
||
<div className="text-right w-28">
|
||
<div className="text-xs text-gray-400 uppercase tracking-wider whitespace-nowrap">{monthLabel} so far</div>
|
||
<div className="text-sm font-semibold tabular-nums text-[#00798C] dark:text-[#EDAE49]">
|
||
{monthValue > 0 ? ZAR2(monthValue) : "—"}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div
|
||
className={`shrink-0 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
|
||
hasData ? "bg-[#EDAE49]/10 text-[#EDAE49] dark:bg-[#EDAE49]/20" : "bg-gray-100 text-gray-400 dark:bg-white/5"
|
||
}`}
|
||
>
|
||
{hasData ? `${r.months.filter((v) => v > 0).length} months` : "—"}
|
||
</div>
|
||
<ChevronRight
|
||
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||
/>
|
||
</button>
|
||
|
||
{/* Below md the money row above is hidden, so the year and the live month
|
||
get their own line — the same shape the income cards use on a phone. */}
|
||
<div className="flex md:hidden items-end justify-between gap-3 px-4 pb-2 pt-1 text-xs">
|
||
<div>
|
||
<div className="text-gray-400 uppercase tracking-wider">YTD spend</div>
|
||
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
||
{hasData ? ZAR2(r.ytd) : "—"}
|
||
</div>
|
||
</div>
|
||
{monthLabel && (
|
||
<div className="text-right">
|
||
<div className="text-gray-400 uppercase tracking-wider whitespace-nowrap">{monthLabel} so far</div>
|
||
<div className="text-sm font-semibold tabular-nums text-[#00798C] dark:text-[#EDAE49]">
|
||
{monthValue > 0 ? ZAR2(monthValue) : "—"}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* What the vehicle's spend is MADE of, on the card itself. These three parts
|
||
are the whole of it, so they add up to the YTD figure above — and the
|
||
vehicles add up to the Overview. A vehicle with no spend shows nothing
|
||
here: "nothing recorded" is already on the card. */}
|
||
{hasData && (
|
||
<div className="px-4 pb-3 -mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||
{KINDS.map((k, index) => (
|
||
<span key={k} className="whitespace-nowrap">
|
||
<span className={r.byKindYtd[k] > 0 ? "text-gray-400 dark:text-text-soft" : "text-gray-300 dark:text-gray-600"}>{COST_KIND_LABELS[k]}</span>{" "}
|
||
<span className={r.byKindYtd[k] > 0 ? "font-medium tabular-nums text-gray-600 dark:text-gray-300" : "tabular-nums text-gray-300 dark:text-gray-600"}>{ZAR2(r.byKindYtd[k])}</span>
|
||
{index < KINDS.length - 1 && <span className="ml-3 text-gray-300 dark:text-gray-600">·</span>}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{isOpen && (
|
||
<div className="border-t border-gray-100 dark:border-border px-4 py-4 bg-gray-50/50 dark:bg-white/[0.04]">
|
||
{/* Mobile: 3 cols (4 rows × 3 cols = 12 months). md+: 6 cols. lg+: 12 cols. */}
|
||
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-12 gap-2 text-xs">
|
||
{r.months.slice(0, visibleMonths ?? 12).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 ? "bg-[#EDAE49]/5 text-[#EDAE49] dark:bg-[#EDAE49]/15" : "text-gray-300 dark:text-text-soft"}`}>
|
||
{v > 0 ? ZAR2(v) : "—"}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{/* The three kinds, and the total they add up to. This is the check: the
|
||
parts ARE the spend, so the parts and the total can never disagree. */}
|
||
<div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-3 border-t border-gray-100 pt-3 text-xs dark:border-border sm:grid-cols-4">
|
||
{KINDS.map((k) => (
|
||
<div key={k}>
|
||
<div className="text-gray-400 uppercase tracking-wider text-xs">{COST_KIND_LABELS[k]}</div>
|
||
<div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.byKindYtd[k])}</div>
|
||
</div>
|
||
))}
|
||
<div>
|
||
<div className="text-gray-400 uppercase tracking-wider text-xs">YTD total</div>
|
||
<div className="font-semibold tabular-nums text-[#EDAE49]">{ZAR(r.ytd)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GridView({
|
||
rows, monthTotals, monthTotalsExcl, byKind, visibleMonths, grandTotal, grandExcl,
|
||
}: {
|
||
rows: RunningRow[];
|
||
monthTotals: number[];
|
||
monthTotalsExcl?: number[];
|
||
byKind?: { purchases: number[]; insurance: number[]; finance: number[] };
|
||
visibleMonths?: number;
|
||
grandTotal: number;
|
||
grandExcl: number;
|
||
}) {
|
||
// Shown only when the table actually overflows — a permanent fade on a table that
|
||
// cannot scroll tells the reader to drag a page with nowhere to go.
|
||
const scroller = useRef<HTMLDivElement>(null);
|
||
const [overflows, setOverflows] = useState(false);
|
||
useEffect(() => {
|
||
const el = scroller.current;
|
||
if (!el) return;
|
||
const check = () => setOverflows(el.scrollWidth > el.clientWidth + 1);
|
||
check();
|
||
const ro = new ResizeObserver(check);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [rows.length]);
|
||
|
||
return (
|
||
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
|
||
{/* `relative` + the conditional fade give the right-edge affordance a wide table
|
||
needs on a phone — but only when there is actually something to scroll to. */}
|
||
<div className="relative">
|
||
<div ref={scroller} className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-y 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.slice(0, visibleMonths ?? 12).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) => (
|
||
<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">
|
||
<Wrench className="w-3 h-3 text-[#EDAE49]" />
|
||
{r.name}
|
||
</div>
|
||
</td>
|
||
{r.months.slice(0, visibleMonths ?? 12).map((v, i) => (
|
||
<td key={i} className={`py-2.5 px-2 text-right tabular-nums ${v > 0 ? "text-gray-700 dark:text-gray-300" : "text-gray-300 dark:text-text-soft"}`}>
|
||
{v > 0 ? ZAR2(v) : "—"}
|
||
</td>
|
||
))}
|
||
<td className={`py-2.5 px-6 text-right tabular-nums font-semibold whitespace-nowrap ${r.ytd > 0 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
|
||
{r.ytd > 0 ? ZAR(r.ytd) : "—"}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{/* The three kinds, then the total they add up to. The fleet's parts are
|
||
visible above its total, so "do the costs add up?" is answered on the
|
||
screen instead of taken on trust. */}
|
||
{byKind && KINDS.map((k) => {
|
||
const cells = byKind[k].slice(0, visibleMonths ?? 12);
|
||
const kindTotal = cells.reduce((s, v) => s + v, 0);
|
||
return (
|
||
<tr key={k} className="border-t border-gray-50 dark:border-border">
|
||
<td colSpan={2} className="py-2 px-6 text-right text-xs text-gray-500 dark:text-gray-400">
|
||
{COST_KIND_LABELS[k]}
|
||
</td>
|
||
{cells.map((v, i) => (
|
||
<td key={i} className="py-2 px-2 text-right tabular-nums whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||
{v > 0 ? ZAR(v) : "—"}
|
||
</td>
|
||
))}
|
||
<td className="py-2 px-6 text-right tabular-nums whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||
{kindTotal > 0 ? ZAR(kindTotal) : "—"}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{/* Bottom row: the month's total on BOTH bases, incl ABOVE excl, so a
|
||
reader never has to back the VAT out by hand. */}
|
||
<tr className="bg-gradient-to-r from-[#EDAE49]/10 dark:from-[#EDAE49]/15 to-transparent border-t-2 border-[#EDAE49]/30">
|
||
<td colSpan={2} className="py-3 px-6 font-semibold text-right align-bottom text-[#003D5B] dark:text-white">
|
||
Total
|
||
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
|
||
incl / excl
|
||
</span>
|
||
</td>
|
||
{monthTotals.slice(0, visibleMonths ?? 12).map((v, i) => (
|
||
<td
|
||
key={i}
|
||
title={byKind ? kindTitle(byKind, i) : undefined}
|
||
className="py-3 px-2 text-right tabular-nums align-bottom"
|
||
>
|
||
{v > 0 ? (
|
||
<>
|
||
<span className="block font-semibold text-[#003D5B] dark:text-white">{ZAR(v)}</span>
|
||
{monthTotalsExcl && (
|
||
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
|
||
{ZAR(monthTotalsExcl[i] ?? 0)}
|
||
</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className="text-[#003D5B] dark:text-white">—</span>
|
||
)}
|
||
</td>
|
||
))}
|
||
<td className="py-3 px-6 text-right tabular-nums align-bottom">
|
||
<span className="block font-bold text-base text-[#EDAE49]">{ZAR(grandTotal)}</span>
|
||
{grandExcl > 0 && (
|
||
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
|
||
{ZAR(grandExcl)}
|
||
</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{overflows && <ScrollFade />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|