mega-plant/app/(dashboard)/sites/[id]/page.tsx
David 6f66dff2a6 Split the invoice register into Internal and External Invoices
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.
2026-10-08 11:49:48 +02:00

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