mega-plant/app/sites/[id]/page.tsx

194 lines
9.9 KiB
TypeScript
Raw Normal View History

Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines } from "@/lib/data";
import { buildSiteSummaries } from "@/lib/sites";
import { ZAR2 } from "@/lib/utils";
import { ScrollFade } from "@/components/ScrollFade";
import { ArrowLeft, MapPin, FileText } from "lucide-react";
import { SiteCoordinateEditor } from "@/components/SiteCoordinateEditor";
Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
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-[#131a21] 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>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-5">
<div className="lg:col-span-2">
<div className="relative bg-white dark:bg-[#131a21] 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-[#131a21]">
<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-[#131a21] 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-[#131a21] 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">
Sites: a real Sites list, with the unbooked lines bookable to a site The container lines (MPLANT04/MPLANT05) have no matching plant item, so they sat unbooked forever with no way to account for them. They can now be booked to a SITE instead. `sites` is a new PocketBase collection derived from the invoices — each distinct invoice site becomes a site, created as invoices are imported. They are never hand-typed, and never deleted: a site can carry notes and hold line bookings, so removing one would orphan that history. /sites lists every site with its revenue ex VAT, its VAT, the total billed, how many invoices and machines it carries, and how many lines are still outstanding. Each row links to a detail page listing the site's invoices and every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a group with Overview and Sites. Booking a line to a site posts NO income — there is no machine to carry it — it only marks the line accounted for, so it stops showing as unbooked. Exactly one of booked_asset / booked_site is ever set, and a line with neither is what surfaces as unbooked everywhere. One container per site, as specified: once a machine-less line has taken a site, that site is withheld from the next one's picker and shown disabled ("already has one") rather than hidden. Free sites sort to the top. The rule is enforced server-side too, so it does not depend on the UI being the only way in — re-posting a taken site is refused with 409. Verified: booking The Hills, then trying again, then taking West Park, and the remaining line sees 8 free and 2 taken. Clearing a booking frees its site. The site picker only appears on lines that need it — no machine, or one already booked to a site — so fully-booked rows keep a single control. Also fixed the Sidebar's group-active check, which derived its prefix from the first child. For a group whose first child is "/" that yields "/" and matched every route, so Dashboard would have been highlighted on every page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Invoices</h2>
</div>
<ul className="divide-y divide-gray-100 dark:border-white/5">
{summary.invoices.map((i) => (
<li key={i.id} className="px-5 py-3 flex items-center justify-between gap-3">
<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">
{i.invoiceNo}
</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
{String(i.invoiceDate).slice(0, 10)}
</span>
</span>
</span>
<span className="text-sm tabular-nums text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(i.totalIncl)}
</span>
</li>
))}
</ul>
</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="/income/invoices"
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>
);
}