mega-plant/app/page.tsx
David def147bc73 Income: hide stood-down machines, with an active/inactive filter
The field is `assets.is_active`. All 7 inactive machines have no income at all
— the three generators, two porta camps, AEB Plant Versekering and the Diesel
Truck — so on the income list they were a flat row of zeros and nothing else,
reading as "idle" when they are actually out of the fleet.

They are now hidden by default. The rows are not dropped from the data: the
filter lives in the view, so Active / All / Inactive is a client-side toggle
with no refetch, and each shows its count so a filter that silently hides rows
is obvious. A machine hidden by the filter also collapses if it was expanded.

The YTD income and profit tiles moved into the view so they sum only what is
visible — otherwise the headline would report the whole fleet while the list
below it showed a subset.

/charts is unchanged: it passes no filter and still sees everything.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-03 18:40:32 +02:00

194 lines
9.1 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>
{/* The dots read the same tokens the chart reads. They were hard-coded, so
the Running dot was Brick Red while the Running line is actually the
project's purple — the legend was describing a colour not on screen. */}
<div className="flex items-center gap-4 text-xs">
<LegendDot color="var(--color-chart-income)" label="Income" />
<LegendDot color="var(--color-chart-fixed)" label="Fixed" />
<LegendDot color="var(--color-chart-running)" label="Running" />
<LegendDot color="var(--color-chart-profit)" label="Profit" />
</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-xs 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>
);
}