"use client"; import "leaflet/dist/leaflet.css"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"; 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: '© OpenStreetMap contributors', }, dark: { url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", attribution: '© OpenStreetMap © CARTO', }, } 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: `${escapeHtml(label)}`, iconSize: [34, 34], iconAnchor: [17, 17], popupAnchor: [0, -20], }); } /** 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(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(null); const [saving, setSaving] = useState(false); const [notice, setNotice] = useState(null); // Read the theme class the ThemeProvider puts on . 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 (
{/* ── Map ─────────────────────────────────────────────────────── */}
{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)}
))}
{/* Placing mode — capture the map click outside React's synthetic tree */} {placingId && ( saveCoords(placingId, lat, lng)} disabled={saving} /> )} {/* Legend */}
Active Inactive
{/* Nothing located yet */} {located.length === 0 && !placingId && (

No coordinates yet

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.

)} {notice && (
{notice}
)}
{/* ── Side panel ──────────────────────────────────────────────── */}
); } /** * 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; }