mega-plant/app/page.tsx
Mega Plant 0516220110 Round 4: regressions fixed + mobile standard
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.
2026-09-12 12:05:43 +02:00

190 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}