`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>
507 lines
21 KiB
TypeScript
507 lines
21 KiB
TypeScript
"use client";
|
||
|
||
// 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";
|
||
|
||
type MapAsset = {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
category: string;
|
||
type: string;
|
||
is_active: boolean;
|
||
latitude: number | null;
|
||
longitude: number | null;
|
||
};
|
||
|
||
/** A machine counts as located only with a real, non-null, non-(0,0) pair. */
|
||
export function hasCoords(a: MapAsset): boolean {
|
||
if (a.latitude == null || a.longitude == null) return false;
|
||
if (a.latitude === 0 && a.longitude === 0) return false;
|
||
return true;
|
||
}
|
||
|
||
// Johannesburg / Ekurhuleni — the client operates around Halfway Gardens,
|
||
// Cosmo City and West Park. Swap this when the fleet spreads nationally.
|
||
const DEFAULT_CENTER: [number, number] = [-26.05, 28.05];
|
||
const DEFAULT_ZOOM = 10;
|
||
|
||
// OSM's standard tile servers have a usage policy that forbids heavy/commercial
|
||
// traffic. This is fine while a handful of staff use the dashboard, but move to
|
||
// a proper tile provider before the site count grows.
|
||
const TILES = {
|
||
light: {
|
||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
},
|
||
dark: {
|
||
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||
},
|
||
} as const;
|
||
|
||
const escapeHtml = (s: string) =>
|
||
s.replace(/[&<>"']/g, (c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string,
|
||
);
|
||
|
||
/**
|
||
* Leaflet's stock markers point at image files that Webpack rewrites, which
|
||
* breaks under Next's bundler. A divIcon with inline markup sidesteps asset
|
||
* resolution entirely and lets the pin carry the machine code and brand colour.
|
||
*/
|
||
function badgeIcon(label: string, active: boolean) {
|
||
const fill = active ? "#00798C" : "#9CA3AF";
|
||
const ring = active ? "rgba(0,121,140,0.28)" : "rgba(156,163,175,0.25)";
|
||
return L.divIcon({
|
||
className: "plant-map-marker",
|
||
html: `<span style="
|
||
display:flex;align-items:center;justify-content:center;
|
||
width:34px;height:34px;border-radius:9999px;
|
||
background:${fill};color:#fff;font-weight:700;font-size:12px;
|
||
letter-spacing:.02em;border:2.5px solid #fff;
|
||
box-shadow:0 0 0 6px ${ring},0 2px 6px rgba(0,0,0,.3);
|
||
">${escapeHtml(label)}</span>`,
|
||
iconSize: [34, 34],
|
||
iconAnchor: [17, 17],
|
||
popupAnchor: [0, -20],
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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: `<span style="
|
||
display:flex;align-items:center;justify-content:center;
|
||
width:${size}px;height:${size}px;border-radius:9999px;
|
||
background:${fill};color:#fff;font-weight:700;font-size:${fontSize}px;
|
||
font-variant-numeric:tabular-nums;
|
||
border:3px solid rgba(255,255,255,.92);
|
||
box-shadow:0 0 0 7px ${fill}33,0 3px 10px rgba(0,0,0,.35);
|
||
">${count}</span>`,
|
||
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();
|
||
useEffect(() => {
|
||
if (!focus) return;
|
||
map.flyTo([focus.lat, focus.lng], focus.zoom ?? Math.max(map.getZoom(), 15), {
|
||
duration: 0.8,
|
||
});
|
||
}, [focus, map]);
|
||
return null;
|
||
}
|
||
|
||
export function PlantMap({ assets }: { assets: MapAsset[] }) {
|
||
const [assetsState, setAssets] = useState<MapAsset[]>(assets);
|
||
const [query, setQuery] = useState("");
|
||
const [showInactive, setShowInactive] = useState(true);
|
||
const [focus, setFocus] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
|
||
const [dark, setDark] = useState(false);
|
||
const [placingId, setPlacingId] = useState<string | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
|
||
// Read the theme class the ThemeProvider puts on <html>. Leaflet needs the
|
||
// right tile set, and this is cheaper than threading context through here.
|
||
useEffect(() => {
|
||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||
const read = () => setDark(document.documentElement.classList.contains("dark"));
|
||
read();
|
||
const obs = new MutationObserver(read);
|
||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||
mq.addEventListener("change", read);
|
||
return () => {
|
||
obs.disconnect();
|
||
mq.removeEventListener("change", read);
|
||
};
|
||
}, []);
|
||
|
||
const located = useMemo(() => assetsState.filter(hasCoords), [assetsState]);
|
||
const awaiting = useMemo(() => assetsState.filter((a) => !hasCoords(a)), [assetsState]);
|
||
|
||
const visible = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
return assetsState
|
||
.filter((a) => (showInactive ? true : a.is_active))
|
||
.filter(
|
||
(a) =>
|
||
!q ||
|
||
a.code.toLowerCase().includes(q) ||
|
||
a.name.toLowerCase().includes(q) ||
|
||
a.type.toLowerCase().includes(q),
|
||
);
|
||
}, [assetsState, query, showInactive]);
|
||
|
||
const saveCoords = useCallback(async (id: string, lat: number, lng: number) => {
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch(`/api/assets/${id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ latitude: lat, longitude: lng }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.ok) throw new Error(data.error || "Could not save location");
|
||
setAssets((curr) =>
|
||
curr.map((a) => (a.id === id ? { ...a, latitude: lat, longitude: lng } : a)),
|
||
);
|
||
setNotice("Location saved");
|
||
} catch (e: any) {
|
||
setNotice(e?.message || "Could not save location");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, []);
|
||
|
||
const clearCoords = useCallback(async (id: string) => {
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch(`/api/assets/${id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ latitude: null, longitude: null }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok || !data.ok) throw new Error(data.error || "Could not clear location");
|
||
setAssets((curr) =>
|
||
curr.map((a) => (a.id === id ? { ...a, latitude: null, longitude: null } : a)),
|
||
);
|
||
setNotice("Location cleared");
|
||
} catch (e: any) {
|
||
setNotice(e?.message || "Could not clear location");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!notice) return;
|
||
const t = window.setTimeout(() => setNotice(null), 2500);
|
||
return () => window.clearTimeout(t);
|
||
}, [notice]);
|
||
|
||
const tiles = dark ? TILES.dark : TILES.light;
|
||
const placing = placingId ? assetsState.find((a) => a.id === placingId) : null;
|
||
|
||
return (
|
||
<div className="grid lg:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start">
|
||
{/* ── Map ─────────────────────────────────────────────────────── */}
|
||
<div className="relative rounded-2xl overflow-hidden border border-gray-200 dark:border-white/5 shadow-sm">
|
||
<MapContainer
|
||
center={DEFAULT_CENTER}
|
||
zoom={DEFAULT_ZOOM}
|
||
scrollWheelZoom
|
||
className={cn("w-full", "h-[52vh] min-h-[320px]", "lg:h-[calc(100vh-15rem)] lg:min-h-[560px]")}
|
||
>
|
||
<TileLayer url={tiles.url} attribution={tiles.attribution} />
|
||
<MapController focus={focus} />
|
||
|
||
{/* 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. */}
|
||
<MarkerClusterGroup
|
||
iconCreateFunction={clusterIcon}
|
||
chunkedLoading
|
||
showCoverageOnHover={false}
|
||
zoomToBoundsOnClick
|
||
spiderfyOnMaxZoom
|
||
maxClusterRadius={70}
|
||
removeOutsideVisibleBounds
|
||
>
|
||
{located.map((a) => (
|
||
<Marker
|
||
key={a.id}
|
||
position={[a.latitude as number, a.longitude as number]}
|
||
icon={badgeIcon(a.code || "?", a.is_active)}
|
||
>
|
||
<Popup>
|
||
<div style={{ minWidth: "180px" }}>
|
||
<div style={{ fontWeight: 700, marginBottom: 2 }}>{escapeHtml(a.name)}</div>
|
||
<div style={{ color: "#6b7280", fontSize: 12, marginBottom: 6 }}>
|
||
{escapeHtml(a.code || "no code")}
|
||
{a.type ? ` · ${escapeHtml(a.type)}` : ""}
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "inline-block",
|
||
fontSize: 12,
|
||
padding: "2px 8px",
|
||
borderRadius: 999,
|
||
background: a.is_active ? "rgba(0,121,140,.12)" : "rgba(156,163,175,.18)",
|
||
color: a.is_active ? "#00798C" : "#6b7280",
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{a.is_active ? "Active" : "Inactive"}
|
||
</div>
|
||
<div style={{ color: "#6b7280", fontSize: 12, marginTop: 6 }}>
|
||
{a.latitude?.toFixed(5)}, {a.longitude?.toFixed(5)}
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
))}
|
||
</MarkerClusterGroup>
|
||
</MapContainer>
|
||
|
||
{/* Placing mode — capture the map click outside React's synthetic tree */}
|
||
{placingId && (
|
||
<MapClickCapture
|
||
onPick={(lat, lng) => saveCoords(placingId, lat, lng)}
|
||
disabled={saving}
|
||
/>
|
||
)}
|
||
|
||
{/* Legend */}
|
||
<div className="absolute left-3 bottom-3 z-[500] flex flex-col gap-1.5 rounded-xl bg-white/95 dark:bg-[#1e2732]/95 backdrop-blur border border-gray-200 dark:border-white/10 px-2.5 py-2 text-xs">
|
||
<span className="flex items-center gap-2">
|
||
<span className="w-3 h-3 rounded-full bg-[#00798C] border-2 border-white shadow" />
|
||
<span className="text-gray-600 dark:text-gray-300">Active</span>
|
||
</span>
|
||
<span className="flex items-center gap-2">
|
||
<span className="w-3 h-3 rounded-full bg-gray-400 border-2 border-white shadow" />
|
||
<span className="text-gray-600 dark:text-gray-300">Inactive</span>
|
||
</span>
|
||
{located.length > 1 && (
|
||
<>
|
||
<span className="h-px bg-gray-200 dark:bg-white/10 my-0.5" />
|
||
<span className="flex items-center gap-2">
|
||
<span className="w-3 h-3 rounded-full bg-[#30638E] border-2 border-white shadow" />
|
||
<span className="text-gray-600 dark:text-gray-300">
|
||
Cluster — tap to zoom
|
||
</span>
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Nothing located yet */}
|
||
{located.length === 0 && !placingId && (
|
||
<div className="absolute inset-0 z-[600] grid place-items-center p-4 bg-white/80 dark:bg-[#0f151b]/80 backdrop-blur-sm">
|
||
<div className="text-center max-w-sm">
|
||
<div className="w-12 h-12 mx-auto rounded-2xl bg-[#30638E]/10 grid place-items-center mb-3">
|
||
<MapPinned className="w-6 h-6 text-[#30638E]" />
|
||
</div>
|
||
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white mb-1">
|
||
No coordinates yet
|
||
</h3>
|
||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||
All {awaiting.length} machines are waiting on the on-site scan sheet. Until it
|
||
lands, drop a pin manually below to start building the map.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{notice && (
|
||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[700] px-3 py-2 rounded-xl bg-[#003D5B] text-white text-xs font-medium shadow-lg">
|
||
{notice}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Side panel ──────────────────────────────────────────────── */}
|
||
<aside className="rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-[#131a21] shadow-sm overflow-hidden">
|
||
<div className="p-4 border-b border-gray-100 dark:border-white/5">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Machines</h2>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||
{located.length} located · {awaiting.length} awaiting
|
||
</span>
|
||
</div>
|
||
|
||
<div className="relative mb-3">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Filter code, name, type…"
|
||
className="w-full min-h-[44px] pl-9 pr-3 rounded-lg text-sm bg-gray-50 dark:bg-white/5 border border-transparent focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowInactive((v) => !v)}
|
||
aria-pressed={showInactive}
|
||
className={cn(
|
||
"flex-1 inline-flex items-center justify-center gap-1.5 min-h-[44px] px-3 rounded-lg text-xs font-medium transition",
|
||
showInactive
|
||
? "bg-[#003D5B] text-white"
|
||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5",
|
||
)}
|
||
>
|
||
{showInactive ? "All machines" : "Active only"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setPlacingId((cur) => (cur ? null : (awaiting[0]?.id ?? located[0]?.id ?? null)));
|
||
setNotice(null);
|
||
}}
|
||
className={cn(
|
||
"inline-flex items-center justify-center gap-1.5 min-h-[44px] px-3 rounded-lg text-xs font-semibold transition",
|
||
placingId
|
||
? "bg-[#EDAE49] text-[#003D5B] hover:bg-[#E5A340]"
|
||
: "bg-[#30638E] text-white hover:bg-[#003D5B]",
|
||
)}
|
||
>
|
||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <MapPin className="w-4 h-4" />}
|
||
{placingId ? "Placing…" : "Set location"}
|
||
</button>
|
||
</div>
|
||
|
||
{placingId && (
|
||
<p className="mt-2.5 text-xs text-[#EDAE49] font-medium leading-relaxed">
|
||
Tap the map to place{" "}
|
||
<span className="font-bold">{placing?.code || placing?.name}</span>, or pick another
|
||
machine below.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="max-h-[52vh] lg:max-h-[calc(100vh-25rem)] overflow-y-auto">
|
||
{visible.length === 0 ? (
|
||
<p className="p-4 text-sm text-gray-500 dark:text-gray-400">No machines match.</p>
|
||
) : (
|
||
<ul className="divide-y divide-gray-100 dark:divide-white/5">
|
||
{visible.map((a) => {
|
||
const on = hasCoords(a);
|
||
const isPlacing = placingId === a.id;
|
||
return (
|
||
<li
|
||
key={a.id}
|
||
className={cn(
|
||
"flex items-center gap-2 p-2 transition",
|
||
isPlacing ? "bg-[#EDAE49]/10" : "hover:bg-gray-50 dark:hover:bg-white/[0.02]",
|
||
!a.is_active && !isPlacing && "opacity-60",
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
disabled={!on}
|
||
onClick={() => setFocus({ lat: a.latitude as number, lng: a.longitude as number })}
|
||
className="flex-1 min-w-0 min-h-[44px] text-left disabled:cursor-default"
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
"font-mono text-xs px-1.5 py-0.5 rounded font-semibold shrink-0",
|
||
a.is_active
|
||
? "bg-[#30638E]/10 text-[#30638E]"
|
||
: "bg-gray-100 dark:bg-white/5 text-gray-500",
|
||
)}
|
||
>
|
||
{a.code || "—"}
|
||
</span>
|
||
<span className="truncate text-sm text-[#003D5B] dark:text-white">
|
||
{a.name}
|
||
</span>
|
||
</span>
|
||
<span className="block mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||
{on ? (
|
||
<span className="text-[#00798C] font-medium">
|
||
{a.latitude?.toFixed(4)}, {a.longitude?.toFixed(4)}
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-400 dark:text-gray-500">
|
||
No coordinates yet
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setPlacingId(isPlacing ? null : a.id)}
|
||
aria-label={`Place ${a.name} on the map`}
|
||
className={cn(
|
||
"inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg transition shrink-0",
|
||
isPlacing
|
||
? "bg-[#EDAE49] text-[#003D5B]"
|
||
: "text-[#30638E] hover:bg-[#30638E]/10",
|
||
)}
|
||
>
|
||
{isPlacing ? <X className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
|
||
</button>
|
||
|
||
{on && (
|
||
<button
|
||
type="button"
|
||
onClick={() => clearCoords(a.id)}
|
||
disabled={saving}
|
||
aria-label={`Clear location for ${a.name}`}
|
||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-[#D1495B] hover:bg-[#D1495B]/10 transition shrink-0 disabled:opacity-40"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Leaflet's own click handler is managed by react-leaflet's internals, so a
|
||
* tap while "placing" needs its own listener on the container. Registered
|
||
* imperatively and removed on unmount.
|
||
*/
|
||
function MapClickCapture({
|
||
onPick,
|
||
disabled,
|
||
}: {
|
||
onPick: (lat: number, lng: number) => void;
|
||
disabled: boolean;
|
||
}) {
|
||
const mapRef = useMap();
|
||
const handlerRef = useRef(onPick);
|
||
handlerRef.current = onPick;
|
||
|
||
useEffect(() => {
|
||
if (disabled) return;
|
||
const map = mapRef;
|
||
const onClick = (e: L.LeafletMouseEvent) => handlerRef.current(e.latlng.lat, e.latlng.lng);
|
||
map.on("click", onClick);
|
||
return () => {
|
||
map.off("click", onClick);
|
||
};
|
||
}, [mapRef, disabled]);
|
||
|
||
return null;
|
||
}
|