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:
parent
b70fc8c5c7
commit
7194e78079
18 changed files with 748 additions and 653 deletions
278
HANDOFF-PROMPT-3.md
Normal file
278
HANDOFF-PROMPT-3.md
Normal 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**
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
app/expenses/vehicles/page.tsx
Normal file
16
app/expenses/vehicles/page.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" /> },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]"}`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
30
lib/rows.ts
30
lib/rows.ts
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue