Renames the existing /income/invoices page to "Internal Invoices" and adds a sibling /income/external-invoices page, splitting ONE `invoices` collection by the debtor printed on the invoice: a Mega group company is internal, anything else is external. Never two collections. An invoice must not be able to exist on neither page, and the halves must stay reconcilable against /income. Both routes render through the single components/InvoiceRegisterPage.tsx so they cannot drift apart. lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts does the matching. Matching is on debtor name OR debtor_no, never name alone: all three external invoices (PHI 178/186/192) carry an empty debtor name and only a third-party number, so a name-only match would misfile every one of them. An invoice naming neither is never internal - it lands on External and is reported as unclassified rather than silently filed. The split is 16 internal / 3 external across the 19 invoices in the register. Also fixes three things the split would otherwise break: - Booking a line, importing a PDF and retrying an intake can all land on either side depending on the debtor, so the five write paths only revalidated /income/invoices and would have left the other page stale. They now call revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in lib/invoice-routes.ts, which imports no next/* so a client component may import it; the revalidate helper imports next/cache and is server-only. - A site's "Go and assign them" link hard-coded the internal path, so a site whose outstanding lines sit on external invoices would dead-end. SiteSummary now carries the outstanding lines split by party. - verify-auth-routes.mjs now covers the new route. The two routes are siblings by design: isGroupActive matches a child by path prefix, so a nested /income/invoices/external would highlight both leaves at once. Verified: typecheck clean, 41 tests pass (7 new), production build green, anonymous access to both routes 307s to /login, and the mobile checklist passes at 375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no text under 12px. The nested line-item table still scrolls inside its own container without stretching the outer table. Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are someone else's in-flight work and were deliberately left uncommitted here.
276 lines
14 KiB
TypeScript
276 lines
14 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { notFound } from "next/navigation";
|
|
import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines, MONTH_NAMES } from "@/lib/server-data";
|
|
import { buildSiteSummaries } from "@/lib/sites";
|
|
import { INTERNAL_INVOICES_PATH, EXTERNAL_INVOICES_PATH } from "@/lib/invoice-routes";
|
|
import { ZAR2 } from "@/lib/utils";
|
|
import { ScrollFade } from "@/components/ScrollFade";
|
|
import { ArrowLeft, MapPin, FileText } from "lucide-react";
|
|
import { Sparkline } from "@/components/Sparkline";
|
|
import { SiteCoordinateEditor } from "@/components/SiteCoordinateEditor";
|
|
import { SiteContainerEditor } from "@/components/SiteContainerEditor";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
|
const { id } = await params;
|
|
const sites = await getSites();
|
|
const site = sites.find((s) => s.id === id);
|
|
return { title: `${site?.name ?? "Site"} — Mega Plant` };
|
|
}
|
|
|
|
export default async function SiteDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
const { id } = await params;
|
|
const [sites, invoices, lines, assets] = await Promise.all([
|
|
getSites(),
|
|
getInvoices(),
|
|
getInvoiceLines(),
|
|
getAssets(),
|
|
]);
|
|
|
|
const site = sites.find((s) => s.id === id);
|
|
if (!site) notFound();
|
|
|
|
const joined = joinBookedLines(lines, assets);
|
|
const summary = buildSiteSummaries(sites, invoices, joined, assets).find((s) => s.id === id);
|
|
if (!summary) notFound();
|
|
|
|
const invById = new Map(invoices.map((i) => [i.id, i]));
|
|
const assetById = new Map(assets.map((a) => [a.id, a]));
|
|
const siteLines = joined
|
|
.filter((l) => invById.get(l.invoice)?.site === site.name)
|
|
.sort((a, b) => (invById.get(b.invoice)!.invoice_date).localeCompare(invById.get(a.invoice)!.invoice_date) || a.line_no - b.line_no);
|
|
|
|
return (
|
|
<div className="p-4 sm:p-8 max-w-[1400px]">
|
|
<Link
|
|
href="/sites"
|
|
className="inline-flex items-center gap-1.5 min-h-[44px] text-sm text-gray-500 dark:text-gray-400 hover:text-[#00798C] transition"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
All sites
|
|
</Link>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#EDAE49]/20 to-[#00798C]/15 grid place-items-center text-[#00798C]">
|
|
<MapPin className="w-5 h-5" />
|
|
</div>
|
|
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">{site.name}</h1>
|
|
</div>
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
|
{summary.invoices.length} invoice{summary.invoices.length === 1 ? "" : "s"} ·{" "}
|
|
{summary.machineCount} machine{summary.machineCount === 1 ? "" : "s"} ·{" "}
|
|
{summary.lineCount} line{summary.lineCount === 1 ? "" : "s"}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-stretch gap-3">
|
|
<div className="px-5 py-3 rounded-2xl bg-white dark:bg-surface border border-gray-100 dark:border-white/5">
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">Revenue (ex VAT)</div>
|
|
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
|
{ZAR2(summary.revenueExcl)}
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
|
+ {ZAR2(summary.vat)} VAT
|
|
</div>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-[#00798C] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-teal-500/20">
|
|
<div className="text-xs text-white/80">Billed (incl VAT)</div>
|
|
<div className="text-2xl font-semibold tabular-nums">{ZAR2(summary.revenueIncl)}</div>
|
|
</div>
|
|
{/* The same monthly series the site list plots, so the detail page and the
|
|
list can never disagree about what a month was worth. */}
|
|
<div className="px-5 py-3 rounded-2xl bg-white dark:bg-surface border border-gray-100 dark:border-white/5">
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
Billed per month · {summary.year}
|
|
</div>
|
|
<div className="mt-1">
|
|
<Sparkline data={summary.months} color="#00798C" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid lg:grid-cols-3 gap-5">
|
|
<div className="lg:col-span-2">
|
|
<div className="relative bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Line items</h2>
|
|
</div>
|
|
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
|
|
<table className="w-full text-sm sm:min-w-[640px]">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
|
|
<th className="text-left py-3 px-4 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">Machine</th>
|
|
<th className="text-left py-3 px-3 font-medium hidden sm:table-cell">Invoice</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">Qty</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap">Ex VAT</th>
|
|
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">VAT</th>
|
|
<th className="text-right py-3 px-4 font-medium whitespace-nowrap">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{siteLines.map((l) => {
|
|
const inv = invById.get(l.invoice)!;
|
|
const asset = l.booked_asset ? assetById.get(l.booked_asset) : null;
|
|
return (
|
|
<tr key={l.id} className="border-b border-gray-50 dark:border-white/5">
|
|
<td className="py-3 px-4 sticky left-0 z-10 bg-white dark:bg-surface">
|
|
<div className="font-mono text-xs font-semibold text-[#00798C]">{l.machine_code}</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{asset ? `${asset.code} ${asset.name}` : l.description}
|
|
</div>
|
|
</td>
|
|
<td className="hidden sm:table-cell py-3 px-3 font-mono text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
{inv.invoice_no}
|
|
</td>
|
|
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300 whitespace-nowrap">
|
|
{l.qty} {l.unit}
|
|
</td>
|
|
<td className="py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300 whitespace-nowrap">{ZAR2(l.amount_excl)}</td>
|
|
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-500 dark:text-gray-400 whitespace-nowrap">{ZAR2(l.vat)}</td>
|
|
<td className="py-3 px-4 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
|
|
{ZAR2(l.total_incl)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<ScrollFade />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-5">
|
|
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Location</h2>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<SiteCoordinateEditor
|
|
siteId={site.id}
|
|
name={site.name}
|
|
latitude={site.latitude}
|
|
longitude={site.longitude}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Container</h2>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<SiteContainerEditor
|
|
siteId={site.id}
|
|
siteName={site.name}
|
|
code={site.container_code}
|
|
assetId={site.container_asset}
|
|
assets={assets
|
|
.filter((a) => a.is_active !== false)
|
|
.map((a) => ({ id: a.id, label: `${a.code || "—"} · ${a.name}` }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
|
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Invoices</h2>
|
|
</div>
|
|
{/* Grouped by billing month rather than one flat list, so the page reads the
|
|
same way as the list: a month, then the invoices that made it up. */}
|
|
<div className="divide-y divide-gray-100 dark:border-white/5">
|
|
{summary.monthly.map((m) => (
|
|
<div key={m.key} className="px-5 py-3">
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">
|
|
{MONTH_NAMES[m.month - 1]} {m.year}
|
|
</span>
|
|
<span className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white whitespace-nowrap">
|
|
{ZAR2(m.totalIncl)}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-gray-400 dark:text-text-soft tabular-nums">
|
|
ex {ZAR2(m.totalExcl)} + {ZAR2(m.vat)} VAT · {m.invoices.length} invoice
|
|
{m.invoices.length === 1 ? "" : "s"}
|
|
</div>
|
|
<ul className="mt-2 space-y-1">
|
|
{m.invoices.map((inv) => (
|
|
<li key={inv.id} className="flex items-center justify-between gap-2">
|
|
<span className="inline-flex items-center gap-2 min-w-0">
|
|
<FileText className="w-4 h-4 text-[#00798C] shrink-0" />
|
|
<span className="min-w-0">
|
|
<span className="block font-mono text-xs font-semibold text-[#003D5B] dark:text-white">
|
|
{inv.invoiceNo}
|
|
</span>
|
|
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
|
{fmtDate(inv.invoiceDate)} · {fmtRange(inv.periodFrom, inv.periodTo)}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
{inv.hasPdf ? (
|
|
<a
|
|
href={`/api/invoices/${inv.id}/document`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center min-h-[44px] min-w-[44px] px-2 -mr-2 rounded-lg text-xs font-medium text-[#00798C] hover:bg-[#00798C]/10 transition shrink-0"
|
|
>
|
|
PDF
|
|
</a>
|
|
) : (
|
|
<span className="text-xs tabular-nums text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
{ZAR2(inv.totalIncl)}
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{summary.unbookedCount > 0 && (
|
|
<div className="px-5 py-4 rounded-2xl bg-[#D1495B]/5 border border-[#D1495B]/15">
|
|
<h2 className="text-sm font-semibold text-[#D1495B] mb-1">Unbooked</h2>
|
|
<p className="text-sm text-[#D1495B]">
|
|
{summary.unbookedCount} line{summary.unbookedCount === 1 ? "" : "s"} here still need a
|
|
machine or a site — {ZAR2(summary.unbookedAmount)} incl VAT.
|
|
</p>
|
|
<Link
|
|
href={
|
|
// Send the reader to the half of the register that actually holds these
|
|
// lines. Internal wins a tie because it is where every seeded invoice
|
|
// lives today, so this preserves the existing destination.
|
|
summary.unbookedExternalCount > summary.unbookedInternalCount
|
|
? EXTERNAL_INVOICES_PATH
|
|
: INTERNAL_INVOICES_PATH
|
|
}
|
|
className="inline-flex items-center min-h-[44px] text-sm font-medium text-[#D1495B] hover:underline mt-1"
|
|
>
|
|
Go and assign them
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// PocketBase dates arrive as "2026-09-18 00:00:00.000Z". The date part is parsed as
|
|
// UTC so a label can't slip a day backwards in a timezone behind the server.
|
|
function fmtDate(d: string): string {
|
|
if (!d) return "—";
|
|
const dt = new Date(d.length <= 10 ? `${d}T00:00:00Z` : d);
|
|
if (Number.isNaN(dt.getTime())) return String(d).slice(0, 10);
|
|
return `${dt.getUTCDate()} ${MONTH_NAMES[dt.getUTCMonth()]}`;
|
|
}
|
|
|
|
function fmtRange(from: string, to: string): string {
|
|
if (!from || !to) return "—";
|
|
return `${fmtDate(from)} → ${fmtDate(to)}`;
|
|
}
|