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 },