The container lines (MPLANT04/MPLANT05) have no matching plant item, so they
sat unbooked forever with no way to account for them. They can now be booked
to a SITE instead.
`sites` is a new PocketBase collection derived from the invoices — each
distinct invoice site becomes a site, created as invoices are imported. They
are never hand-typed, and never deleted: a site can carry notes and hold line
bookings, so removing one would orphan that history.
/sites lists every site with its revenue ex VAT, its VAT, the total billed,
how many invoices and machines it carries, and how many lines are still
outstanding. Each row links to a detail page listing the site's invoices and
every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a
group with Overview and Sites.
Booking a line to a site posts NO income — there is no machine to carry it —
it only marks the line accounted for, so it stops showing as unbooked. Exactly
one of booked_asset / booked_site is ever set, and a line with neither is what
surfaces as unbooked everywhere.
One container per site, as specified: once a machine-less line has taken a
site, that site is withheld from the next one's picker and shown disabled
("already has one") rather than hidden. Free sites sort to the top. The rule is
enforced server-side too, so it does not depend on the UI being the only way in —
re-posting a taken site is refused with 409. Verified: booking The Hills, then
trying again, then taking West Park, and the remaining line sees 8 free and 2
taken. Clearing a booking frees its site.
The site picker only appears on lines that need it — no machine, or one already
booked to a site — so fully-booked rows keep a single control.
Also fixed the Sidebar's group-active check, which derived its prefix from the
first child. For a group whose first child is "/" that yields "/" and matched
every route, so Dashboard would have been highlighted on every page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
448 lines
18 KiB
TypeScript
448 lines
18 KiB
TypeScript
"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: '© <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],
|
|
});
|
|
}
|
|
|
|
/** 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} />
|
|
|
|
{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>
|
|
))}
|
|
</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>
|
|
</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;
|
|
}
|