/plant and /plant/map import Fuel Logs CSVs by exact QR and show each vehicle's current and previous distinct site with the dates they were observed. - plant_site_observations is durable, QR-keyed history. It is never wiped and a previous site is never manufactured. - An older or repeated snapshot must not move a vehicle backwards; untimed conflicting sites on one day are held for review. - A manual site change shares the same history and the same lock. - Current/previous metadata and the sticker QR are captured BEFORE the asset rows are deleted in the seed, so a re-seed cannot wipe them. Explicit empty values are preserved too: clearing a site is a deliberate manual observation, not permission to restore an old sheet value. - Imports change location metadata only. They never touch income, cost or fuel measurements. - A new named site is created only for a matched Plant vehicle. Missing coordinates mean "awaiting a pin", never a headquarters fallback. Owner-confirmed 2026-11-04: TLB06's QR is 1089, not the old meter reading 4704. lib/plant-site-policy.json holds the confirmed aliases (Equestria Bastion Job -> Equestria 215, Cosmo City / Lion Park -> Cosmo City); no other identity is guessed.
535 lines
26 KiB
TypeScript
535 lines
26 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 { VehicleSiteHistory } from "./VehicleSiteHistory";
|
||
import { SiteCoordinateEditor } from "./SiteCoordinateEditor";
|
||
import { cn } from "@/lib/utils";
|
||
import { HEAD_OFFICE, hasCoords, resolveAssetLocation, type MapAsset, type MapSite } from "@/lib/plant-map";
|
||
|
||
// Detailed street tiles in both themes. Esri's gray canvas returns placeholder
|
||
// images above zoom 16 here, even though its metadata lists higher levels.
|
||
const STREET_TILES = {
|
||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
};
|
||
|
||
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;width:44px;height:44px;align-items:center;justify-content:center"><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></span>`,
|
||
iconSize: [44, 44],
|
||
iconAnchor: [22, 22],
|
||
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],
|
||
});
|
||
}
|
||
|
||
function waypointIcon(office = false) {
|
||
return L.divIcon({
|
||
className: "plant-map-waypoint",
|
||
html: `<span style="display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:12px;background:${office ? "#30638E" : "#EDAE49"};color:${office ? "#fff" : "#003D5B"};border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.35)"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span>`,
|
||
iconSize: [44, 44],
|
||
// Keep the waypoint above machines clustered at the same coordinates.
|
||
iconAnchor: [22, 68],
|
||
popupAnchor: [0, -68],
|
||
});
|
||
}
|
||
|
||
function MapController({ focus, points, fitKey, onPopupChange }: {
|
||
focus: { lat: number; lng: number; zoom?: number } | null;
|
||
points: [number, number][];
|
||
fitKey: number;
|
||
onPopupChange: (open: boolean) => void;
|
||
}) {
|
||
const map = useMap();
|
||
useEffect(() => {
|
||
const opened = () => onPopupChange(true);
|
||
const closed = () => onPopupChange(false);
|
||
map.on("popupopen", opened); map.on("popupclose", closed);
|
||
return () => { map.off("popupopen", opened); map.off("popupclose", closed); };
|
||
}, [map, onPopupChange]);
|
||
useEffect(() => {
|
||
map.fitBounds(L.latLngBounds(points), { padding: [50, 70], maxZoom: 14 });
|
||
}, [map, points, fitKey]);
|
||
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, sites }: { assets: MapAsset[]; sites: MapSite[] }) {
|
||
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 [fitKey, setFitKey] = useState(0);
|
||
const [popupOpen, setPopupOpen] = useState(false);
|
||
const [placingId, setPlacingId] = useState<string | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
|
||
useEffect(() => setAssets(assets), [assets]);
|
||
const mappedAssets = useMemo(() => assetsState.map(a => ({ ...a, ...resolveAssetLocation(a, sites) })), [assetsState, sites]);
|
||
const located = useMemo(() => mappedAssets.filter(hasCoords), [mappedAssets]);
|
||
const awaiting = useMemo(() => mappedAssets.filter(a => !hasCoords(a)), [mappedAssets]);
|
||
const pinnedSites = useMemo(() => sites.filter(hasCoords), [sites]);
|
||
const unpinnedSites = useMemo(() => sites.filter(site => !hasCoords(site)), [sites]);
|
||
const points = useMemo<[number, number][]>(() => [
|
||
[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude],
|
||
...pinnedSites.map(s => [s.latitude, s.longitude] as [number, number]),
|
||
...located.map(a => [a.latitude!, a.longitude!] as [number, number]),
|
||
], [pinnedSites, located]);
|
||
|
||
const visible = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
return mappedAssets
|
||
.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) ||
|
||
a.siteName?.toLowerCase().includes(q) ||
|
||
a.qr_code?.toLowerCase().includes(q),
|
||
);
|
||
}, [mappedAssets, 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, ...(data.asset || {}) } : a)),
|
||
);
|
||
setPlacingId(null);
|
||
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, ...(data.asset || {}) } : 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 placing = placingId ? assetsState.find((a) => a.id === placingId) : null;
|
||
|
||
return (
|
||
<div className="grid min-w-0 lg:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start">
|
||
{/* ── Map ─────────────────────────────────────────────────────── */}
|
||
<div className="relative min-w-0 rounded-2xl overflow-hidden border border-gray-200 dark:border-white/5 shadow-sm">
|
||
<MapContainer
|
||
center={[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude]}
|
||
zoom={10}
|
||
maxZoom={19}
|
||
scrollWheelZoom
|
||
className={cn("w-full", "h-[52vh] min-h-[320px]", "lg:h-[calc(100vh-15rem)] lg:min-h-[560px]")}
|
||
>
|
||
<TileLayer url={STREET_TILES.url} attribution={STREET_TILES.attribution} maxNativeZoom={19} maxZoom={19} />
|
||
<MapController focus={focus} points={points} fitKey={fitKey} onPopupChange={setPopupOpen} />
|
||
{placingId && <MapClickCapture onPick={(lat, lng) => saveCoords(placingId, lat, lng)} disabled={saving} />}
|
||
<Marker position={[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude]} icon={waypointIcon(true)} title="Head office" zIndexOffset={1000}>
|
||
<Popup><strong>Head office</strong><p>Default location for unassigned machines.</p></Popup>
|
||
</Marker>
|
||
{pinnedSites.map(site => (
|
||
<Marker key={site.id} position={[site.latitude, site.longitude]} icon={waypointIcon()} title={site.name} zIndexOffset={1000}>
|
||
<Popup>
|
||
<strong>{site.name}</strong>
|
||
<p>{assetsState.filter(a => a.site === site.id).length} machines assigned · Site location</p>
|
||
<a href={`/sites/${site.id}`}>View site details</a>
|
||
</Popup>
|
||
</Marker>
|
||
))}
|
||
|
||
{/* 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
|
||
>
|
||
{visible.filter(hasCoords).map((a) => (
|
||
<Marker
|
||
key={a.id}
|
||
position={[a.latitude as number, a.longitude as number]}
|
||
icon={badgeIcon(a.code || "?", a.is_active)}
|
||
title={`${a.code || a.name} · ${a.siteName || (a.source === "head-office" ? "Head office" : "Manual pin")}`}
|
||
>
|
||
<Popup>
|
||
<div style={{ minWidth: "180px" }}>
|
||
<div style={{ fontWeight: 700, marginBottom: 2 }}>{a.name}</div>
|
||
<div style={{ color: "#6b7280", fontSize: 12, marginBottom: 6 }}>
|
||
{a.code || "no code"}
|
||
{a.type ? ` · ${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.siteName ? `${a.siteName} · Site location` : a.source === "head-office" ? "Head office · Default location" : "Manual location"}
|
||
<br />{a.latitude?.toFixed(5)}, {a.longitude?.toFixed(5)}
|
||
</div>
|
||
<div className="mt-3 border-t border-gray-200 pt-3 dark:[&_dt]:text-[#003D5B] dark:[&_dd]:text-gray-600 dark:[&_span]:text-gray-500"><VehicleSiteHistory asset={a} currentSite={a.siteName} previousSite={sites.find(site => site.id === a.previous_site)?.name} /></div>
|
||
{a.qr_code && <p className="text-xs text-gray-500">QR: {a.qr_code}</p>}
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
))}
|
||
</MarkerClusterGroup>
|
||
</MapContainer>
|
||
|
||
{!popupOpen && <button type="button" onClick={() => { setFocus(null); setFitKey(k => k + 1); }}
|
||
className="absolute top-3 right-3 z-[500] min-h-[44px] px-3 rounded-lg bg-white dark:bg-surface text-sm font-medium shadow text-[#003D5B] dark:text-white">
|
||
Fit all
|
||
</button>}
|
||
{/* Legend */}
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 bg-white dark:bg-surface border-t border-gray-200 dark:border-white/10 px-3 py-2 text-xs">
|
||
<span className="flex items-center gap-2"><span className="w-3 h-3 rounded-sm bg-[#EDAE49]" /><span className="text-gray-600 dark:text-gray-300">Site</span></span>
|
||
<span className="flex items-center gap-2"><span className="w-3 h-3 rounded-sm bg-[#30638E]" /><span className="text-gray-600 dark:text-gray-300">Head office</span></span>
|
||
<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="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>
|
||
|
||
{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="min-w-0 rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-surface shadow-sm overflow-hidden">
|
||
<div className="p-4 border-b border-gray-100 dark:border-white/5">
|
||
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white mb-2">Sites & head office</h2>
|
||
<button type="button" onClick={() => setFocus({ lat: HEAD_OFFICE.latitude, lng: HEAD_OFFICE.longitude })}
|
||
className="flex items-center gap-2 w-full min-h-[44px] text-left text-sm text-[#003D5B] dark:text-white">
|
||
<MapPinned className="w-4 h-4 shrink-0 text-[#30638E]" />Head office
|
||
</button>
|
||
{pinnedSites.map(site => (
|
||
<button key={site.id} type="button"
|
||
onClick={() => setFocus({ lat: site.latitude, lng: site.longitude })}
|
||
className="flex items-center gap-2 w-full min-h-[44px] text-left text-sm text-[#003D5B] dark:text-white">
|
||
<MapPin className="w-4 h-4 shrink-0 text-[#EDAE49]" />
|
||
<span className="min-w-0 flex-1 truncate">{site.name}</span>
|
||
<span className="text-xs shrink-0 text-gray-500 dark:text-gray-400">{assetsState.filter(a => a.site === site.id).length} assigned</span>
|
||
</button>
|
||
))}
|
||
{unpinnedSites.length > 0 && (
|
||
<details className="mt-2 min-w-0 border-t border-gray-100 dark:border-white/10">
|
||
<summary className="flex min-h-[44px] cursor-pointer items-center text-xs font-medium text-[#30638E] dark:text-gray-300">{unpinnedSites.length} sites need coordinates</summary>
|
||
<div className="min-w-0 space-y-4 pt-2 [&_input]:min-w-0 [&_.flex-1]:min-w-0">
|
||
{unpinnedSites.map(site => <div key={site.id} className="min-w-0">
|
||
<h3 className="break-words text-sm font-medium text-[#003D5B] dark:text-white">{site.name}</h3>
|
||
<SiteCoordinateEditor siteId={site.id} name={site.name} latitude={site.latitude} longitude={site.longitude} />
|
||
</div>)}
|
||
</div>
|
||
</details>
|
||
)}
|
||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">Assigned machines follow their site's pin. These locations are based on assignments, not live GPS.</p>
|
||
</div>
|
||
<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, site…"
|
||
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-white/10 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 : (mappedAssets.find(a => !a.site)?.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(
|
||
"p-2 transition",
|
||
isPlacing ? "bg-[#EDAE49]/10" : "hover:bg-gray-50 dark:hover:bg-white/[0.02]",
|
||
!a.is_active && !isPlacing && "opacity-60",
|
||
)}
|
||
>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<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.siteName || (a.source === "head-office" ? "Head office · Default" : "Manual pin")}
|
||
<span className="block font-normal text-gray-500 dark:text-gray-400">{a.latitude?.toFixed(4)}, {a.longitude?.toFixed(4)}</span>
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-400 dark:text-gray-500">
|
||
{a.source === "missing-site" ? `${a.siteName} · Site coordinates needed` : "No coordinates yet"}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setPlacingId(isPlacing ? null : a.id)}
|
||
aria-label={`Place ${a.name} on the map`}
|
||
disabled={Boolean(a.site) || saving}
|
||
title={a.site ? "Add or edit coordinates in the site panel above" : undefined}
|
||
className={cn(
|
||
"inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg transition shrink-0 disabled:opacity-40",
|
||
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 && !a.site && (
|
||
<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>
|
||
)}
|
||
</div>
|
||
<details className="mt-1 px-1">
|
||
<summary className="flex min-h-[44px] cursor-pointer items-center text-xs font-medium text-[#30638E] dark:text-gray-300">Vehicle site details</summary>
|
||
<div className="pb-2 pl-1"><VehicleSiteHistory asset={a} currentSite={a.siteName} previousSite={sites.find(site => site.id === a.previous_site)?.name} />
|
||
<p className="mt-2 break-words text-xs text-gray-500 dark:text-gray-400">QR: {a.qr_code || a.code || "Not recorded"}</p>
|
||
</div>
|
||
</details>
|
||
</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;
|
||
}
|