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>
194 lines
9.1 KiB
TypeScript
194 lines
9.1 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-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>
|
||
);
|
||
}
|