mega-plant/app/sites/page.tsx
David 802f471047 Sites: give every site coordinates
`assets` already carried latitude/longitude, but `sites` had nowhere to put a
pin — so there was no way to locate a site. Both fields added, matching the
plant collection's shape so the same map component can plot either.

Coordinates are entered as two plain number fields with an "open in maps" link
to verify the point, rather than a drag-to-pin map: the in-app Leaflet map is
still being worked on, and a coordinate is usually copied off some other map
anyway. Clearing removes the pin rather than leaving half a coordinate, which
would plot in the Gulf of Guinea and read as "located".

Bounds are checked server-side, not just in the form: a point outside South Africa
is refused with the numbers echoed back, because a swapped pair or a dropped
digit is far likelier than a machine abroad. 0,0 is rejected by the same rule.

The sites list marks each row as pinned or "no pin", with a banner counting the
gaps, so an unlocated site is visible without opening it.

While checking this, found that all 44 assets carry the SAME coordinate
(-26.1016, 28.3487) — the Plant Map currently stacks every marker on one pin
while reporting "44 located on the map". Left alone, but worth knowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:33:43 +02:00

202 lines
No EOL
11 KiB
TypeScript

import type { Metadata } from "next";
import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines } from "@/lib/data";
import { buildSiteSummaries } from "@/lib/sites";
import { ZAR, ZAR2 } from "@/lib/utils";
import { ScrollFade } from "@/components/ScrollFade";
import { AlertTriangle, ArrowRight, MapPin, MapPinned } from "lucide-react";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Sites — Mega Plant" };
export default async function SitesPage() {
const [sites, invoices, lines, assets] = await Promise.all([
getSites(),
getInvoices(),
getInvoiceLines(),
getAssets(),
]);
const joined = joinBookedLines(lines, assets);
const rows = buildSiteSummaries(sites, invoices, joined, assets);
const totalIncl = rows.reduce((s, r) => s + r.revenueIncl, 0);
const totalExcl = rows.reduce((s, r) => s + r.revenueExcl, 0);
const totalVat = rows.reduce((s, r) => s + r.vat, 0);
const outstanding = rows.reduce((s, r) => s + r.unbookedCount, 0);
const pinned = rows.filter((r) => sites.find((x) => x.id === r.id)?.latitude).length;
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<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">Sites</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{rows.length} site{rows.length === 1 ? "" : "s"} from the {invoices.length} imported
invoice{invoices.length === 1 ? "" : "s"} · revenue and VAT per site
</p>
</div>
<div className="flex flex-wrap items-stretch gap-3">
<Tile label="Revenue (ex VAT)" value={ZAR2(totalExcl)} sub={`+ ${ZAR2(totalVat)} VAT`} />
<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(totalIncl)}</div>
</div>
</div>
</div>
{pinned < rows.length && (
<div className="flex items-start gap-3 px-4 py-3 mb-5 rounded-xl bg-[#EDAE49]/10 border border-[#EDAE49]/20">
<MapPinned className="w-4 h-4 text-[#8a6420] dark:text-[#EDAE49] shrink-0 mt-0.5" />
<p className="text-sm text-[#8a6420] dark:text-[#EDAE49]">
{rows.length - pinned} of {rows.length} sites have no coordinates yet. Open a site to
pin it — the markers marked{" "}
<span className="font-medium">no pin</span> sit at none.
</p>
</div>
)}
{outstanding > 0 && (
<div className="flex items-start gap-3 px-4 py-3 mb-5 rounded-xl bg-[#D1495B]/5 border border-[#D1495B]/15">
<AlertTriangle className="w-4 h-4 text-[#D1495B] shrink-0 mt-0.5" />
<p className="text-sm text-[#D1495B]">
{outstanding} line{outstanding === 1 ? "" : "s"} across these sites still have no
machine and no site. Open an invoice and assign one.
</p>
</div>
)}
{rows.length === 0 ? (
<div className="bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-2xl p-10 text-center">
<p className="text-gray-500 dark:text-gray-400">No sites yet.</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
Sites appear automatically as invoices are imported.
</p>
</div>
) : (
<div className="relative bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full text-sm sm:min-w-[820px]">
<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-3 sm:px-4 font-medium sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur">
Site
</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">
Invoices
</th>
<th className="text-right py-3 px-3 font-medium whitespace-nowrap hidden sm:table-cell">
Machines
</th>
<th className="hidden sm:table-cell text-right py-3 px-3 font-medium whitespace-nowrap">Revenue 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-3 font-medium whitespace-nowrap">Total incl VAT</th>
<th className="text-left py-3 px-2 sm:px-4 font-medium whitespace-nowrap">Unbooked</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.id}
className={`border-b border-gray-50 dark:border-white/5 ${
r.unbookedCount ? "bg-[#D1495B]/[0.04]" : "hover:bg-gray-50/60 dark:hover:bg-white/[0.02]"
}`}
>
<td className="py-3 px-3 sm:px-4 sticky left-0 z-10 bg-white dark:bg-[#131a21]">
<a
href={`/sites/${r.id}`}
className="group inline-flex items-center gap-2 min-h-[44px] font-medium text-[#003D5B] dark:text-white hover:text-[#00798C] transition"
>
<MapPin className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
<span className="whitespace-nowrap">{r.name}</span>
{sites.find((x) => x.id === r.id)?.latitude ? (
<MapPinned className="w-3 h-3 text-[#00798C] shrink-0" aria-label="Has coordinates" />
) : (
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">no pin</span>
)}
<ArrowRight className="w-3.5 h-3.5 opacity-0 group-hover:opacity-100 transition shrink-0" />
</a>
</td>
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300">
{r.invoices.length}
</td>
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums text-gray-600 dark:text-gray-300">
{r.machineCount}
</td>
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR2(r.revenueExcl)}
</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(r.vat)}
</td>
<td className="py-3 px-2 sm:px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
<div>{ZAR2(r.revenueIncl)}</div>
{/* The ex-VAT/VAT split stays visible on mobile, but as two short
stacked lines — "161 367,50 + 24 205,13" on one line is wider
than the total it annotates, which pushed the row back over the
375px viewport. */}
<div className="sm:hidden text-xs font-normal leading-tight text-gray-500 dark:text-gray-400">
<div>ex {ZAR2(r.revenueExcl)}</div>
<div>VAT {ZAR2(r.vat)}</div>
</div>
</td>
<td className="py-3 px-2 sm:px-4 whitespace-nowrap">
{r.unbookedCount > 0 ? (
<span
title={`${r.unbookedCount} line(s) still unbooked`}
className="inline-flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-[#D1495B]/10 text-[#D1495B] text-xs font-semibold"
>
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
{r.unbookedCount}
<span className="hidden sm:inline">unbooked</span>
</span>
) : (
<span className="text-sm text-gray-400 dark:text-gray-500">—</span>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-200 dark:border-white/15 bg-gray-50/80 dark:bg-white/[0.03]">
<td className="py-3 px-4 sticky left-0 z-10 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur text-sm font-semibold text-[#003D5B] dark:text-white">
Total
</td>
<td className="hidden sm:table-cell" />
<td className="hidden sm:table-cell" />
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR(totalExcl)}
</td>
<td className="hidden sm:table-cell py-3 px-3 text-right tabular-nums whitespace-nowrap text-[#003D5B] dark:text-white">
{ZAR(totalVat)}
</td>
<td className="py-3 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white whitespace-nowrap">
{ZAR(totalIncl)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
<ScrollFade />
</div>
)}
</div>
);
}
function Tile({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<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">{label}</div>
<div className="text-2xl font-semibold tabular-nums text-[#003D5B] dark:text-white">{value}</div>
{sub && <div className="text-xs text-gray-500 dark:text-gray-400 tabular-nums">{sub}</div>}
</div>
);
}