Add PocketBase login, first-sign-in password changes, account settings, and user activation, reactivation and deletion with self-removal guards. Protect application pages and APIs and keep private pages out of PWA caches. Resolve plant locations from site assignments and improve map controls, profit chart views, navigation and mobile table layouts. Validation: production build and TypeScript passed; 28 unit tests passed. Backend access integration tests and mobile verification are documented.
171 lines
8.6 KiB
TypeScript
171 lines
8.6 KiB
TypeScript
import { getOverheadsBySection } from "@/lib/server-data";
|
|
import { ZAR } from "@/lib/utils";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { ReactNode } from "react";
|
|
import { ScrollFade } from "./ScrollFade";
|
|
|
|
type Props = {
|
|
section: string;
|
|
title: string;
|
|
subtitle: string;
|
|
icon: ReactNode;
|
|
gradient: string;
|
|
iconBg: string;
|
|
iconColor: string;
|
|
};
|
|
|
|
export async function ExpenseSectionPage({
|
|
section, title, subtitle, icon, gradient, iconBg, iconColor,
|
|
}: Props) {
|
|
const items = await getOverheadsBySection(section);
|
|
const totalIncl = items.reduce((s, o) => s + (o.amount_incl || 0), 0);
|
|
const totalExcl = items.reduce((s, o) => s + (o.amount_excl || 0), 0);
|
|
const totalVat = items.reduce((s, o) => s + (o.vat || 0), 0);
|
|
|
|
const byGl = new Map<string, typeof items>();
|
|
for (const o of items) {
|
|
const g = o.gl_account || "—";
|
|
if (!byGl.has(g)) byGl.set(g, []);
|
|
byGl.get(g)!.push(o);
|
|
}
|
|
const glBreakdown = Array.from(byGl.entries())
|
|
.map(([gl, list]) => ({
|
|
gl,
|
|
total: list.reduce((s, o) => s + (o.amount_incl || 0), 0),
|
|
count: list.length,
|
|
}))
|
|
.sort((a, b) => b.total - a.total);
|
|
|
|
return (
|
|
<div className="p-4 sm:p-8 max-w-[1400px]">
|
|
<Link href="/expenses" className="inline-flex min-h-11 items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-[#30638E] dark:hover:text-[#00798C] mb-3">
|
|
<ArrowLeft className="w-3 h-3" /> Back to Expenses
|
|
</Link>
|
|
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between mb-6">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className={`w-10 h-10 shrink-0 rounded-xl ${iconBg} grid place-items-center`} style={{ color: iconColor }}>
|
|
{icon}
|
|
</div>
|
|
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">{title}</h1>
|
|
</div>
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">{subtitle}</p>
|
|
</div>
|
|
<div className={`bg-gradient-to-br ${gradient} rounded-2xl px-5 py-3 text-white shadow-lg`}>
|
|
<div className="text-xs text-white/80">Monthly total (incl VAT)</div>
|
|
<div className="text-2xl font-semibold tabular-nums">{ZAR(totalIncl)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
|
<StatCard label="Items" value={String(items.length)} color="#30638E" />
|
|
<StatCard label="Excl VAT" value={ZAR(totalExcl)} color="#003D5B" />
|
|
<StatCard label="VAT" value={ZAR(totalVat)} color="#EDAE49" />
|
|
</div>
|
|
|
|
{glBreakdown.length > 1 && (
|
|
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-6 shadow-sm mb-6">
|
|
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white mb-4">By GL account</h2>
|
|
<div className="space-y-3">
|
|
{glBreakdown.map((g) => {
|
|
const pct = totalIncl ? (g.total / totalIncl) * 100 : 0;
|
|
return (
|
|
<div key={g.gl}>
|
|
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-sm mb-1">
|
|
<span className="text-gray-700 dark:text-gray-300 font-medium">{g.gl}</span>
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
{ZAR(g.total)} · <span className="text-gray-400 dark:text-text-soft">{g.count} item{g.count !== 1 && "s"}</span> · {pct.toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
<div className="h-2 rounded-full bg-gray-100 dark:bg-white/5 overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full"
|
|
style={{ width: `${pct}%`, background: iconColor }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Part B.1: empty state must say so plainly, not render a table of zeros */}
|
|
{items.length === 0 ? (
|
|
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 shadow-sm text-center">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
No <strong className="text-gray-700 dark:text-gray-200">{section}</strong> entries in PocketBase.
|
|
</div>
|
|
<div className="text-xs text-gray-400 dark:text-text-soft mt-1">
|
|
This category either doesn't apply yet or lives elsewhere — see round-3 brief.
|
|
</div>
|
|
</section>
|
|
) : (
|
|
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
|
|
<div className="p-6 pb-3 flex items-end justify-between">
|
|
<h2 className="text-base font-semibold text-[#003D5B] dark:text-white">Line items</h2>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">{items.length} entries</span>
|
|
</div>
|
|
<div className="relative">
|
|
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-y border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
|
|
<th className="sticky left-0 z-10 min-w-[180px] bg-gray-50 dark:bg-surface-muted text-left py-3 px-6 font-medium">Description</th>
|
|
<th className="text-left py-3 px-3 font-medium">GL Account</th>
|
|
<th className="text-left py-3 px-3 font-medium">Supplier</th>
|
|
<th className="text-right py-3 px-3 font-medium">Excl VAT</th>
|
|
<th className="text-right py-3 px-3 font-medium">VAT</th>
|
|
<th className="text-right py-3 px-6 font-medium">Incl VAT</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((o) => (
|
|
<tr key={o.id} className="border-b border-gray-50 dark:border-border hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
|
|
<td className="sticky left-0 z-10 bg-white dark:bg-surface py-3 px-6 font-medium text-[#003D5B] dark:text-white">{o.description}</td>
|
|
<td className="py-3 px-3">
|
|
{o.gl_account ? (
|
|
<span className="text-xs px-2 py-0.5 rounded-md bg-[#30638E]/10 text-accent-ink font-medium">
|
|
{o.gl_account}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-400">—</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-3 text-gray-500 dark:text-gray-400">{o.supplier || "—"}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-700 dark:text-gray-300">{o.amount_excl ? ZAR(o.amount_excl) : "—"}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{o.vat ? ZAR(o.vat) : "—"}</td>
|
|
<td className="py-3 px-6 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">
|
|
{o.amount_incl ? ZAR(o.amount_incl) : "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
<tr className="bg-gradient-to-r from-gray-50 dark:from-white/[0.02] to-white dark:to-transparent border-t-2 border-gray-200 dark:border-border">
|
|
<td colSpan={3} className="py-3 px-6 font-semibold text-right text-gray-700 dark:text-gray-300">Total</td>
|
|
<td className="py-3 px-3 text-right tabular-nums font-semibold text-gray-900 dark:text-white">{ZAR(totalExcl)}</td>
|
|
<td className="py-3 px-3 text-right tabular-nums font-semibold text-gray-900 dark:text-white">{ZAR(totalVat)}</td>
|
|
<td className="py-3 px-6 text-right tabular-nums font-semibold text-base" style={{ color: iconColor }}>
|
|
{ZAR(totalIncl)}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<ScrollFade />
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
|
|
return (
|
|
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm">
|
|
<div className="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">{label}</div>
|
|
<div className="text-2xl font-semibold mt-2 tabular-nums" style={{ color }}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|