2026-09-12 07:35:35 +00:00
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
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 09:24:18 +00:00
|
|
|
{/* 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)}
|
2026-09-12 07:35:35 +00:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
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 09:24:18 +00:00
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
2026-09-12 07:35:35 +00:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|