mega-plant/components/PlantMap.tsx
Dave 2594dead84 feat: add user access and improve plant dashboard
Add PocketBase login, first-sign-in password changes, account settings,
and user activation, reactivation and deletion with self-removal guards.
Protect application pages and APIs and keep private pages out of PWA
caches.

Resolve plant locations from site assignments and improve map controls,
profit chart views, navigation and mobile table layouts.

Validation: production build and TypeScript passed; 28 unit tests passed.
Backend access integration tests and mobile verification are documented.
2026-10-06 04:02:08 +02:00

507 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
};
const escapeHtml = (s: string) =>
s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 }: {
focus: { lat: number; lng: number; zoom?: number } | null;
points: [number, number][];
fitKey: number;
}) {
const map = useMap();
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 [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 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),
);
}, [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 } : 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 } : 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} />
{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>
</Popup>
</Marker>
))}
</MarkerClusterGroup>
</MapContainer>
<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>
))}
{sites.length > pinnedSites.length && (
<a href="/sites" className="flex items-center min-h-[44px] text-xs text-[#30638E] dark:text-gray-300 underline underline-offset-4">
{sites.length - pinnedSites.length} sites still need coordinates
</a>
)}
<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(
"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.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 ? "Edit the assigned site coordinates from Sites" : 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>
)}
</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;
}