Part A — Round 3 regressions: - A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure - A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum) - A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart) - A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00) - A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack' Part B — Mobile standard: - B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses - B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char - B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running - B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container - B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters - B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile - B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance AGENTS.md created with the standing mobile checklist rule.
150 lines
5.1 KiB
TypeScript
150 lines
5.1 KiB
TypeScript
import Link from "next/link";
|
|
import { getOverheads } from "@/lib/data";
|
|
import { ZAR } from "@/lib/utils";
|
|
import {
|
|
Truck, Shield, CreditCard, KeyRound, Users, PackageOpen, Wallet,
|
|
ArrowUpRight, Coins,
|
|
} from "lucide-react";
|
|
|
|
// Reads PocketBase on every request. Without this Next tries to prerender the
|
|
// page at build time, which makes `docker build` depend on the database being
|
|
// reachable and then serves a frozen build-time snapshot to users.
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const expenseSections = [
|
|
{
|
|
key: "Plant Overheads",
|
|
href: "/expenses/plant-overheads",
|
|
icon: Truck,
|
|
color: "#30638E",
|
|
iconColor: "#30638E",
|
|
description: "Insurance, finance & tracking tied to each plant item",
|
|
},
|
|
{
|
|
key: "Insurance",
|
|
href: "/expenses/insurance",
|
|
icon: Shield,
|
|
color: "#EDAE49",
|
|
iconColor: "#EDAE49",
|
|
description: "Santam + AEB + Bastion-insured assets",
|
|
},
|
|
{
|
|
key: "Bastion Finance",
|
|
href: "/expenses/finance",
|
|
icon: CreditCard,
|
|
color: "#00798C",
|
|
iconColor: "#00798C",
|
|
description: "Monthly finance installments (Bastion)",
|
|
},
|
|
{
|
|
key: "Tracking",
|
|
href: "/expenses/tracking",
|
|
icon: KeyRound,
|
|
color: "#003D5B",
|
|
iconColor: "#003D5B",
|
|
description: "Vehicle & asset tracking subscriptions",
|
|
},
|
|
{
|
|
key: "Personnel",
|
|
href: "/expenses/personnel",
|
|
icon: Users,
|
|
color: "#D1495B",
|
|
iconColor: "#D1495B",
|
|
description: "Staff salaries, drivers, operators",
|
|
},
|
|
{
|
|
key: "Subscriptions",
|
|
href: "/expenses/subscriptions",
|
|
icon: PackageOpen,
|
|
color: "#30638E",
|
|
iconColor: "#30638E",
|
|
description: "CCS + Buildsmart licenses",
|
|
},
|
|
{
|
|
key: "Rent & Utilities",
|
|
href: "/expenses/rent",
|
|
icon: Wallet,
|
|
color: "#00798C",
|
|
iconColor: "#00798C",
|
|
description: "Office rent + water & lights",
|
|
},
|
|
{
|
|
key: "Cash Floats",
|
|
href: "/expenses/petty-cash",
|
|
icon: Coins,
|
|
color: "#EDAE49",
|
|
iconColor: "#EDAE49",
|
|
description: "Petty cash + maintenance float",
|
|
},
|
|
];
|
|
|
|
export default async function ExpensesHub() {
|
|
const overheads = await getOverheads();
|
|
|
|
const sectionTotals = expenseSections.map((s) => {
|
|
const items = overheads.filter((o) => o.section === s.key);
|
|
return {
|
|
...s,
|
|
total: items.reduce((sum, o) => sum + (o.amount_incl || 0), 0),
|
|
count: items.length,
|
|
};
|
|
});
|
|
|
|
const grandTotal = sectionTotals.reduce((s, x) => s + x.total, 0);
|
|
|
|
return (
|
|
<div className="p-8 max-w-[1400px]">
|
|
<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 bg-gradient-to-br from-[#EDAE49]/15 to-[#D1495B]/15 grid place-items-center text-[#EDAE49]">
|
|
<Wallet className="w-5 h-5" />
|
|
</div>
|
|
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Expenses</h1>
|
|
</div>
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">Monthly cost breakdown by category · 2026</p>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-orange-500/20">
|
|
<div className="text-xs text-white/80">Total monthly</div>
|
|
<div className="text-2xl font-semibold tabular-nums">{ZAR(grandTotal)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{sectionTotals.map((s) => {
|
|
const Icon = s.icon;
|
|
return (
|
|
<Link
|
|
key={s.key}
|
|
href={s.href}
|
|
className="group relative bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-5 shadow-sm hover:shadow-md transition overflow-hidden"
|
|
>
|
|
<div className="relative">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div
|
|
className="w-10 h-10 rounded-xl grid place-items-center"
|
|
style={{ background: `${s.iconColor}15`, color: s.iconColor }}
|
|
>
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-[#003D5B] dark:group-hover:text-white transition" />
|
|
</div>
|
|
<div className="text-base font-semibold text-[#003D5B] dark:text-white">{s.key}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">{s.description}</div>
|
|
<div className="mt-4 flex items-end justify-between">
|
|
<div>
|
|
<div className="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wider">Monthly</div>
|
|
<div className="text-xl font-semibold tabular-nums" style={{ color: s.iconColor }}>
|
|
{ZAR(s.total)}
|
|
</div>
|
|
</div>
|
|
<div className="text-[10px] text-gray-400 dark:text-gray-500">{s.count} items</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|