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.
190 lines
8.7 KiB
TypeScript
190 lines
8.7 KiB
TypeScript
import { getAssets, getMonthlyIncome, getActualMonthlyIncome, getRunningCosts, getOverheads, lastActualMonth, MONTH_NAMES, type Asset, type MonthlyIncome, type MonthlyRunningCost, type Overhead } from "@/lib/data";
|
||
import { computeProfit } from "@/lib/profit";
|
||
import { ZAR } from "@/lib/utils";
|
||
import { Truck, Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
||
import { IncomeAreaChart, IncomeBarChart, OverheadDonut, PIE_COLORS } from "@/components/DashboardCharts";
|
||
|
||
// 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";
|
||
|
||
export default async function DashboardPage() {
|
||
// Part 1.2: act on actuals only for YTD aggregations.
|
||
const [assets, incomeActual, incomeAll, running, overheads] = await Promise.all([
|
||
getAssets(),
|
||
getActualMonthlyIncome(),
|
||
getMonthlyIncome(),
|
||
getRunningCosts(),
|
||
getOverheads(),
|
||
]);
|
||
|
||
const totalPlantCount = assets.filter((a: Asset) => a.category === "Plant" && a.code).length;
|
||
const totalPurchaseValue = assets.reduce((s: number, a: Asset) => s + (a.purchase_value || 0), 0);
|
||
const totalMonthlyIncl = overheads.reduce((s: number, o: Overhead) => s + (o.amount_incl || 0), 0);
|
||
|
||
// Actual-only monthly rollup (Jan..lastActualMonth)
|
||
const lastActual = lastActualMonth(incomeAll) || lastActualMonth(incomeActual);
|
||
const monthsIncome = MONTH_NAMES.map((_: string, idx: number) => {
|
||
const m = idx + 1;
|
||
const isForecast = m > lastActual;
|
||
const ms = incomeActual.filter((r: MonthlyIncome) => r.month === m);
|
||
const rm = running.filter((r: MonthlyRunningCost) => r.month === m);
|
||
return {
|
||
month: MONTH_NAMES[idx],
|
||
income: ms.reduce((s: number, r: MonthlyIncome) => s + r.income, 0),
|
||
fixed: ms.reduce((s: number, r: MonthlyIncome) => s + r.fixed_expenses, 0),
|
||
running: rm.reduce((s: number, r: MonthlyRunningCost) => s + r.amount, 0),
|
||
isForecast,
|
||
};
|
||
});
|
||
|
||
const sections = Array.from(new Set(overheads.map((o: Overhead) => o.section)));
|
||
const sectionTotals: { name: string; value: number }[] = sections.map((s: string) => ({
|
||
name: s,
|
||
value: overheads.filter((o: Overhead) => o.section === s).reduce((sum: number, o: Overhead) => sum + (o.amount_incl || 0), 0),
|
||
}));
|
||
|
||
// Actuals-only YTD — Part 1.2
|
||
const ytdIncome = monthsIncome.filter((m) => !m.isForecast).reduce((s, m) => s + m.income, 0);
|
||
const ytdFixed = monthsIncome.filter((m) => !m.isForecast).reduce((s, m) => s + m.fixed, 0);
|
||
const ytdRunning = monthsIncome.filter((m) => !m.isForecast).reduce((s, m) => s + m.running, 0);
|
||
const ytdProfit = computeProfit({ income: ytdIncome, fixed: ytdFixed, running: ytdRunning });
|
||
const periodLabel = lastActual > 0 ? `Jan–${MONTH_NAMES[lastActual - 1]} actuals` : "no data";
|
||
|
||
return (
|
||
<div className="p-8 max-w-[1400px]">
|
||
<div className="flex items-end justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Dashboard</h1>
|
||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">Year-to-date plant P&L overview · 2026</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* KPI cards — Coolors palette */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||
<GradientCard
|
||
label="Plant items"
|
||
value={String(totalPlantCount)}
|
||
sub={`${assets.length} total records`}
|
||
pct={68}
|
||
icon={<Truck className="w-5 h-5" />}
|
||
gradient="from-[#30638E] to-[#003D5B]"
|
||
/>
|
||
<GradientCard
|
||
label="Fleet purchase value"
|
||
value={ZAR(totalPurchaseValue)}
|
||
sub="excl VAT"
|
||
pct={82}
|
||
icon={<Wallet className="w-5 h-5" />}
|
||
gradient="from-[#EDAE49] to-[#D1495B]"
|
||
/>
|
||
<GradientCard
|
||
label="YTD Income"
|
||
value={ZAR(ytdIncome)}
|
||
sub={periodLabel}
|
||
pct={92}
|
||
icon={<TrendingUp className="w-5 h-5" />}
|
||
gradient="from-[#00798C] to-[#003D5B]"
|
||
/>
|
||
<GradientCard
|
||
label="YTD Profit"
|
||
value={ZAR(ytdProfit)}
|
||
sub={`${periodLabel} · ${ZAR(ytdRunning)} running`}
|
||
pct={Math.max(0, 100 - Math.min(100, (Math.abs(ytdProfit) / Math.max(1, ytdIncome)) * 100)) | 0}
|
||
icon={<TrendingDown className="w-5 h-5" />}
|
||
gradient="from-[#D1495B] to-[#003D5B]"
|
||
negative={ytdProfit < 0}
|
||
/>
|
||
</div>
|
||
|
||
{/* Main charts */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
|
||
<section className="lg:col-span-2 bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-6 shadow-sm">
|
||
<div className="flex items-end justify-between mb-5">
|
||
<div>
|
||
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Income vs Fixed vs Running</h2>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">2026 monthly trend · R</p>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-xs">
|
||
<LegendDot color="#00798C" label="Income" />
|
||
<LegendDot color="#EDAE49" label="Fixed" />
|
||
<LegendDot color="#D1495B" label="Running" />
|
||
</div>
|
||
</div>
|
||
<IncomeAreaChart data={monthsIncome} />
|
||
</section>
|
||
|
||
<section className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-6 shadow-sm">
|
||
<div className="mb-4">
|
||
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Overhead mix</h2>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">By section · {ZAR(totalMonthlyIncl)} total</p>
|
||
</div>
|
||
<OverheadDonut data={sectionTotals} />
|
||
<div className="space-y-1.5 mt-3">
|
||
{sectionTotals.slice(0, 5).map((s, i) => (
|
||
<div key={s.name} className="flex items-center justify-between text-xs">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<div className="w-2 h-2 rounded-sm shrink-0" style={{ background: PIE_COLORS[i % PIE_COLORS.length] }} />
|
||
<span className="text-gray-600 dark:text-gray-400 truncate">{s.name}</span>
|
||
</div>
|
||
<span className="text-gray-900 dark:text-white font-medium shrink-0 ml-2">{ZAR(s.value)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{/* Bottom row */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||
<section className="lg:col-span-2 bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 p-6 shadow-sm">
|
||
<div className="mb-5">
|
||
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Monthly Income trend</h2>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Project income per month</p>
|
||
</div>
|
||
<IncomeBarChart data={monthsIncome} />
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GradientCard({
|
||
label, value, sub, pct, icon, gradient, negative,
|
||
}: {
|
||
label: string; value: string; sub: string; pct: number;
|
||
icon: React.ReactNode; gradient: string; negative?: boolean;
|
||
}) {
|
||
return (
|
||
<div className={`relative rounded-2xl bg-gradient-to-br ${gradient} p-5 text-white overflow-hidden shadow-lg shadow-black/5`}>
|
||
<div className="absolute -top-6 -right-6 w-32 h-32 rounded-full bg-white/10 blur-2xl" />
|
||
<div className="flex items-start justify-between relative">
|
||
<div className="text-xs uppercase tracking-wider text-white/80">{label}</div>
|
||
<div className="w-9 h-9 rounded-xl bg-white/15 grid place-items-center">{icon}</div>
|
||
</div>
|
||
<div className="text-3xl font-semibold mt-3 tabular-nums relative">{value}</div>
|
||
<div className="text-xs text-white/80 mt-1 relative">{sub}</div>
|
||
<div className="mt-4 relative">
|
||
<div className="h-1.5 rounded-full bg-white/20 overflow-hidden">
|
||
<div
|
||
className="h-full bg-white/90 rounded-full"
|
||
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between mt-2 text-[10px] text-white/80">
|
||
<span>{negative ? "Deficit" : "On target"}</span>
|
||
<span>{pct}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LegendDot({ 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>
|
||
);
|
||
}
|