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>
167 lines
8.2 KiB
TypeScript
167 lines
8.2 KiB
TypeScript
import { getOverheadsBySection } from "@/lib/data";
|
|
import { ZAR } from "@/lib/utils";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { ReactNode } from "react";
|
|
|
|
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-8 max-w-[1400px]">
|
|
<Link href="/expenses" className="inline-flex 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 items-end justify-between mb-6">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className={`w-10 h-10 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-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 items-center justify-between 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="overflow-x-auto">
|
|
<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="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="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>
|
|
</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>
|
|
);
|
|
}
|