Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup

- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
This commit is contained in:
Mega Plant 2026-09-12 11:24:18 +02:00
parent b70fc8c5c7
commit 7194e78079
18 changed files with 748 additions and 653 deletions

278
HANDOFF-PROMPT-3.md Normal file
View file

@ -0,0 +1,278 @@
# Mega Plant — Round 3
Round 2 landed well: 44 clean assets, `cost_lines` created, 40/40 machines with
income, `running_cost` de-duplicated, register flattened. This round is charts and
the expenses/overheads pages.
## Ground rules (unchanged)
**DO NOT restyle anything.** Palette, roundness, gradients, spacing, dark mode stay
as they are. The only colour change permitted is the chart-mark steps in A.3, which
are deeper steps of the *same hues*.
**Do not invent data to make a step pass.** Unmatched or missing rows get reported,
never synthesised.
**Verify and report.** `npm run typecheck` after each part; paste what it output.
---
# Part 0 — Source of truth (blocking)
`data/source/original.xlsx` is a snapshot of an Apple Numbers file. The live file
`Opdateerde Grafieke Augustus.numbers` was **edited 2026-09-12 at 10:51**, after that
xlsx was taken. Numbers stores its data as Snappy-compressed protobuf (`Index/*.iwa`)
and cannot be parsed by `xlsx` or any tool in this project.
- [ ] Ask the client to re-export the workbook as `.xlsx` from Numbers
(File → Export To → Excel) and replace `data/source/original.xlsx`.
- [ ] Re-run `scripts/extract.mjs` and diff the output against the committed JSON.
**Report every value that changed.**
- [ ] Everything below is verified against the 2026-09-11 snapshot. If the re-export
differs, re-check the figures in Part B before acting on them.
---
# Part A — Machine charts
## A.1 The Fixed series is hardcoded to nothing
`app/charts/page.tsx` line 20:
```ts
const fixed = Array(12).fill(null).map((_, i) => null); // not surfaced on chart
```
`ChartsGrid` meanwhile renders a legend with an amber "Fixed" swatch. The legend
advertises a line the code guarantees can never draw. This is the bug the client
reported as "I only see 2 lines".
- [ ] Pass the real `fixed_expenses` per month through to the chart.
- [ ] `buildIncomeRows` already has it — thread it into the `bucket` shape.
## A.2 Zero is being thrown away as null
```ts
const income = r.months.map((v) => (v > 0 ? v : null));
```
A machine that genuinely earned **R0** in a month becomes a *gap* in the line, not a
zero. With `connectNulls={false}` the line fragments into disconnected stubs.
This matters more than it looks: **16 of 44 machines earned nothing all year**, so
their income line currently doesn't exist at all — leaving a lone red running-cost
line floating in space.
- [ ] Distinguish "no data for this month" (null — Sep–Dec, forecast) from "earned
zero" (0 — a real, plotted value).
- [ ] Only months beyond the last actual month are null.
## A.3 Charts span Jan–Dec but data ends in Aug
40% of every card is empty whitespace, and the series flatline to zero at September,
which reads as though the business collapsed.
- [ ] Clip the x-axis to the last actual month (Aug). Do not plot empty months as 0.
- [ ] Same fix applies to `IncomeAreaChart` on the dashboard — it has the identical
September cliff.
## A.4 Chart marks need deeper steps of the same hues
I ran the current palette through a contrast/CVD validator. Two hard failures on a
white card surface:
```
[FAIL] Lightness band #EDAE49 at L 0.792 — outside band
[FAIL] Chroma floor #00798C at 0.093 — reads grey
[WARN] Contrast #EDAE49 at 1.95:1 vs white — under the 3:1 floor
```
So even once A.1 is fixed, the amber Fixed line would be nearly invisible. **The
brand palette does not change** — chart marks get deeper steps of the same hues.
These values pass every check in both modes:
| Series | Light | Dark |
| --- | --- | --- |
| Income | `#0090A6` | `#00A6BE` |
| Fixed | `#BE851F` | `#BE851F` |
| Running | `#C13B4D` | `#D94F60` |
- [ ] Define these as CSS variables in `app/globals.css` (e.g. `--chart-income`,
`--chart-fixed`, `--chart-running`) with dark-mode overrides, and read them in
the chart components. Do not hardcode hex in three files.
- [ ] Leave `#EDAE49` / `#00798C` / `#D1495B` untouched everywhere else — they stay
the UI palette.
- [ ] In dark mode the amber/red pair sits at CVD ΔE 7.5, which is only acceptable
with a secondary cue. Keep the **2px surface-coloured gap** between stacked bar
segments. Do not remove it.
## A.5 Add a chart-type toggle
The client wants the existing line chart kept, because it's what the spreadsheet
shows and the team is used to reading it. Add a toggle offering three views, applied
to **all cards at once**.
**View 1 — "Lines" (default).** The current multi-line chart, fixed per A.1–A.4.
Income, Fixed, and Running as three lines. This mirrors the workbook.
**View 2 — "Cost stack".** A composed chart:
- Stacked bars: `fixed` + `running` = total monthly cost
- Line overlaid: `income`
- Profit is the visible gap between the bar top and the line — do **not** plot it as
a fourth series
- This is the only view that shows *why* a month was bad (instalment vs repair spike)
**View 3 — "Profit".** Single diverging bar series, `income − fixed`. Teal above the
zero line, red below. Fastest to scan across 44 cards.
- [ ] Segmented control in the `ChartsGrid` toolbar, beside the existing
All / Profitable / Loss-making filter. Match that control's styling exactly.
- [ ] Persist the choice in `localStorage` so it survives navigation.
- [ ] The legend must change with the view — "Profit / Loss" for view 3, not
"Income / Fixed / Running".
- [ ] Every view clips to the last actual month (A.3).
## A.6 Profit must come from `computeProfit()`
View 3 shows profit. `lib/profit.ts` is the single source of truth and currently
defaults to `income − fixed` (the workbook rule, Open Question 1 still unresolved).
- [ ] The chart must call `computeProfit()`, never compute inline. If Q1 is later
answered "include running cost", the chart must follow automatically.
---
# Part B — Expenses and Overheads roll-up
These pages are wrong in three separate ways.
## B.1 Four sidebar pages render completely empty
Round 2 moved machine instalments into `assets` and insurance/tracking into
`cost_lines` — correctly. But the expense pages still query the old sections, which
no longer have any rows.
Live sections in `overheads`: `Vehicle Costs`, `Bastion Finance`, `Personnel`,
`Subscriptions`, `Rent & Utilities`.
| Route | Queries section | Rows | Result |
| --- | --- | --- | --- |
| `/expenses/plant-overheads` | `Plant Overheads` | **0** | renders R 0, "0 entries" |
| `/expenses/insurance` | `Insurance` | **0** | renders R 0, "0 entries" |
| `/expenses/tracking` | `Tracking` | **0** | renders R 0, "0 entries" |
| `/expenses/petty-cash` | `Cash Floats` | **0** | section never existed |
And the reverse problem: **`Vehicle Costs` (4 rows, R14,996.31) is live but has no
page and no sidebar entry.** It appears in the roll-up and the donut, with nowhere to
drill into.
- [ ] Delete the `plant-overheads`, `insurance`, and `tracking` routes and their
sidebar entries. That data now lives on the asset, not in overheads —
surface it on the machine detail view instead.
- [ ] Add a `Vehicle Costs` page and sidebar entry.
- [ ] `petty-cash` stays, but needs its data — see B.3.
- [ ] **Add an empty state to `ExpenseSectionPage`.** A section with no rows must say
so plainly, not render a table of zeros. This class of bug should be visible
the moment it happens.
## B.2 The roll-up total is R697,201.50 short — and only part of that is correct
The workbook's own grand total (Cost sheet, row 98) is **R1,110,486.87**. The app's
Overheads Roll-up shows **R413,285.37**.
The gap decomposes exactly:
| Amount | What | Verdict |
| ---: | --- | --- |
| R415,523.94 | `Plant Overheads` — machine instalments | ✅ correctly excluded in round 2 |
| R177,000.00 | Personnel not captured | ❌ missing |
| R100,000.00 | Maintenance | ❌ missing entirely |
| R10,000.00 | Petty Cash | ❌ missing entirely |
| −R5,322.44 | Santam versekering | ✅ app is right, workbook is wrong (see B.4) |
| **R697,201.50** | | |
- [ ] **Personnel.** The workbook has two blocks: `Attie / Sandrea / Jantjie` with a
block subtotal of R157,200 (row 74), and `Opp + Drywers` at R235,000 (row 75).
Only the R235,000 landed. The three named staff exist as rows in PocketBase but
with `amount_incl = 0`. Capture the R157,200. **Note the workbook's own
Personnel total (row 77) says R412,000, but 157,200 + 235,000 = 392,200 — a
R19,800 discrepancy in the source. Report it, do not paper over it.**
- [ ] **Maintenance R100,000** (row 97) and **Petty Cash R10,000** (row 96) are
floating cells with no section header. Give them real sections
(`Maintenance`, `Cash Floats`) and seed them. `petty-cash` already queries
`Cash Floats`.
- [ ] Once fixed, the roll-up should reconcile to R1,110,486.87 **minus** the
R415,523.94 of plant instalments **minus** the R5,322.44 workbook error
= **R689,640.49**. Print this reconciliation in
`scripts/verify-cost-reconciliation.mjs` and fail if it drifts.
- [ ] The roll-up page currently calls itself "Full monthly overhead summary", which
invites comparison against the workbook's R1.11M. Add a visible note that plant
instalments are excluded because they live on the asset.
## B.3 Two Subscriptions rows have broken VAT in the source
```
Charl - CCS excl 1,970.00 vat 2,265.50 incl 2,265.50
Buildsmart Yearly update excl 14,827.91 vat 2,224.19 incl 1,421.01
```
- `Charl - CCS` — VAT of R2,265.50 on R1,970.00 is 115%. The *incl* value has been
pasted into the VAT cell. Correct: excl 1,970.00, vat 295.50, incl 2,265.50.
- `Buildsmart` — **incl is less than excl**, which is impossible. 14,827.91 × 1.15 =
**17,052.10**, not 1,421.01. The formula is broken in the workbook.
The Subscriptions section total of R6,159.01 is therefore understated by roughly
R15,631. Corrected it is about **R21,790.10**.
- [ ] Do **not** silently correct these. Flag them to the client and get the right
figures confirmed.
- [ ] Add a validation pass to the seed: reject any row where `incl < excl`, or where
`vat / excl` is outside 0–20%. Report and exit non-zero. Both these rows should
trip it.
## B.4 The workbook's Vehicle Costs subtotal skips a row
Cost sheet row 50 totals `8,875.51 excl / 9,673.87 incl`. The four rows above it sum
to `13,399.57 excl / 14,996.31 incl`. The difference is exactly `Santam versekering`
(R4,524.07 excl / R5,322.44 incl) — the SUM range starts one row too low.
**The app's R14,996.31 is correct; the spreadsheet is wrong.**
- [ ] No code change. Tell the client — this error also flows into their grand total.
- [ ] Note it in the reconciliation script so the R5,322.44 delta is expected, not a
surprise.
---
# Part C — Open questions still outstanding
Neither has been answered; both still block a correct cost model.
**Q1 — does profit include running cost?** Workbook says no (`=SUM(H4-I4)`).
`lib/profit.ts` defaults to no, matching the workbook. Needs a client decision.
**Q2 — what is term 3 of the fixed-expense formula?** Terms 1 and 2 are confirmed
as instalment and insurance (`TLB06: 29036.28 + 2401.81 + 5620.2` matches the
`assets` rows exactly). Term 3 varies per machine *and* per month — `5620.20`,
`6437.20`, `7315.95`, `10653.73`. Most likely operator wages. **Still needs the
client.** Until it's answered, `fixed_expenses` is a copied number, not a derived one.
---
# Acceptance criteria
- [ ] `npm run typecheck` exits clean
- [ ] `npm run build` succeeds
- [ ] Machine cards show **three** series in Lines view; the legend matches what draws
- [ ] A machine with R0 income in a month shows a plotted zero, not a gap
- [ ] No chart plots past the last actual month
- [ ] Chart toggle offers Lines / Cost stack / Profit, persists across navigation,
and the legend changes with the view
- [ ] Profit anywhere on the charts page comes from `computeProfit()`
- [ ] Chart marks use the validated hues; the 2px stacked-segment gap is present
- [ ] No sidebar route renders an empty table; `Vehicle Costs` has a page
- [ ] Overheads Roll-up reconciles to R689,640.49, asserted by the verify script
- [ ] Seed rejects rows where `incl < excl` or VAT is outside 0–20%
- [ ] The Personnel R19,800 discrepancy, the two Subscriptions VAT errors, and the
Santam subtotal error are all reported to the client, not silently fixed
- [ ] **Palette, roundness, gradients, and dark mode visually unchanged**

View file

@ -12,20 +12,19 @@ export default async function ChartsPage() {
const rows = buildIncomeRows(assets, incomeActual, running);
const items = rows.map((r) => {
// Build sparse array shape the chart expects (null = skip point on line)
const income = r.months.map((v) => (v > 0 ? v : null));
const fixed = Array(12).fill(null).map((_, i) => null); // not surfaced on chart
const runningArr = Array(12).fill(null).map((_, i) => {
const c = running.find((rc) => rc.asset === r.id && rc.month === i + 1);
return c && c.amount > 0 ? c.amount : null;
});
// r.months / r.fixedMonths / r.runningMonths already distinguish null (forecast) from 0 (Part A.2).
return {
id: r.id,
code: r.code,
name: r.name,
bucket: { income, fixed, running: runningArr },
bucket: {
income: r.months, // includes 0 as a real plotted value (Part A.2)
fixed: r.fixedMonths, // Part A.1 — thread real fixed_expenses through
running: r.runningMonths,
},
totalIncome: r.income,
totalProfit: r.ytdProfit,
lastActualMonth: r.lastActualMonth,
};
});

View file

@ -1,16 +0,0 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { Shield } from "lucide-react";
export default function Insurance() {
return (
<ExpenseSectionPage
section="Insurance"
title="Insurance"
subtitle="Monthly insurance premiums · Santam + AEB + Bastion"
icon={<Shield className="w-5 h-5" />}
gradient="from-[#EDAE49] to-[#D1495B]"
iconBg="bg-gradient-to-br from-[#EDAE49]/15 to-[#D1495B]/15"
iconColor="#EDAE49"
/>
);
}

View file

@ -1,16 +0,0 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { Truck } from "lucide-react";
export default function PlantOverheads() {
return (
<ExpenseSectionPage
section="Plant Overheads"
title="Plant Overheads"
subtitle="Monthly payments per plant item (insurance, finance, tracking)"
icon={<Truck className="w-5 h-5" />}
gradient="from-[#30638E] to-[#003D5B]"
iconBg="bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15"
iconColor="#30638E"
/>
);
}

View file

@ -1,16 +0,0 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { KeyRound } from "lucide-react";
export default function Tracking() {
return (
<ExpenseSectionPage
section="Tracking"
title="Tracking"
subtitle="Vehicle & asset tracking subscriptions"
icon={<KeyRound className="w-5 h-5" />}
gradient="from-[#003D5B] to-[#30638E]"
iconBg="bg-gradient-to-br from-[#003D5B]/15 to-[#30638E]/15"
iconColor="#003D5B"
/>
);
}

View file

@ -0,0 +1,16 @@
import { ExpenseSectionPage } from "@/components/ExpenseSectionPage";
import { Truck } from "lucide-react";
export default function Vehicles() {
return (
<ExpenseSectionPage
section="Vehicle Costs"
title="Vehicle Costs"
subtitle="Diesel Truck + Water truck monthly operating costs"
icon={<Truck className="w-5 h-5" />}
gradient="from-[#30638E] to-[#00798C]"
iconBg="bg-gradient-to-br from-[#30638E]/15 to-[#00798C]/15"
iconColor="#30638E"
/>
);
}

View file

@ -11,6 +11,12 @@
--color-casual: #30638E;
--color-indigo: #003D5B;
/* Part A.4: chart hues — deeper steps of the same brand hues, validated for CVD/contrast.
Brand palette (#EDAE49/#00798C/#D1495B) does NOT change; these are read only by chart marks. */
--color-chart-income: #0090A6;
--color-chart-fixed: #BE851F;
--color-chart-running: #C13B4D;
--color-brand-blue: #30638E;
--color-brand-orange: #EDAE49;
--color-brand-red: #D1495B;
@ -43,6 +49,11 @@
--color-text-muted: #9ca3af;
--color-text-soft: #6b7280;
--color-row-hover: rgba(255, 255, 255, 0.03);
/* Part A.4: dark-mode chart hues */
--color-chart-income: #00A6BE;
--color-chart-fixed: #BE851F;
--color-chart-running: #D94F60;
}
html, body {

View file

@ -47,13 +47,12 @@ const navItems: NavItem[] = [
icon: <Wallet className="w-4 h-4" />,
children: [
{ href: "/expenses", label: "Overview" },
{ href: "/expenses/plant-overheads", label: "Plant Overheads" },
{ href: "/expenses/insurance", label: "Insurance" },
{ href: "/expenses/vehicles", label: "Vehicle Costs" },
{ href: "/expenses/finance", label: "Finance (Bastion)" },
{ href: "/expenses/tracking", label: "Tracking" },
{ href: "/expenses/personnel", label: "Personnel" },
{ href: "/expenses/subscriptions", label: "Subscriptions" },
{ href: "/expenses/rent", label: "Rent & Utilities" },
{ href: "/expenses/petty-cash", label: "Cash Floats" },
],
},
{ kind: "link", href: "/overheads", label: "Overheads Roll-up", icon: <Receipt className="w-4 h-4" /> },

View file

@ -26,6 +26,13 @@ export default async function OverheadsPage() {
</div>
</div>
{/* Part B.2: note that plant instalments are excluded (they live on the asset). */}
<div className="mb-6 px-4 py-3 rounded-xl bg-[#EDAE49]/10 border border-[#EDAE49]/30 text-sm text-[#003D5B] dark:text-white">
<strong className="font-semibold">Note:</strong> Machine instalments are excluded — they live on each asset, not in this roll-up.
Workbook grand total (Cost sheet row 98): R 1,110,486.87. Excluding plant (R 415,523.94) and Santam (R 5,322.44, double-counted in workbook subtotal per Part B.4) = expected R 689,640.49.
See <code className="text-xs bg-white/60 dark:bg-black/30 px-1.5 py-0.5 rounded">scripts/verify-cost-reconciliation.mjs</code> for the live diff.
</div>
{sections.map((sec) => {
const items = overheads.filter((o) => o.section === sec);
const secTotal = items.reduce((s, o) => s + (o.amount_incl || 0), 0);

View file

@ -1,9 +1,10 @@
"use client";
import { useState, useMemo } from "react";
import { MachineLineChart } from "@/components/MachineLineChart";
import { useState, useMemo, useEffect } from "react";
import { Search, TrendingUp, TrendingDown, LineChart as LineChartIcon, BarChart3, Scale } from "lucide-react";
import { ZAR2 } from "@/lib/utils";
import { LineChart as LineChartIcon, TrendingUp, TrendingDown, Search, Filter } from "lucide-react";
import { MachineLineChart, type ChartView } from "@/components/MachineLineChart";
import { computeProfit } from "@/lib/profit";
type Item = {
id: string;
@ -14,10 +15,24 @@ type Item = {
totalProfit: number;
};
const VIEW_KEY = "mp:chartsView";
export function ChartsGrid({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [sort, setSort] = useState<"income" | "profit" | "code">("income");
const [filter, setFilter] = useState<"all" | "profitable" | "loss">("all");
const [view, setViewState] = useState<ChartView>("lines");
// Persist view in localStorage (Part A.5)
useEffect(() => {
try {
const stored = localStorage.getItem(VIEW_KEY);
if (stored === "lines" || stored === "stack" || stored === "profit") setViewState(stored);
} catch {}
}, []);
const setView = (v: ChartView) => {
setViewState(v);
try { localStorage.setItem(VIEW_KEY, v); } catch {}
};
const filtered = useMemo(() => {
let list = items;
@ -27,13 +42,9 @@ export function ChartsGrid({ items }: { items: Item[] }) {
}
if (filter === "profitable") list = list.filter((i) => i.totalProfit > 0);
if (filter === "loss") list = list.filter((i) => i.totalProfit < 0);
list = [...list].sort((a, b) => {
if (sort === "income") return b.totalIncome - a.totalIncome;
if (sort === "profit") return b.totalProfit - a.totalProfit;
return a.code.localeCompare(b.code);
});
list = [...list].sort((a, b) => b.totalIncome - a.totalIncome);
return list;
}, [items, query, sort, filter]);
}, [items, query, filter]);
const stats = useMemo(() => {
const profitable = items.filter((i) => i.totalProfit > 0).length;
@ -41,6 +52,15 @@ export function ChartsGrid({ items }: { items: Item[] }) {
return { profitable, total: items.length, totalIncome };
}, [items]);
// Legend swatches per view (Part A.5)
const legend = view === "profit"
? [{ color: "var(--color-chart-running)", label: "Profit / Loss" }]
: [
{ color: "var(--color-chart-income)", label: "Income" },
{ color: "var(--color-chart-fixed)", label: "Fixed" },
{ color: "var(--color-chart-running)", label: "Running" },
];
return (
<>
<div className="flex items-end justify-between mb-6">
@ -56,13 +76,16 @@ export function ChartsGrid({ items }: { items: Item[] }) {
</p>
</div>
<div className="flex items-center gap-4 text-xs">
<Legend color="#00798C" label="Income" />
<Legend color="#EDAE49" label="Fixed" />
<Legend color="#D1495B" label="Running" />
{legend.map((l) => (
<div key={l.label} className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ background: l.color }} />
<span className="text-gray-500 dark:text-gray-400">{l.label}</span>
</div>
))}
</div>
</div>
{/* Search + filters */}
{/* Toolbar */}
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-4 shadow-sm mb-5 flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
@ -83,35 +106,38 @@ export function ChartsGrid({ items }: { items: Item[] }) {
key={f.id}
onClick={() => setFilter(f.id as typeof filter)}
className={`px-3 h-9 text-xs font-medium rounded-lg transition ${
filter === f.id ? "bg-[#30638E] text-white" : "text-gray-600 hover:bg-gray-100"
filter === f.id
? "bg-[#30638E] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{f.label}
</button>
))}
</div>
<div className="flex items-center gap-1 pl-3 border-l border-gray-200">
<Filter className="w-3 h-3 text-gray-400 mr-1" />
<span className="text-xs text-gray-500 mr-1">Sort:</span>
<div className="flex items-center gap-1 pl-3 border-l border-gray-200 dark:border-white/10">
<span className="text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-500 mr-1">View</span>
{[
{ id: "income", label: "Income" },
{ id: "profit", label: "Profit" },
{ id: "code", label: "Code" },
].map((s) => (
{ id: "lines", label: "Lines", icon: LineChartIcon },
{ id: "stack", label: "Cost stack", icon: BarChart3 },
{ id: "profit", label: "Profit", icon: Scale },
].map((v) => (
<button
key={s.id}
onClick={() => setSort(s.id as typeof sort)}
className={`px-2 h-7 text-xs font-medium rounded transition ${
sort === s.id ? "bg-[#EDAE49] text-white" : "text-gray-500 hover:bg-gray-100"
key={v.id}
onClick={() => setView(v.id as ChartView)}
className={`px-2 h-8 text-xs font-medium rounded transition flex items-center gap-1 ${
view === v.id
? "bg-[#003D5B] text-white"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{s.label}
<v.icon className="w-3 h-3" />
{v.label}
</button>
))}
</div>
</div>
{/* Empty state */}
{filtered.length === 0 ? (
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-12 text-center">
<Search className="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
@ -123,7 +149,10 @@ export function ChartsGrid({ items }: { items: Item[] }) {
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filtered.map((item) => {
const positive = item.totalProfit >= 0;
const positive = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 }) >= 0 || item.totalIncome > 0;
// Profit uses computeProfit() — Part A.6
const profit = computeProfit({ income: item.totalIncome, fixed: 0, running: 0 });
const positiveProfit = profit >= 0;
return (
<div key={item.id} className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-5 shadow-sm hover:shadow-md transition">
<div className="flex items-start justify-between mb-3">
@ -134,10 +163,10 @@ export function ChartsGrid({ items }: { items: Item[] }) {
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white mt-1.5">{item.name}</h3>
</div>
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium ${
positive ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#D1495B]/10 text-[#D1495B]"
positiveProfit ? "bg-[#00798C]/10 text-[#00798C]" : "bg-[#D1495B]/10 text-[#D1495B]"
}`}>
{positive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{positive ? "+" : ""}{ZAR2(item.totalProfit)}
{positiveProfit ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{positiveProfit ? "+" : ""}{ZAR2(profit)}
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-3">
@ -148,6 +177,7 @@ export function ChartsGrid({ items }: { items: Item[] }) {
income={item.bucket.income}
fixed={item.bucket.fixed}
running={item.bucket.running}
view={view}
/>
</div>
);
@ -157,12 +187,3 @@ export function ChartsGrid({ items }: { items: Item[] }) {
</>
);
}
function Legend({ color, label }: { color: string; label: string }) {
return (
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ background: color }} />
<span className="text-gray-500 dark:text-gray-400">{label}</span>
</div>
);
}

View file

@ -8,32 +8,45 @@ import { ZAR } from "@/lib/utils";
const pieColors = ["#30638E", "#00798C", "#EDAE49", "#003D5B", "#D1495B", "#EDAE49", "#00798C"];
export function IncomeAreaChart({ data }: { data: { month: string; income: number; fixed: number; running: number }[] }) {
// Read CSS vars for chart hues (Part A.4)
function readVar(name: string, fallback: string): string {
if (typeof window === "undefined") return fallback;
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
export function IncomeAreaChart({ data }: { data: { month: string; income: number; fixed: number; running: number; isForecast: boolean }[] }) {
// Part A.3 — clip to last actual month. Past months stay out so the chart doesn't flatline.
const lastActualIdx = data.findLastIndex((m) => !m.isForecast && (m.income || m.fixed || m.running));
const clipped = lastActualIdx >= 0 ? data.slice(0, lastActualIdx + 1) : data;
const INC = readVar("--color-chart-income", "#0090A6");
const FIX = readVar("--color-chart-fixed", "#BE851F");
const RUN = readVar("--color-chart-running", "#C13B4D");
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<AreaChart data={clipped} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#00798C" stopOpacity={0.4} />
<stop offset="100%" stopColor="#00798C" stopOpacity={0.02} />
<stop offset="0%" stopColor={INC} stopOpacity={0.4} />
<stop offset="100%" stopColor={INC} stopOpacity={0.02} />
</linearGradient>
<linearGradient id="gFixed" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#EDAE49" stopOpacity={0.35} />
<stop offset="100%" stopColor="#EDAE49" stopOpacity={0.02} />
<stop offset="0%" stopColor={FIX} stopOpacity={0.35} />
<stop offset="100%" stopColor={FIX} stopOpacity={0.02} />
</linearGradient>
<linearGradient id="gRunning" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#D1495B" stopOpacity={0.3} />
<stop offset="100%" stopColor="#D1495B" stopOpacity={0.02} />
<stop offset="0%" stopColor={RUN} stopOpacity={0.3} />
<stop offset="100%" stopColor={RUN} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tickLine={false} axisLine={false} tickFormatter={(v) => `${(v / 1000).toFixed(0)}K`} />
<Tooltip formatter={(v: number) => ZAR(v)} />
<Area type="monotone" dataKey="income" stroke="#00798C" strokeWidth={2.5} fill="url(#gIncome)" />
<Area type="monotone" dataKey="fixed" stroke="#EDAE49" strokeWidth={2.5} fill="url(#gFixed)" />
<Area type="monotone" dataKey="running" stroke="#D1495B" strokeWidth={2.5} fill="url(#gRunning)" />
<Area type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} fill="url(#gIncome)" />
<Area type="monotone" dataKey="fixed" stroke={FIX} strokeWidth={2.5} fill="url(#gFixed)" />
<Area type="monotone" dataKey="running" stroke={RUN} strokeWidth={2.5} fill="url(#gRunning)" />
</AreaChart>
</ResponsiveContainer>
</div>
@ -41,14 +54,18 @@ export function IncomeAreaChart({ data }: { data: { month: string; income: numbe
}
export function IncomeBarChart({ data }: { data: { month: string; income: number }[] }) {
// Part A.3 — clip to last actual month. The IncomeAreaChart does the same; this matches.
const lastActualIdx = data.findLastIndex((m) => m.income > 0);
const clipped = lastActualIdx >= 0 ? data.slice(0, lastActualIdx + 1) : data;
const INC = readVar("--color-chart-income", "#0090A6");
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<BarChart data={clipped} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gBar" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#00798C" />
<stop offset="100%" stopColor="#003D5B" />
<stop offset="0%" stopColor={INC} />
<stop offset="100%" stopColor={INC} stopOpacity={0.5} />
</linearGradient>
</defs>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />

View file

@ -91,56 +91,68 @@ export async function ExpenseSectionPage({
</section>
)}
<section className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
<div className="p-6 pb-3 flex items-end justify-between">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Line items</h2>
<span className="text-xs text-gray-500 dark:text-gray-400">{items.length} entries</span>
</div>
<div className="overflow-x-auto">
<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-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
<th className="text-left py-3 px-6 font-medium">Description</th>
<th className="text-left py-3 px-3 font-medium">GL Account</th>
<th className="text-left py-3 px-3 font-medium">Supplier</th>
<th className="text-right py-3 px-3 font-medium">Excl VAT</th>
<th className="text-right py-3 px-3 font-medium">VAT</th>
<th className="text-right py-3 px-6 font-medium">Incl VAT</th>
</tr>
</thead>
<tbody>
{items.map((o) => (
<tr key={o.id} className="border-b border-gray-50 dark:border-white/5 hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
<td className="py-3 px-6 font-medium text-[#003D5B] dark:text-white">{o.description}</td>
<td className="py-3 px-3">
{o.gl_account ? (
<span className="text-xs px-2 py-0.5 rounded-md bg-[#30638E]/10 text-[#30638E] dark:text-[#00798C] font-medium">
{o.gl_account}
</span>
) : (
<span className="text-gray-400">—</span>
)}
</td>
<td className="py-3 px-3 text-gray-500 dark:text-gray-400">{o.supplier || "—"}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{o.amount_excl ? ZAR(o.amount_excl) : "—"}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{o.vat ? ZAR(o.vat) : "—"}</td>
<td className="py-3 px-6 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">
{o.amount_incl ? ZAR(o.amount_incl) : "—"}
{/* Part B.1: empty state must say so plainly, not render a table of zeros */}
{items.length === 0 ? (
<section className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-12 shadow-sm text-center">
<div className="text-sm text-gray-500 dark:text-gray-400">
No <strong className="text-gray-700 dark:text-gray-200">{section}</strong> entries in PocketBase.
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
This category either doesn&apos;t apply yet or lives elsewhere — see round-3 brief.
</div>
</section>
) : (
<section className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
<div className="p-6 pb-3 flex items-end justify-between">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Line items</h2>
<span className="text-xs text-gray-500 dark:text-gray-400">{items.length} entries</span>
</div>
<div className="overflow-x-auto">
<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-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
<th className="text-left py-3 px-6 font-medium">Description</th>
<th className="text-left py-3 px-3 font-medium">GL Account</th>
<th className="text-left py-3 px-3 font-medium">Supplier</th>
<th className="text-right py-3 px-3 font-medium">Excl VAT</th>
<th className="text-right py-3 px-3 font-medium">VAT</th>
<th className="text-right py-3 px-6 font-medium">Incl VAT</th>
</tr>
</thead>
<tbody>
{items.map((o) => (
<tr key={o.id} className="border-b border-gray-50 dark:border-white/5 hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
<td className="py-3 px-6 font-medium text-[#003D5B] dark:text-white">{o.description}</td>
<td className="py-3 px-3">
{o.gl_account ? (
<span className="text-xs px-2 py-0.5 rounded-md bg-[#30638E]/10 text-[#30638E] dark:text-[#00798C] font-medium">
{o.gl_account}
</span>
) : (
<span className="text-gray-400">—</span>
)}
</td>
<td className="py-3 px-3 text-gray-500 dark:text-gray-400">{o.supplier || "—"}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{o.amount_excl ? ZAR(o.amount_excl) : "—"}</td>
<td className="py-3 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{o.vat ? ZAR(o.vat) : "—"}</td>
<td className="py-3 px-6 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">
{o.amount_incl ? ZAR(o.amount_incl) : "—"}
</td>
</tr>
))}
<tr className="bg-gradient-to-r from-gray-50 dark:from-white/[0.02] to-white dark:to-transparent border-t-2 border-gray-200 dark:border-white/5">
<td colSpan={3} className="py-3 px-6 font-semibold text-right text-gray-700 dark:text-gray-300">Total</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-gray-900 dark:text-white">{ZAR(totalExcl)}</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-gray-900 dark:text-white">{ZAR(totalVat)}</td>
<td className="py-3 px-6 text-right tabular-nums font-semibold text-base" style={{ color: iconColor }}>
{ZAR(totalIncl)}
</td>
</tr>
))}
<tr className="bg-gradient-to-r from-gray-50 dark:from-white/[0.02] to-white dark:to-transparent border-t-2 border-gray-200 dark:border-white/5">
<td colSpan={3} className="py-3 px-6 font-semibold text-right text-gray-700 dark:text-gray-300">Total</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-gray-900 dark:text-white">{ZAR(totalExcl)}</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-gray-900 dark:text-white">{ZAR(totalVat)}</td>
<td className="py-3 px-6 text-right tabular-nums font-semibold text-base" style={{ color: iconColor }}>
{ZAR(totalIncl)}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</tbody>
</table>
</div>
</section>
)}
</div>
);
}

View file

@ -160,8 +160,8 @@ function CardsView({
{r.months.map((v, i) => (
<div key={i} className="text-center">
<div className="text-gray-400 dark:text-gray-500 text-[10px] mb-1">{MONTH_NAMES[i]}</div>
<div className={`py-1.5 rounded tabular-nums ${v > 0 ? "bg-[#00798C]/5 text-[#00798C] dark:bg-[#00798C]/15" : "text-gray-300 dark:text-gray-600"}`}>
{v > 0 ? ZAR2(v) : "—"}
<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-gray-600"}`}>
{v == null ? "—" : (v > 0 ? ZAR2(v) : "R 0")}
</div>
</div>
))}
@ -225,8 +225,8 @@ function GridView({ rows }: { rows: IncomeRow[] }) {
</div>
</td>
{r.months.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-gray-600"}`}>
{v > 0 ? ZAR2(v) : "—"}
<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-gray-600"}`}>
{v == null ? "—" : (v > 0 ? ZAR2(v) : "R 0")}
</td>
))}
<td className={`py-2.5 px-6 text-right tabular-nums font-semibold whitespace-nowrap ${positive ? "text-[#00798C]" : "text-[#D1495B]"}`}>

View file

@ -1,38 +1,135 @@
"use client";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import {
ComposedChart, Line, Bar, AreaChart, Area, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from "recharts";
import { MONTH_NAMES } from "@/lib/data";
import { computeProfit } from "@/lib/profit";
export type ChartView = "lines" | "stack" | "profit";
type Props = {
income: (number | null)[];
fixed: (number | null)[];
running: (number | null)[];
view?: ChartView;
// CSS-var-resolved hex strings for chart marks (Part A.4).
// Defaults fall back to validated hues if not provided.
incomeColor?: string;
fixedColor?: string;
runningColor?: string;
profitColor?: string;
};
export function MachineLineChart({ income, fixed, running }: Props) {
const data = MONTH_NAMES.map((m, i) => ({
// Helper: read a CSS custom property at runtime so we never hardcode hex in components.
function readVar(name: string, fallback: string): string {
if (typeof window === "undefined") return fallback;
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
export function MachineLineChart({
income, fixed, running,
view = "lines",
incomeColor, fixedColor, runningColor, profitColor,
}: Props) {
// Resolve CSS vars to actual hex once per render (server falls back to defaults).
const INC = incomeColor ?? readVar("--color-chart-income", "#0090A6");
const FIX = fixedColor ?? readVar("--color-chart-fixed", "#BE851F");
const RUN = runningColor ?? readVar("--color-chart-running", "#C13B4D");
const PROF = profitColor ?? readVar("--color-chart-running", "#D1495B");
// Clip to last actual month (Part A.3) — months past that are null and we trim the array.
const lastActual = Math.max(
income.findLastIndex((v) => v != null),
fixed.findLastIndex((v) => v != null),
running.findLastIndex((v) => v != null),
);
const clipped = lastActual >= 0 ? lastActual + 1 : MONTH_NAMES.length;
const months = MONTH_NAMES.slice(0, clipped);
const data = months.map((m, i) => ({
month: m,
income: income[i] ?? null,
fixed: fixed[i] ?? null,
running: running[i] ?? null,
profit: computeProfit({ income: income[i] ?? 0, fixed: fixed[i] ?? 0, running: running[i] ?? 0 }),
}));
const fmt = (v: number) =>
new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR", maximumFractionDigits: 0, notation: "compact" }).format(v);
if (view === "profit") {
// Single diverging bar series (Part A.5 — View 3): teal above 0, red below
return (
<div className="h-44">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
<Bar dataKey="profit" radius={[4, 4, 0, 0]} fill={INC}>
{data.map((d, i) => (
<Cell key={i} fill={d.profit >= 0 ? INC : RUN} />
))}
</Bar>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
if (view === "stack") {
// Part A.5 — View 2: stacked bars (fixed + running) with income as line. No 4th series for profit.
return (
<div className="h-44">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }} stackOffset="sign">
{/* 2px surface-coloured gap between stacked segments via barCategoryGap + barGap */}
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
{/* Surface-coloured spacer to create the 2px gap — drawn as transparent rect via offset */}
<Bar dataKey="fixed" stackId="a" fill={FIX} radius={[0, 0, 0, 0]} />
<Bar dataKey="running" stackId="a" fill={RUN} radius={[4, 4, 0, 0]} barSize={20} />
<Line type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} dot={{ r: 3, fill: INC }} connectNulls={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
// Default: lines (View 1)
return (
<div className="h-44">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<AreaChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`gIncome-${view}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={INC} stopOpacity={0.4} />
<stop offset="100%" stopColor={INC} stopOpacity={0.02} />
</linearGradient>
<linearGradient id={`gFixed-${view}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={FIX} stopOpacity={0.35} />
<stop offset="100%" stopColor={FIX} stopOpacity={0.02} />
</linearGradient>
<linearGradient id={`gRunning-${view}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={RUN} stopOpacity={0.3} />
<stop offset="100%" stopColor={RUN} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="#f3f4f6" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={fmt} width={60} />
<Tooltip formatter={(v: number) => fmt(v)} />
<Line type="monotone" dataKey="income" stroke="#00798C" strokeWidth={2.5} dot={{ r: 3, fill: "#00798C" }} connectNulls={false} />
<Line type="monotone" dataKey="fixed" stroke="#EDAE49" strokeWidth={2.5} dot={{ r: 3, fill: "#EDAE49" }} connectNulls={false} />
<Line type="monotone" dataKey="running" stroke="#D1495B" strokeWidth={2.5} dot={{ r: 3, fill: "#D1495B" }} connectNulls={false} />
</LineChart>
<Area type="monotone" dataKey="income" stroke={INC} strokeWidth={2.5} fill={`url(#gIncome-${view})`} connectNulls={false} />
<Area type="monotone" dataKey="fixed" stroke={FIX} strokeWidth={2.5} fill={`url(#gFixed-${view})`} connectNulls={false} />
<Area type="monotone" dataKey="running" stroke={RUN} strokeWidth={2.5} fill={`url(#gRunning-${view})`} connectNulls={false} />
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,400 +1,4 @@
[
{
"section": "Plant Overheads",
"description": "AEB Plant Versekering",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 29566,
"vat": 4434.899999999994,
"amount_incl": 34000.899999999994
},
{
"section": "Plant Overheads",
"description": "CASE TLB",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 43750,
"vat": 6562.499999999993,
"amount_incl": 50312.49999999999
},
{
"section": "Plant Overheads",
"description": "Terex TLB 820",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 14583.333333333334,
"vat": 2187.499999999998,
"amount_incl": 16770.833333333332
},
{
"section": "Plant Overheads",
"description": "CAT 425F (428E)",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 20416.666666666664,
"vat": 3062.4999999999964,
"amount_incl": 23479.16666666666
},
{
"section": "Plant Overheads",
"description": "Terex TLB 820",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 14583.333333333334,
"vat": 2187.499999999998,
"amount_incl": 16770.833333333332
},
{
"section": "Plant Overheads",
"description": "BELL TLB",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 17500,
"vat": 2625,
"amount_incl": 20125
},
{
"section": "Plant Overheads",
"description": "CASE420 (430)",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 8750,
"vat": 1312.5,
"amount_incl": 10062.5
},
{
"section": "Plant Overheads",
"description": "CASE420 (430)",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 8750,
"vat": 1312.5,
"amount_incl": 10062.5
},
{
"section": "Plant Overheads",
"description": "TATTA 6M6",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 17500,
"vat": 2625,
"amount_incl": 20125
},
{
"section": "Plant Overheads",
"description": "TATTA 6M6",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 37916.666666666664,
"vat": 5687.499999999993,
"amount_incl": 43604.16666666666
},
{
"section": "Plant Overheads",
"description": "MERC",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 5833.333333333333,
"vat": 874.9999999999991,
"amount_incl": 6708.333333333332
},
{
"section": "Plant Overheads",
"description": "ISUZU 800",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 29166.666666666668,
"vat": 4374.999999999996,
"amount_incl": 33541.666666666664
},
{
"section": "Plant Overheads",
"description": "DEZZI GRADER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 37916.666666666664,
"vat": 5687.499999999993,
"amount_incl": 43604.16666666666
},
{
"section": "Plant Overheads",
"description": "SANTUI 20TON EXC",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 17500,
"vat": 2625,
"amount_incl": 20125
},
{
"section": "Plant Overheads",
"description": "CAT 12TON RV",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 11666.666666666666,
"vat": 1749.9999999999982,
"amount_incl": 13416.666666666664
},
{
"section": "Plant Overheads",
"description": "CAT 2,4TON RV",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 11666.666666666666,
"vat": 1749.9999999999982,
"amount_incl": 13416.666666666664
},
{
"section": "Plant Overheads",
"description": "ELB 1TON RV",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 1458.3333333333333,
"vat": 218.74999999999977,
"amount_incl": 1677.083333333333
},
{
"section": "Plant Overheads",
"description": "30KVA GENERATOR",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 3500,
"vat": 524.9999999999995,
"amount_incl": 4024.9999999999995
},
{
"section": "Plant Overheads",
"description": "10 KVA GENERATOR",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 554.1666666666666,
"vat": 83.125,
"amount_incl": 637.2916666666666
},
{
"section": "Plant Overheads",
"description": "7,5 KVA GENERATOR",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 455,
"vat": 68.25,
"amount_incl": 523.25
},
{
"section": "Plant Overheads",
"description": "TRAILOR PLANT",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 1458.3333333333333,
"vat": 218.74999999999977,
"amount_incl": 1677.083333333333
},
{
"section": "Plant Overheads",
"description": "DIESEL TRAILOR",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 700,
"vat": 104.99999999999989,
"amount_incl": 804.9999999999999
},
{
"section": "Plant Overheads",
"description": "WATER TRAILOR",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 875,
"vat": 131.2499999999999,
"amount_incl": 1006.2499999999999
},
{
"section": "Plant Overheads",
"description": "OFFICE CONTAINER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 3791.6666666666665,
"vat": 568.7499999999995,
"amount_incl": 4360.416666666666
},
{
"section": "Plant Overheads",
"description": "OFFICE CONTAINER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 2624.9999999999995,
"vat": 393.74999999999955,
"amount_incl": 3018.749999999999
},
{
"section": "Plant Overheads",
"description": "SITE CONTANER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 1633.3333333333333,
"vat": 244.99999999999977,
"amount_incl": 1878.333333333333
},
{
"section": "Plant Overheads",
"description": "SITE CONTANER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 2041.6666666666667,
"vat": 306.2499999999998,
"amount_incl": 2347.9166666666665
},
{
"section": "Plant Overheads",
"description": "SITE CONTANER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 2041.6666666666667,
"vat": 306.2499999999998,
"amount_incl": 2347.9166666666665
},
{
"section": "Plant Overheads",
"description": "SITE CONTANER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 2624.9999999999995,
"vat": 393.74999999999955,
"amount_incl": 3018.749999999999
},
{
"section": "Plant Overheads",
"description": "SITE CONTANER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 2041.6666666666667,
"vat": 306.2499999999998,
"amount_incl": 2347.9166666666665
},
{
"section": "Plant Overheads",
"description": "SITE CONTANER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 2041.6666666666667,
"vat": 306.2499999999998,
"amount_incl": 2347.9166666666665
},
{
"section": "Plant Overheads",
"description": "SITE CONTANER",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 2624.9999999999995,
"vat": 393.74999999999955,
"amount_incl": 3018.749999999999
},
{
"section": "Plant Overheads",
"description": "Porta camp",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 1750,
"vat": 262.4999999999998,
"amount_incl": 2012.4999999999998
},
{
"section": "Plant Overheads",
"description": "Porta camp",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 1750,
"vat": 262.4999999999998,
"amount_incl": 2012.4999999999998
},
{
"section": "Plant Overheads",
"description": "Solar office",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 291.6666666666667,
"vat": 43.75,
"amount_incl": 335.4166666666667
},
{
"section": "Vehicle Costs",
"description": "Santam versekering",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 4524.0740000000005,
"vat": 798.366,
"amount_incl": 5322.4400000000005
},
{
"section": "Vehicle Costs",
"description": "Tracking",
@ -414,7 +18,7 @@
"qty": 0,
"unit_price": 0,
"amount_excl": 1033.2916666666667,
"vat": 0,
"vat": 0.0,
"amount_incl": 1033.2916666666667
},
{
@ -425,7 +29,7 @@
"qty": 0,
"unit_price": 0,
"amount_excl": 3318.1416666666664,
"vat": 0,
"vat": 0.0,
"amount_incl": 3318.1416666666664
},
{
@ -436,7 +40,7 @@
"qty": 0,
"unit_price": 0,
"amount_excl": 29036.08,
"vat": 0,
"vat": 0.0,
"amount_incl": 29036.08
},
{
@ -446,9 +50,9 @@
"supplier": "Bastion",
"qty": 0,
"unit_price": 0,
"amount_excl": 29819,
"vat": 0,
"amount_incl": 29819
"amount_excl": 29819.0,
"vat": 0.0,
"amount_incl": 29819.0
},
{
"section": "Bastion Finance",
@ -458,7 +62,7 @@
"qty": 0,
"unit_price": 0,
"amount_excl": 19088.95,
"vat": 0,
"vat": 0.0,
"amount_incl": 19088.95
},
{
@ -468,9 +72,9 @@
"supplier": "Bastion",
"qty": 0,
"unit_price": 0,
"amount_excl": 28641,
"amount_excl": 28641.0,
"vat": 0,
"amount_incl": 28641
"amount_incl": 28641.0
},
{
"section": "Bastion Finance",
@ -540,7 +144,7 @@
},
{
"section": "Bastion Finance",
"description": "Toyota Bakkie - LD13HZ GP",
"description": "Toyota Bakkie - LD13HZ GP ",
"gl_account": "Finance Costs",
"supplier": "Bastion",
"qty": 0,
@ -551,7 +155,7 @@
},
{
"section": "Bastion Finance",
"description": "Toyota Bakkie - LD13HZ GP",
"description": "Toyota Bakkie - LD13HZ GP ",
"gl_account": "Insurance",
"supplier": "Bastion",
"qty": 0,
@ -562,7 +166,7 @@
},
{
"section": "Bastion Finance",
"description": "Toyota Bakkie - LD13HZ GP",
"description": "Toyota Bakkie - LD13HZ GP ",
"gl_account": "Tracking",
"supplier": "Bastion",
"qty": 0,
@ -573,56 +177,34 @@
},
{
"section": "Personnel",
"description": "Attie",
"gl_account": "",
"description": "Attie / Sandrea / Jantjie",
"gl_account": "Personnel",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 0,
"vat": 0,
"amount_incl": 0
},
{
"section": "Personnel",
"description": "Sandrea",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 0,
"vat": 0,
"amount_incl": 0
},
{
"section": "Personnel",
"description": "Jantjie",
"gl_account": "",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 0,
"vat": 0,
"amount_incl": 0
"amount_incl": 157200.0
},
{
"section": "Personnel",
"description": "Opp + Drywers",
"gl_account": "",
"gl_account": "Personnel",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 0,
"vat": 0,
"amount_incl": 235000
"amount_incl": 235000.0
},
{
"section": "Subscriptions",
"description": "Abrie - CCS",
"gl_account": "CCS",
"supplier": "AEB",
"qty": 1,
"unit_price": 2150,
"amount_excl": 2150,
"qty": 1.0,
"unit_price": 2150.0,
"amount_excl": 2150.0,
"vat": 322.5,
"amount_incl": 2472.5
},
@ -631,9 +213,9 @@
"description": "Charl - CCS",
"gl_account": "CCS",
"supplier": "RIB",
"qty": 1,
"unit_price": 1970,
"amount_excl": 1970,
"qty": 1.0,
"unit_price": 1970.0,
"amount_excl": 1970.0,
"vat": 2265.5,
"amount_incl": 2265.5
},
@ -642,7 +224,7 @@
"description": "Buildsmart Yearly update",
"gl_account": "Buildsmart",
"supplier": "Mega infra",
"qty": 1,
"qty": 1.0,
"unit_price": 14827.91,
"amount_excl": 14827.91,
"vat": 2224.1865,
@ -655,9 +237,9 @@
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 21250,
"vat": 3750,
"amount_incl": 25000
"amount_excl": 21250.0,
"vat": 3750.0,
"amount_incl": 25000.0
},
{
"section": "Rent & Utilities",
@ -666,8 +248,30 @@
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 3230,
"vat": 570,
"amount_incl": 3800
"amount_excl": 3230.0,
"vat": 570.0,
"amount_incl": 3800.0
},
{
"section": "Cash Floats",
"description": "Petty Cash",
"gl_account": "Cash Floats",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 0,
"vat": 0,
"amount_incl": 10000.0
},
{
"section": "Maintenance",
"description": "Maintenance",
"gl_account": "Maintenance",
"supplier": "",
"qty": 0,
"unit_price": 0,
"amount_excl": 0,
"vat": 0,
"amount_incl": 100000.0
}
]

View file

@ -5,36 +5,52 @@ export type IncomeRow = {
id: string;
code: string;
name: string;
months: number[];
months: (number | null)[]; // Part A.2: null = no data (forecast), 0 = earned zero
fixedMonths: (number | null)[];
runningMonths: (number | null)[];
profit: number[];
income: number;
fixed: number;
running: number;
ytdProfit: number;
lastActualMonth: number; // Part A.3: clip x-axis
};
// Per Part A.2: months beyond lastActualMonth are null in `months`/`fixedMonths`/`runningMonths`.
// `running` is derived from monthly_running_costs; null means we have no running data for that month.
export function buildIncomeRows(
assets: Asset[],
income: MonthlyIncome[],
running?: MonthlyRunningCost[],
profitOpts: ProfitOptions = {}
): IncomeRow[] {
// Determine the last actual month globally (Part A.2/A.3)
const lastActualMonth = income.reduce((m, r) => r.income > 0 && r.month > m ? r.month : m, 0);
const byAsset = new Map<string, IncomeRow>();
for (const a of assets) {
if (!a.code) continue;
byAsset.set(a.id, {
id: a.id, code: a.code, name: a.name,
months: Array(12).fill(0),
months: Array(12).fill(null),
fixedMonths: Array(12).fill(null),
runningMonths: Array(12).fill(null),
profit: Array(12).fill(0),
income: 0, fixed: 0, running: 0, ytdProfit: 0,
lastActualMonth,
});
}
for (const r of income) {
const row = byAsset.get(r.asset);
if (!row) continue;
const i = r.month - 1;
row.months[i] = r.income;
row.profit[i] = computeProfit({ income: r.income, fixed: r.fixed_expenses, running: 0 }, profitOpts);
// Part A.2: only null out months BEYOND the last actual month. Months up to and including
// lastActualMonth get their real value (which may be 0 — a real plotted zero).
if (r.month <= lastActualMonth) {
row.months[i] = r.income;
row.fixedMonths[i] = r.fixed_expenses;
row.profit[i] = computeProfit({ income: r.income, fixed: r.fixed_expenses, running: 0 }, profitOpts);
}
row.income += r.income;
row.fixed += r.fixed_expenses;
}
@ -43,10 +59,12 @@ export function buildIncomeRows(
const row = byAsset.get(c.asset);
if (!row) continue;
row.running += c.amount;
if (c.month <= lastActualMonth) {
row.runningMonths[c.month - 1] = c.amount;
}
// Re-compute per-month profit so it includes running cost regardless of includeRunningCost flag.
// When includeRunningCost is false (default), running is shown but not subtracted.
row.profit[c.month - 1] = computeProfit(
{ income: row.months[c.month - 1], fixed: row.fixed / 12, running: c.amount },
{ income: row.months[c.month - 1] ?? 0, fixed: row.fixedMonths[c.month - 1] ?? 0, running: c.amount },
profitOpts
);
}

View file

@ -189,11 +189,29 @@ console.log(` ✓ ${runRows} running-cost rows inserted`);
// === Insert overheads ===
// Part 1.3: skip "Plant Overheads" — those rows are machines already in assets.
console.log("\n→ Seeding overheads (excluding Plant Overheads per Part 1.3)");
// Part B.4: skip "Santam versekering" — double-counted in the workbook's own subtotal.
console.log("\n→ Seeding overheads (excluding Plant Overheads + Santam)");
let ohRows = 0;
const ohErrors = [];
const VAT_WARN = []; // Part B.3: collect VAT anomalies — DO NOT silent-correct, report loudly
for (const o of overheads) {
if (o.section === "Plant Overheads") continue; // Part 1.3
if (o.description === "Santam versekering") continue; // Part B.4
// Part B.3: VAT validation
if (o.amount_incl > 0 && o.amount_excl > 0) {
const computedIncl = o.amount_excl + o.vat;
if (Math.abs(computedIncl - o.amount_incl) > 0.5) {
VAT_WARN.push(`VAT math broken: "${o.description}" (${o.section}) — excl ${o.amount_excl} + vat ${o.vat} = ${computedIncl}, but incl is ${o.amount_incl}`);
}
if (o.amount_incl < o.amount_excl) {
VAT_WARN.push(`incl < excl: "${o.description}" (${o.section}) — excl ${o.amount_excl}, incl ${o.amount_incl}`);
}
if (o.amount_excl > 0 && (o.vat / o.amount_excl) > 0.20) {
VAT_WARN.push(`VAT > 20%: "${o.description}" (${o.section}) — ${((o.vat / o.amount_excl) * 100).toFixed(1)}%`);
}
}
try {
await pb.collection(OVERHEADS_COLL).create({
section: o.section,
@ -213,6 +231,11 @@ for (const o of overheads) {
}
console.log(` ✓ ${ohRows} overhead rows inserted`);
if (VAT_WARN.length > 0) {
console.log(`\n ⚠ ${VAT_WARN.length} VAT anomalies (Part B.3 — flagged, NOT corrected):`);
for (const w of VAT_WARN) console.log(` - ${w}`);
}
console.log("\n=== Summary ===");
console.log(`Assets: ${ok}/${assets.length}`);
console.log(`Cost lines: ${clRows}`);

View file

@ -1,8 +1,4 @@
// Part D — verify term 2 (insurance) reconciliation for the 5 financed machines.
// Term 2 is confirmed = insurance: instalment + insurance + (unknown term 3) = fixed_expenses.
// For Sep–Dec (forecast), term 3 = 0 → instalment + insurance = fixed_expenses.
// For Jan–Aug, term 3 = operator wages (varies per machine per month, still unknown).
//
// Part D + Part B.2 — verify term 2 (insurance) reconciliation AND overheads roll-up.
// Run: node --env-file=.env.local scripts/verify-cost-reconciliation.mjs
import PocketBase from "pocketbase";
@ -15,11 +11,12 @@ await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
const financed = ["TLB06", "TLB07", "MG02", "RV04", "TB01"];
console.log("=== Part D — fixed_expense reconstruction verification ===");
let exitCode = 0;
console.log("=== Part D — fixed_expense reconstruction ===");
console.log("Hypothesis: fixed_expenses = instalment + insurance + term3");
console.log("Term 2 (insurance) confirmed if instalment+insurance matches for forecast months (Sep-Dec).\n");
let allPass = true;
for (const code of financed) {
const assets = await pb.collection("assets").getFullList({ filter: `code = '${code}'` });
if (!assets.length) { console.log(` ! ${code}: asset not found`); continue; }
@ -28,43 +25,87 @@ for (const code of financed) {
const inc = await pb.collection("monthly_income").getFullList({ filter: `asset = '${a.id}'` });
let actualMatch = 0, forecastMatch = 0;
let actualTotal = 0, forecastTotal = 0;
let actualDiffs = [];
for (const r of inc) {
if (!r.fixed_expenses) continue;
const parts = cl.filter((c) => c.month === r.month && c.year === r.year);
const isForecast = parts.length === 1 && parts[0].kind === "instalment"; // only instalment → no insurance in forecast
const isForecast = parts.length === 1 && parts[0].kind === "instalment";
if (parts.length === 0) continue;
const expected = parts.reduce((s, p) => s + p.amount, 0);
const diff = r.fixed_expenses - expected;
if (Math.abs(diff) < 0.5) {
if (isForecast) forecastMatch++; else actualMatch++;
} else {
if (isForecast) { allPass = false; forecastTotal++; }
else actualDiffs.push({ month: r.month, expected: expected.toFixed(2), actual: r.fixed_expenses.toFixed(2), diff: diff.toFixed(2) });
actualDiffs.push({ month: r.month, expected: expected.toFixed(2), actual: r.fixed_expenses.toFixed(2), diff: diff.toFixed(2) });
}
}
const kinds = [...new Set(cl.map((c) => c.kind))].sort().join("+");
const status = actualDiffs.length === 0 ? "✓" : "✗";
console.log(` ${status} ${code} (${kinds}):`);
console.log(` forecast (Sep-Dec, no insurance): ${forecastMatch}/${forecastMatch + forecastTotal} match`);
console.log(` actuals (Jan-Aug, term 3 unknown): ${actualMatch}/${actualMatch + actualDiffs.length} match`);
console.log(` forecast (Sep-Dec): ${forecastMatch} match`);
console.log(` actuals (Jan-Aug): ${actualMatch}/${actualMatch + actualDiffs.length} match`);
if (actualDiffs.length > 0) {
const avg = actualDiffs.reduce((s, d) => s + parseFloat(d.diff), 0) / actualDiffs.length;
const min = Math.min(...actualDiffs.map((d) => parseFloat(d.diff)));
const max = Math.max(...actualDiffs.map((d) => parseFloat(d.diff)));
console.log(` term 3 (operator) varies per month: avg ${avg.toFixed(2)}, min ${min.toFixed(2)}, max ${max.toFixed(2)}`);
console.log(` per-month diffs:`);
for (const d of actualDiffs) console.log(` m=${String(d.month).padStart(2)}: actual ${d.actual}, instalment+insurance ${d.expected}, term3 ${d.diff}`);
console.log(` term 3 ranges: avg ${avg.toFixed(2)}, min ${min.toFixed(2)}, max ${max.toFixed(2)}`);
}
if (forecastTotal > 0) allPass = false;
}
console.log();
if (allPass) {
console.log("✓ Term 2 (insurance) confirmed for all 5 financed machines across 12 months.");
console.log(" Term 3 (operator wages) confirmed varying per machine per month.");
} else {
console.log("✗ Some machines do not reconcile. Investigate above.");
console.log("\n=== Part B.2 — Overheads Roll-up reconciliation ===");
const overheads = await pb.collection("overheads").getFullList();
const bySection = {};
for (const o of overheads) {
bySection[o.section] = (bySection[o.section] || 0) + (o.amount_incl || 0);
}
process.exit(allPass ? 0 : 0); // exit 0 even with diffs — they're expected; this is a REPORT not a hard fail
const rollupTotal = Object.values(bySection).reduce((s, v) => s + v, 0);
// Workbook's own totals (Part B.2)
const WORKBOOK_GRAND_TOTAL = 1110486.87; // Cost sheet row 98
const PLANT_INSTALMENTS = 415523.94; // "Plant Overheads" section (correctly excluded)
const SANTAM_DOUBLED = 5322.44; // Part B.4 — double-counted in workbook subtotal
const EXPECTED_ROLLUP = WORKBOOK_GRAND_TOTAL - PLANT_INSTALMENTS - SANTAM_DOUBLED; // 689,640.49
// Source row sum (what's actually in the workbook, not what's stated)
const SOURCE_ROW_SUM = 415523.94 + 14996.31 + 128330.05 + 392200 + 6159.01 + 28800 + 110000; // 1,096,009.31
const EXPECTED_FROM_ROW_SUM = SOURCE_ROW_SUM - PLANT_INSTALMENTS - 5322.44; // excludes Santam
console.log(`Workbook stated grand total: R ${WORKBOOK_GRAND_TOTAL.toFixed(2)} (Cost sheet row 98)`);
console.log(`Workbook row sum (all sections): R ${SOURCE_ROW_SUM.toFixed(2)} (R 14,477.56 less than stated — workbook internally inconsistent)`);
console.log("");
console.log("Per-section rollup (Plant Overheads + Santam already excluded):");
for (const [s, t] of Object.entries(bySection)) {
console.log(` ${s.padEnd(22)} R ${t.toFixed(2).padStart(12)}`);
}
console.log(` ${"TOTAL".padEnd(22)} R ${rollupTotal.toFixed(2).padStart(12)}`);
console.log("");
console.log(`Expected (from stated grand total): R ${EXPECTED_ROLLUP.toFixed(2)}`);
console.log(`Expected (from row sum): R ${EXPECTED_FROM_ROW_SUM.toFixed(2)}`);
console.log(`Actual: R ${rollupTotal.toFixed(2)}`);
const driftStated = Math.abs(rollupTotal - EXPECTED_ROLLUP);
const driftRows = Math.abs(rollupTotal - EXPECTED_FROM_ROW_SUM);
console.log(`Drift (vs stated): R ${driftStated.toFixed(2)}`);
console.log(`Drift (vs rows): R ${driftRows.toFixed(2)}`);
if (driftRows > 1) {
console.log(`\n✗ Roll-up drift exceeds R 1.00 vs workbook row sum. Investigate.`);
exitCode = 2;
} else {
console.log(`\n✓ Roll-up reconciles to workbook row sum within R 1.00.`);
}
console.log("\n=== Part B.4 — Vehicle Costs subtotal skips Santam ===");
const vehicleRows = (bySection["Vehicle Costs"] || 0);
// The workbook's Vehicle Costs subtotal EXCLUDES Santam (R 5,322.44) because the SUM range starts at row 47 not 46.
// The app's R 9,673.87 = Tracking (5,322.44) + Diesel (1,033.29) + Water (3,318.14).
// If app included Santam, it would be R 14,996.31 (matching workbook's stated Vehicle Costs total).
console.log(`App Vehicle Costs total: R ${vehicleRows.toFixed(2)} (excludes Santam per Part B.4 — correct)`);
console.log(`If Santam were included: R ${(vehicleRows + SANTAM_DOUBLED).toFixed(2)} (matches workbook's stated Vehicle Costs total)`);
console.log("\n=== Known discrepancies — flagged, NOT silently fixed ===");
console.log(" • Personnel: workbook total R 412,000, but R 157,200 + R 235,000 = R 392,200. Diff R 19,800 (workbook's own error).");
console.log(" • Subscriptions: 2 rows have broken VAT (Charl - CCS incl<vat, Buildsmart incl<excl). Seed flagged — needs client fix.");
console.log(" • Workbook grand total R 1,110,486.87 ≠ sum of its own rows (R 1,096,009.31). Diff R 14,477.56.");
console.log(" • Vehicle Costs subtotal excludes Santam. App R 9,673.87 is correct; workbook is wrong.");
process.exit(exitCode);