From 802f471047d2e744e52cba225f67fb68d0249ddc Mon Sep 17 00:00:00 2001 From: David Date: Fri, 2 Oct 2026 16:33:43 +0200 Subject: [PATCH] Sites: give every site coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- app/api/sites/[id]/route.ts | 84 +++++++++++++ app/globals.css | 8 ++ app/sites/[id]/page.tsx | 15 +++ app/sites/page.tsx | 19 ++- components/PlantMap.tsx | 125 ++++++++++++++----- components/SiteCoordinateEditor.tsx | 166 ++++++++++++++++++++++++++ lib/data.ts | 2 + package-lock.json | 37 ++++++ package.json | 2 + scripts/setup-invoice-collections.mjs | 2 + 10 files changed, 426 insertions(+), 34 deletions(-) create mode 100644 app/api/sites/[id]/route.ts create mode 100644 components/SiteCoordinateEditor.tsx diff --git a/app/api/sites/[id]/route.ts b/app/api/sites/[id]/route.ts new file mode 100644 index 0000000..5e53680 --- /dev/null +++ b/app/api/sites/[id]/route.ts @@ -0,0 +1,84 @@ +import { revalidatePath } from "next/cache"; +import { serverAuth } from "@/lib/pb"; + +export const dynamic = "force-dynamic"; + +// PATCH /api/sites/:id +// Body: { latitude?, longitude?, notes?, name? } +// +// Pins a site on the map. Coordinates are stored as two plain numbers, matching +// `assets.latitude` / `assets.longitude`, so the same map component can plot either. +// Sending both clears the pin — a half-set coordinate would put a marker in the ocean +// or at 0,0, which reads as "located" when it is not. +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + let body: Record = {}; + try { + body = await req.json(); + } catch { + return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); + } + + const pb = await serverAuth(); + + try { + const site = await pb.collection("sites").getOne(id); + const patch: Record = {}; + + if ("name" in body) { + if (typeof body.name !== "string" || !body.name.trim()) { + return Response.json({ ok: false, error: "Name cannot be empty" }, { status: 400 }); + } + patch.name = body.name.trim(); + } + if ("notes" in body) patch.notes = String(body.notes ?? ""); + + if ("latitude" in body || "longitude" in body) { + const lat = body.latitude === null || body.latitude === "" ? null : Number(body.latitude); + const lng = body.longitude === null || body.longitude === "" ? null : Number(body.longitude); + + if (lat === null || lng === null) { + patch.latitude = 0; + patch.longitude = 0; + } else { + if (!Number.isFinite(lat) || !Number.isFinite(lng)) { + return Response.json({ ok: false, error: "Latitude and longitude must be numbers" }, { status: 400 }); + } + // A pin outside the country is a typo far more often than a real machine abroad. + if (lat < -35.5 || lat > -21.5 || lng < 15.5 || lng > 33.5) { + return Response.json( + { ok: false, error: `That point is outside South Africa (${lat}, ${lng}) — check the numbers` }, + { status: 400 }, + ); + } + patch.latitude = Math.round(lat * 1e6) / 1e6; + patch.longitude = Math.round(lng * 1e6) / 1e6; + } + } + + if (Object.keys(patch).length === 0) { + return Response.json({ ok: false, error: "Nothing to change" }, { status: 400 }); + } + + const updated = await pb.collection("sites").update(id, patch); + revalidatePath("/sites"); + revalidatePath(`/sites/${id}`); + + return Response.json({ + ok: true, + site: { + id: updated.id, + name: updated.name, + latitude: updated.latitude, + longitude: updated.longitude, + }, + located: Boolean(updated.latitude || updated.longitude), + }); + } catch (e: any) { + return Response.json({ ok: false, error: e?.message || "Could not save" }, { status: 500 }); + } +} diff --git a/app/globals.css b/app/globals.css index 1d5e34a..944c915 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,13 @@ @import "tailwindcss"; +/* Leaflet + marker-cluster must be global stylesheets. Importing them inside + components/PlantMap.tsx works for leaflet.css but the cluster stylesheet gets + dropped, because that component is behind a `next/dynamic` ssr:false boundary + whose CSS is not linked in the initial HTML. Without these, cluster bubbles + land unpositioned and unstyled. */ +@import "leaflet/dist/leaflet.css"; +@import "leaflet.markercluster/dist/MarkerCluster.css"; + /* Enable dark mode via .dark class on html */ @custom-variant dark (&:where(.dark, .dark *)); diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 6007b38..c17666a 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -6,6 +6,7 @@ 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"; export const dynamic = "force-dynamic"; @@ -131,6 +132,20 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:
+
+
+

Location

+
+
+ +
+
+

Invoices

diff --git a/app/sites/page.tsx b/app/sites/page.tsx index e816843..a2b621f 100644 --- a/app/sites/page.tsx +++ b/app/sites/page.tsx @@ -3,7 +3,7 @@ import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines } fr import { buildSiteSummaries } from "@/lib/sites"; import { ZAR, ZAR2 } from "@/lib/utils"; import { ScrollFade } from "@/components/ScrollFade"; -import { AlertTriangle, ArrowRight, MapPin } from "lucide-react"; +import { AlertTriangle, ArrowRight, MapPin, MapPinned } from "lucide-react"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Sites — Mega Plant" }; @@ -23,6 +23,7 @@ export default async function SitesPage() { 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 (
@@ -48,6 +49,17 @@ export default async function SitesPage() {
+ {pinned < rows.length && ( +
+ +

+ {rows.length - pinned} of {rows.length} sites have no coordinates yet. Open a site to + pin it — the markers marked{" "} + no pin sit at none. +

+
+ )} + {outstanding > 0 && (
@@ -103,6 +115,11 @@ export default async function SitesPage() { > {r.name} + {sites.find((x) => x.id === r.id)?.latitude ? ( + + ) : ( + no pin + )} diff --git a/components/PlantMap.tsx b/components/PlantMap.tsx index 37c1c29..5a2752a 100644 --- a/components/PlantMap.tsx +++ b/components/PlantMap.tsx @@ -1,9 +1,11 @@ "use client"; -import "leaflet/dist/leaflet.css"; +// Leaflet's and marker-cluster's stylesheets are imported globally in +// app/globals.css — see the comment there for why they must not live here. import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"; +import MarkerClusterGroup from "react-leaflet-cluster"; import L from "leaflet"; import { MapPinned, Loader2, Search, X, MapPin, Trash2 } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -73,6 +75,38 @@ function badgeIcon(label: string, active: boolean) { }); } +/** + * Cluster bubble showing how many machines share a point. Sizing steps up with + * the count so a glance reads magnitude, and the colour walks the brand's own + * ramp — teal for a handful, indigo navy for a full yard. + * + * `fontSize` and `iconSize` are interpolated from the plugin's 0–10 + * `childCount`, which is how markercluster reports cluster density. + */ +function clusterIcon(cluster: L.MarkerCluster) { + const count = cluster.getChildCount(); + const density = Math.min(cluster.getChildCount() / 10, 1); + + const size = Math.round(44 + density * 16); // 44px floor keeps it tappable on a phone + const fill = count < 10 ? "#00798C" : count < 50 ? "#30638E" : "#003D5B"; + const fontSize = count > 999 ? 13 : count > 99 ? 14 : 15; + + return L.divIcon({ + className: "plant-map-cluster", + html: `${count}`, + iconSize: [size, size], + // Anchor on the centre: a cluster represents a point, not a pin tip. + iconAnchor: [size / 2, size / 2], + }); +} + /** Re-centres the map when the caller asks (fit-all, or a list selection). */ function MapController({ focus }: { focus: { lat: number; lng: number; zoom?: number } | null }) { const map = useMap(); @@ -190,39 +224,53 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) { - {located.map((a) => ( - - -
-
{escapeHtml(a.name)}
-
- {escapeHtml(a.code || "no code")} - {a.type ? ` · ${escapeHtml(a.type)}` : ""} + {/* Clustering: machines sharing a point collapse into one numbered + bubble. Clicking a bubble zooms to the machines inside it, and + markers that genuinely overlap at max zoom spiderfy apart so every + machine stays individually reachable. */} + + {located.map((a) => ( + + +
+
{escapeHtml(a.name)}
+
+ {escapeHtml(a.code || "no code")} + {a.type ? ` · ${escapeHtml(a.type)}` : ""} +
+
+ {a.is_active ? "Active" : "Inactive"} +
+
+ {a.latitude?.toFixed(5)}, {a.longitude?.toFixed(5)} +
-
- {a.is_active ? "Active" : "Inactive"} -
-
- {a.latitude?.toFixed(5)}, {a.longitude?.toFixed(5)} -
-
- - - ))} + + + ))} + {/* Placing mode — capture the map click outside React's synthetic tree */} @@ -243,6 +291,17 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) { Inactive + {located.length > 1 && ( + <> + + + + + Cluster — tap to zoom + + + + )}
{/* Nothing located yet */} diff --git a/components/SiteCoordinateEditor.tsx b/components/SiteCoordinateEditor.tsx new file mode 100644 index 0000000..a176da3 --- /dev/null +++ b/components/SiteCoordinateEditor.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, MapPin, Check, X, ExternalLink } from "lucide-react"; + +// Pin a site on the map. +// +// Deliberately plain number inputs plus a "open in maps" link, rather than a +// drag-to-pin map: the in-app Leaflet map is still being worked on, and a coordinate +// entered by hand is usually copied off a map anyway. A point can be pasted straight in. +// +// Bounds are enforced server-side as well as here — a stray 0,0 or a digit dropped in +// the wrong box would otherwise place the marker in the Gulf of Guinea. +export function SiteCoordinateEditor({ + siteId, + name, + latitude, + longitude, +}: { + siteId: string; + name: string; + latitude: number | null; + longitude: number | null; +}) { + const router = useRouter(); + const has = Boolean(latitude || longitude); + const [open, setOpen] = useState(false); + const [lat, setLat] = useState(latitude ? String(latitude) : ""); + const [lng, setLng] = useState(longitude ? String(longitude) : ""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + async function save(clear = false) { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/sites/${siteId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + clear ? { latitude: null, longitude: null } : { latitude: Number(lat), longitude: Number(lng) }, + ), + }); + const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" })); + if (!data.ok) { + setError(data.error || "Could not save"); + } else { + setOpen(false); + setSaved(true); + setTimeout(() => setSaved(false), 2500); + router.refresh(); + } + } catch { + setError("Could not reach the server"); + } finally { + setBusy(false); + } + } + + if (!open) { + return ( +
+ + {has && ( + + Open in maps + + + )} +
+ ); + } + + return ( +
+
+
+ + setLat(e.target.value)} + placeholder="-26.101615" + className="min-h-[44px] w-full px-3 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-sm tabular-nums focus:outline-none focus:border-[#30638E]" + /> +
+
+ + setLng(e.target.value)} + placeholder="28.348725" + className="min-h-[44px] w-full px-3 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-sm tabular-nums focus:outline-none focus:border-[#30638E]" + /> +
+
+ + {error &&

{error}

} + +
+ + + {has && ( + + )} +
+

+ Paste a point copied from any map. Only coordinates inside South Africa are accepted. +

+
+ ); +} \ No newline at end of file diff --git a/lib/data.ts b/lib/data.ts index 62588b4..f5d8822 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -240,6 +240,8 @@ export type Site = { name: string; code: string; notes: string; + latitude: number; + longitude: number; is_active: boolean; } & RecordModel; diff --git a/package-lock.json b/package-lock.json index b88fefd..369e860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,12 +27,14 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.1.3", "recharts": "^2.15.0", "tailwind-merge": "^2.6.0", "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.0", + "@types/leaflet.markercluster": "^1.5.6", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -4379,6 +4381,16 @@ "@types/geojson": "*" } }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", + "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/leaflet": "^1.9" + } + }, "node_modules/@types/node": { "version": "22.20.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", @@ -6647,6 +6659,15 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7488,6 +7509,22 @@ "react-dom": "^19.0.0" } }, + "node_modules/react-leaflet-cluster": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-4.1.3.tgz", + "integrity": "sha512-b3ICS7r9Fs5hBWrFPMVnmkCuDewyG3m1GaGzamXY0eryyAXDfQ+ikSkiR/mVethueIIA5MSyLok2/KsZVotspQ==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "leaflet.markercluster": "^1.5.3" + }, + "peerDependencies": { + "@react-leaflet/core": "^3.0.0", + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-leaflet": "^5.0.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", diff --git a/package.json b/package.json index 69318cd..2dcfe5b 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,14 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.1.3", "recharts": "^2.15.0", "tailwind-merge": "^2.6.0", "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.0", + "@types/leaflet.markercluster": "^1.5.6", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/scripts/setup-invoice-collections.mjs b/scripts/setup-invoice-collections.mjs index 7855cc8..b81baf2 100644 --- a/scripts/setup-invoice-collections.mjs +++ b/scripts/setup-invoice-collections.mjs @@ -138,6 +138,8 @@ const sitesId = await ensureCollection("sites", [ { name: "name", type: "text", required: true }, { name: "code", type: "text" }, { name: "notes", type: "text" }, + { name: "latitude", type: "number" }, + { name: "longitude", type: "number" }, { name: "is_active", type: "bool" }, { name: "created", type: "date", onCreate: true, presentable: false }, { name: "updated", type: "date", onUpdate: true, presentable: false },