mega-plant/app/page.tsx
David dc7024d98a Fix: the mobile table was mislabelled and clipping its own totals
A screenshot at phone width showed an "INVOICE | LINES | TOTAL | STATUS"
header row while the data under LINES was money. The header and its data cell
had drifted apart: the earlier pass hid the Lines <td> but not the matching
<th>, because the loop that added the hide classes only matched text-left
headers and Lines is text-right. So the Total values rendered under a heading
that named a column which no longer existed.

The same screenshot showed the totals clipped — "41,710.50" reading as
"710,50". With only three columns left, the 520px min-width was still forcing
a sideways scroll, and the opaque sticky first column then sat on top of the
left of the money values. The mobile floor is gone, so the three remaining
columns size to their content, and the sticky and status gutters are trimmed
at mobile widths to let them fit 375px.

Below sm the table is now exactly Invoice, Total, Status — and every header
matches a visible cell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 15:34:19 +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-surface rounded-2xl border border-gray-100 dark:border-border 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-surface rounded-2xl border border-gray-100 dark:border-border 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-surface rounded-2xl border border-gray-100 dark:border-border 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>
);
}