mega-plant/components/ExpenseSectionPage.tsx
Mega Plant 7194e78079 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
2026-09-12 11:24:18 +02:00

167 lines
8.3 KiB
TypeScript

import { getOverheadsBySection } from "@/lib/data";
import { ZAR } from "@/lib/utils";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { ReactNode } from "react";
type Props = {
section: string;
title: string;
subtitle: string;
icon: ReactNode;
gradient: string;
iconBg: string;
iconColor: string;
};
export async function ExpenseSectionPage({
section, title, subtitle, icon, gradient, iconBg, iconColor,
}: Props) {
const items = await getOverheadsBySection(section);
const totalIncl = items.reduce((s, o) => s + (o.amount_incl || 0), 0);
const totalExcl = items.reduce((s, o) => s + (o.amount_excl || 0), 0);
const totalVat = items.reduce((s, o) => s + (o.vat || 0), 0);
const byGl = new Map<string, typeof items>();
for (const o of items) {
const g = o.gl_account || "—";
if (!byGl.has(g)) byGl.set(g, []);
byGl.get(g)!.push(o);
}
const glBreakdown = Array.from(byGl.entries())
.map(([gl, list]) => ({
gl,
total: list.reduce((s, o) => s + (o.amount_incl || 0), 0),
count: list.length,
}))
.sort((a, b) => b.total - a.total);
return (
<div className="p-8 max-w-[1400px]">
<Link href="/expenses" className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-[#30638E] dark:hover:text-[#00798C] mb-3">
<ArrowLeft className="w-3 h-3" /> Back to Expenses
</Link>
<div className="flex items-end justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<div className={`w-10 h-10 rounded-xl ${iconBg} grid place-items-center`} style={{ color: iconColor }}>
{icon}
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">{title}</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">{subtitle}</p>
</div>
<div className={`bg-gradient-to-br ${gradient} rounded-2xl px-5 py-3 text-white shadow-lg`}>
<div className="text-xs text-white/80">Monthly total (incl VAT)</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(totalIncl)}</div>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-6">
<StatCard label="Items" value={String(items.length)} color="#30638E" />
<StatCard label="Excl VAT" value={ZAR(totalExcl)} color="#003D5B" />
<StatCard label="VAT" value={ZAR(totalVat)} color="#EDAE49" />
</div>
{glBreakdown.length > 1 && (
<section className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-6 shadow-sm mb-6">
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white mb-4">By GL account</h2>
<div className="space-y-3">
{glBreakdown.map((g) => {
const pct = totalIncl ? (g.total / totalIncl) * 100 : 0;
return (
<div key={g.gl}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-700 dark:text-gray-300 font-medium">{g.gl}</span>
<span className="text-gray-500 dark:text-gray-400">
{ZAR(g.total)} · <span className="text-gray-400 dark:text-gray-500">{g.count} item{g.count !== 1 && "s"}</span> · {pct.toFixed(0)}%
</span>
</div>
<div className="h-2 rounded-full bg-gray-100 dark:bg-white/5 overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${pct}%`, background: iconColor }}
/>
</div>
</div>
);
})}
</div>
</section>
)}
{/* 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>
</tbody>
</table>
</div>
</section>
)}
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-5 shadow-sm">
<div className="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">{label}</div>
<div className="text-2xl font-semibold mt-2 tabular-nums" style={{ color }}>{value}</div>
</div>
);
}