`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>
166 lines
No EOL
5.9 KiB
TypeScript
166 lines
No EOL
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Loader2, MapPin, Check, X, ExternalLink } from "lucide-react";
|
|
|
|
// Pin a site on the map.
|
|
//
|
|
// Deliberately plain number inputs plus a "open in maps" link, rather than a
|
|
// drag-to-pin map: the in-app Leaflet map is still being worked on, and a coordinate
|
|
// entered by hand is usually copied off a map anyway. A point can be pasted straight in.
|
|
//
|
|
// Bounds are enforced server-side as well as here — a stray 0,0 or a digit dropped in
|
|
// the wrong box would otherwise place the marker in the Gulf of Guinea.
|
|
export function SiteCoordinateEditor({
|
|
siteId,
|
|
name,
|
|
latitude,
|
|
longitude,
|
|
}: {
|
|
siteId: string;
|
|
name: string;
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
}) {
|
|
const router = useRouter();
|
|
const has = Boolean(latitude || longitude);
|
|
const [open, setOpen] = useState(false);
|
|
const [lat, setLat] = useState(latitude ? String(latitude) : "");
|
|
const [lng, setLng] = useState(longitude ? String(longitude) : "");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
async function save(clear = false) {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(`/api/sites/${siteId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(
|
|
clear ? { latitude: null, longitude: null } : { latitude: Number(lat), longitude: Number(lng) },
|
|
),
|
|
});
|
|
const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" }));
|
|
if (!data.ok) {
|
|
setError(data.error || "Could not save");
|
|
} else {
|
|
setOpen(false);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 2500);
|
|
router.refresh();
|
|
}
|
|
} catch {
|
|
setError("Could not reach the server");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
if (!open) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm font-medium text-[#003D5B] dark:text-white hover:bg-gray-100 dark:hover:bg-white/5 transition"
|
|
>
|
|
{saved ? (
|
|
<Check className="w-4 h-4 text-[#00798C]" />
|
|
) : (
|
|
<MapPin className={`w-4 h-4 ${has ? "text-[#00798C]" : "text-gray-400"}`} />
|
|
)}
|
|
{has
|
|
? `${latitude!.toFixed(5)}, ${longitude!.toFixed(5)}`
|
|
: "No location set — add coordinates"}
|
|
</button>
|
|
{has && (
|
|
<a
|
|
href={`https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1.5 min-h-[44px] px-2 text-sm text-[#00798C] hover:underline"
|
|
>
|
|
Open in maps
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="flex-1">
|
|
<label htmlFor={`lat-${siteId}`} className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
|
Latitude
|
|
</label>
|
|
<input
|
|
id={`lat-${siteId}`}
|
|
type="number"
|
|
step="0.000001"
|
|
inputMode="decimal"
|
|
value={lat}
|
|
onChange={(e) => setLat(e.target.value)}
|
|
placeholder="-26.101615"
|
|
className="min-h-[44px] w-full px-3 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-sm tabular-nums focus:outline-none focus:border-[#30638E]"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<label htmlFor={`lng-${siteId}`} className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
|
Longitude
|
|
</label>
|
|
<input
|
|
id={`lng-${siteId}`}
|
|
type="number"
|
|
step="0.000001"
|
|
inputMode="decimal"
|
|
value={lng}
|
|
onChange={(e) => setLng(e.target.value)}
|
|
placeholder="28.348725"
|
|
className="min-h-[44px] w-full px-3 rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5 text-sm tabular-nums focus:outline-none focus:border-[#30638E]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="text-sm text-[#D1495B]">{error}</p>}
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => save(false)}
|
|
disabled={busy || lat === "" || lng === ""}
|
|
className="inline-flex items-center gap-2 min-h-[44px] px-4 rounded-lg text-sm font-semibold text-white bg-[#00798C] hover:opacity-90 transition disabled:opacity-40"
|
|
>
|
|
{busy && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
Save location
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
disabled={busy}
|
|
className="inline-flex items-center gap-2 min-h-[44px] px-3 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
Cancel
|
|
</button>
|
|
{has && (
|
|
<button
|
|
type="button"
|
|
onClick={() => save(true)}
|
|
disabled={busy}
|
|
className="inline-flex items-center min-h-[44px] px-3 text-sm text-[#D1495B] hover:underline disabled:opacity-40"
|
|
>
|
|
Clear location
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Paste a point copied from any map. Only coordinates inside South Africa are accepted.
|
|
</p>
|
|
</div>
|
|
);
|
|
} |