Sites: give every site coordinates
`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>
This commit is contained in:
parent
4e3f1ac7d7
commit
802f471047
10 changed files with 426 additions and 34 deletions
84
app/api/sites/[id]/route.ts
Normal file
84
app/api/sites/[id]/route.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { serverAuth } from "@/lib/pb";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// PATCH /api/sites/:id
|
||||
// Body: { latitude?, longitude?, notes?, name? }
|
||||
//
|
||||
// Pins a site on the map. Coordinates are stored as two plain numbers, matching
|
||||
// `assets.latitude` / `assets.longitude`, so the same map component can plot either.
|
||||
// Sending both clears the pin — a half-set coordinate would put a marker in the ocean
|
||||
// or at 0,0, which reads as "located" when it is not.
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const pb = await serverAuth();
|
||||
|
||||
try {
|
||||
const site = await pb.collection("sites").getOne(id);
|
||||
const patch: Record<string, unknown> = {};
|
||||
|
||||
if ("name" in body) {
|
||||
if (typeof body.name !== "string" || !body.name.trim()) {
|
||||
return Response.json({ ok: false, error: "Name cannot be empty" }, { status: 400 });
|
||||
}
|
||||
patch.name = body.name.trim();
|
||||
}
|
||||
if ("notes" in body) patch.notes = String(body.notes ?? "");
|
||||
|
||||
if ("latitude" in body || "longitude" in body) {
|
||||
const lat = body.latitude === null || body.latitude === "" ? null : Number(body.latitude);
|
||||
const lng = body.longitude === null || body.longitude === "" ? null : Number(body.longitude);
|
||||
|
||||
if (lat === null || lng === null) {
|
||||
patch.latitude = 0;
|
||||
patch.longitude = 0;
|
||||
} else {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
||||
return Response.json({ ok: false, error: "Latitude and longitude must be numbers" }, { status: 400 });
|
||||
}
|
||||
// A pin outside the country is a typo far more often than a real machine abroad.
|
||||
if (lat < -35.5 || lat > -21.5 || lng < 15.5 || lng > 33.5) {
|
||||
return Response.json(
|
||||
{ ok: false, error: `That point is outside South Africa (${lat}, ${lng}) — check the numbers` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
patch.latitude = Math.round(lat * 1e6) / 1e6;
|
||||
patch.longitude = Math.round(lng * 1e6) / 1e6;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return Response.json({ ok: false, error: "Nothing to change" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await pb.collection("sites").update(id, patch);
|
||||
revalidatePath("/sites");
|
||||
revalidatePath(`/sites/${id}`);
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
site: {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
latitude: updated.latitude,
|
||||
longitude: updated.longitude,
|
||||
},
|
||||
located: Boolean(updated.latitude || updated.longitude),
|
||||
});
|
||||
} catch (e: any) {
|
||||
return Response.json({ ok: false, error: e?.message || "Could not save" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,13 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Leaflet + marker-cluster must be global stylesheets. Importing them inside
|
||||
components/PlantMap.tsx works for leaflet.css but the cluster stylesheet gets
|
||||
dropped, because that component is behind a `next/dynamic` ssr:false boundary
|
||||
whose CSS is not linked in the initial HTML. Without these, cluster bubbles
|
||||
land unpositioned and unstyled. */
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
@import "leaflet.markercluster/dist/MarkerCluster.css";
|
||||
|
||||
/* Enable dark mode via .dark class on html */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { buildSiteSummaries } from "@/lib/sites";
|
|||
import { ZAR2 } from "@/lib/utils";
|
||||
import { ScrollFade } from "@/components/ScrollFade";
|
||||
import { ArrowLeft, MapPin, FileText } from "lucide-react";
|
||||
import { SiteCoordinateEditor } from "@/components/SiteCoordinateEditor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -131,6 +132,20 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ id:
|
|||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
||||
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Location</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<SiteCoordinateEditor
|
||||
siteId={site.id}
|
||||
name={site.name}
|
||||
latitude={site.latitude}
|
||||
longitude={site.longitude}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100 dark:border-white/5">
|
||||
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Invoices</h2>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getAssets, getInvoices, getInvoiceLines, getSites, joinBookedLines } fr
|
|||
import { buildSiteSummaries } from "@/lib/sites";
|
||||
import { ZAR, ZAR2 } from "@/lib/utils";
|
||||
import { ScrollFade } from "@/components/ScrollFade";
|
||||
import { AlertTriangle, ArrowRight, MapPin } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, MapPin, MapPinned } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata: Metadata = { title: "Sites — Mega Plant" };
|
||||
|
|
@ -23,6 +23,7 @@ export default async function SitesPage() {
|
|||
const totalExcl = rows.reduce((s, r) => s + r.revenueExcl, 0);
|
||||
const totalVat = rows.reduce((s, r) => s + r.vat, 0);
|
||||
const outstanding = rows.reduce((s, r) => s + r.unbookedCount, 0);
|
||||
const pinned = rows.filter((r) => sites.find((x) => x.id === r.id)?.latitude).length;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
|
|
@ -48,6 +49,17 @@ export default async function SitesPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{pinned < rows.length && (
|
||||
<div className="flex items-start gap-3 px-4 py-3 mb-5 rounded-xl bg-[#EDAE49]/10 border border-[#EDAE49]/20">
|
||||
<MapPinned className="w-4 h-4 text-[#8a6420] dark:text-[#EDAE49] shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-[#8a6420] dark:text-[#EDAE49]">
|
||||
{rows.length - pinned} of {rows.length} sites have no coordinates yet. Open a site to
|
||||
pin it — the markers marked{" "}
|
||||
<span className="font-medium">no pin</span> sit at none.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{outstanding > 0 && (
|
||||
<div className="flex items-start gap-3 px-4 py-3 mb-5 rounded-xl bg-[#D1495B]/5 border border-[#D1495B]/15">
|
||||
<AlertTriangle className="w-4 h-4 text-[#D1495B] shrink-0 mt-0.5" />
|
||||
|
|
@ -103,6 +115,11 @@ export default async function SitesPage() {
|
|||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
|
||||
<span className="whitespace-nowrap">{r.name}</span>
|
||||
{sites.find((x) => x.id === r.id)?.latitude ? (
|
||||
<MapPinned className="w-3 h-3 text-[#00798C] shrink-0" aria-label="Has coordinates" />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">no pin</span>
|
||||
)}
|
||||
<ArrowRight className="w-3.5 h-3.5 opacity-0 group-hover:opacity-100 transition shrink-0" />
|
||||
</a>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
// 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";
|
||||
|
|
@ -73,6 +75,38 @@ function badgeIcon(label: string, active: boolean) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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],
|
||||
});
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
|
@ -190,39 +224,53 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) {
|
|||
<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)}` : ""}
|
||||
{/* 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
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
))}
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
|
||||
{/* Placing mode — capture the map click outside React's synthetic tree */}
|
||||
|
|
@ -243,6 +291,17 @@ export function PlantMap({ assets }: { assets: MapAsset[] }) {
|
|||
<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="h-px bg-gray-200 dark:bg-white/10 my-0.5" />
|
||||
<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>
|
||||
|
||||
{/* Nothing located yet */}
|
||||
|
|
|
|||
166
components/SiteCoordinateEditor.tsx
Normal file
166
components/SiteCoordinateEditor.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -240,6 +240,8 @@ export type Site = {
|
|||
name: string;
|
||||
code: string;
|
||||
notes: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
is_active: boolean;
|
||||
} & RecordModel;
|
||||
|
||||
|
|
|
|||
37
package-lock.json
generated
37
package-lock.json
generated
|
|
@ -27,12 +27,14 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^4.1.3",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/leaflet.markercluster": "^1.5.6",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
|
@ -4379,6 +4381,16 @@
|
|||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/leaflet.markercluster": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz",
|
||||
"integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
|
||||
|
|
@ -6647,6 +6659,15 @@
|
|||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet.markercluster": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
||||
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
|
|
@ -7488,6 +7509,22 @@
|
|||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet-cluster": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-4.1.3.tgz",
|
||||
"integrity": "sha512-b3ICS7r9Fs5hBWrFPMVnmkCuDewyG3m1GaGzamXY0eryyAXDfQ+ikSkiR/mVethueIIA5MSyLok2/KsZVotspQ==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"leaflet.markercluster": "^1.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-leaflet/core": "^3.0.0",
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
|
|
|
|||
|
|
@ -30,12 +30,14 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^4.1.3",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/leaflet.markercluster": "^1.5.6",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ const sitesId = await ensureCollection("sites", [
|
|||
{ name: "name", type: "text", required: true },
|
||||
{ name: "code", type: "text" },
|
||||
{ name: "notes", type: "text" },
|
||||
{ name: "latitude", type: "number" },
|
||||
{ name: "longitude", type: "number" },
|
||||
{ name: "is_active", type: "bool" },
|
||||
{ name: "created", type: "date", onCreate: true, presentable: false },
|
||||
{ name: "updated", type: "date", onUpdate: true, presentable: false },
|
||||
|
|
|
|||
Loading…
Reference in a new issue