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