155 lines
7.5 KiB
TypeScript
155 lines
7.5 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-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 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-gray-500">{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>
|
|
)}
|
|
|
|
<section className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 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-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
|
|
<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-white/5 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-[#30638E] dark:text-[#00798C] 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-white/5">
|
|
<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-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 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>
|
|
);
|
|
}
|