diff --git a/src/app/globals.css b/src/app/globals.css index 88cb57b..2553654 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -184,3 +184,36 @@ .supplier-map-label::before { display: none; } + +.dark .supplier-map-label { + background: rgba(15, 23, 42, 0.94); + color: #f8fafc; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.34); +} + +.leaflet-container { + z-index: 0; +} + +.leaflet-pane, +.leaflet-top, +.leaflet-bottom { + z-index: 10; +} + +.dark .leaflet-control-zoom a, +.dark .leaflet-control-attribution, +.dark .leaflet-popup-content-wrapper, +.dark .leaflet-popup-tip { + background: var(--card); + color: var(--card-foreground); +} + +.dark .leaflet-control-zoom a { + border-color: var(--border); +} + +.dark .leaflet-control-attribution a, +.dark .leaflet-popup-content a { + color: var(--primary); +} diff --git a/src/components/supplier-map.tsx b/src/components/supplier-map.tsx index 258011b..8021e52 100644 --- a/src/components/supplier-map.tsx +++ b/src/components/supplier-map.tsx @@ -3,13 +3,15 @@ import { useEffect, useMemo, useState } from "react"; import L from "leaflet"; import { MapContainer, Marker, Popup, TileLayer, Tooltip, Circle, useMap, useMapEvents } from "react-leaflet"; +import { useTheme } from "next-themes"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Mail, MapPin, Phone, RefreshCw, Search, Tag } from "lucide-react"; +import { Maximize2, Mail, MapPin, Minimize2, Move, Phone, RefreshCw, Search, Tag } from "lucide-react"; import { toast } from "sonner"; import { getAllSuppliers } from "@/lib/pb-helpers"; +import getPocketBase from "@/lib/pocketbase"; import type { Supplier } from "@/lib/types"; type SupplierPoint = Supplier & { @@ -27,8 +29,12 @@ type SupplierCluster = { const DEFAULT_CENTER: [number, number] = [-26.2041, 28.0473]; const CLUSTER_DISTANCE_KM = 8; const CLUSTER_ZOOM_BREAKPOINT = 12; -const LABEL_ZOOM_BREAKPOINT = 13; +const LABEL_ZOOM_BREAKPOINT = 12; const CLUSTER_COLORS = ["#1B76BB", "#E64B4B", "#F4B842", "#169B62", "#7C3AED", "#EA580C", "#0F766E"]; +const LIGHT_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; +const DARK_TILE_URL = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"; +const LIGHT_TILE_ATTRIBUTION = '© OpenStreetMap'; +const DARK_TILE_ATTRIBUTION = '© OpenStreetMap © CARTO'; function parseCoordinates(value?: string): [number, number] | null { if (!value) return null; @@ -119,7 +125,13 @@ function buildClusters(points: SupplierPoint[], distanceKmLimit = CLUSTER_DISTAN return clusters; } -function ZoomWatcher({ onZoomChange }: { onZoomChange: (zoom: number) => void }) { +function MapWatcher({ isFullscreen, onZoomChange }: { isFullscreen: boolean; onZoomChange: (zoom: number) => void }) { + const map = useMap(); + + useEffect(() => { + window.setTimeout(() => map.invalidateSize(), 180); + }, [isFullscreen, map]); + useMapEvents({ zoomend: (event) => onZoomChange(event.target.getZoom()), }); @@ -136,7 +148,7 @@ function ClusterMarker({ cluster }: { cluster: SupplierCluster }) { return ( - + ); @@ -147,26 +159,16 @@ function ClusterMarker({ cluster }: { cluster: SupplierCluster }) { position={cluster.center} icon={clusterIcon(cluster.color, count)} eventHandlers={{ - click: () => map.flyTo(cluster.center, Math.min(map.getZoom() + 2, CLUSTER_ZOOM_BREAKPOINT), { duration: 0.4 }), + click: () => { + map.closePopup(); + map.flyTo(cluster.center, Math.min(map.getZoom() + 2, CLUSTER_ZOOM_BREAKPOINT), { duration: 0.4 }); + }, }} - > - -
-

{count} suppliers in this area

- {cluster.suppliers.slice(0, 12).map((supplier) => ( -
-

{supplier.suppliername || "Unnamed supplier"}

- {supplier.supplieraddress &&

{supplier.supplieraddress}

} -
- ))} - {count > 12 &&

Zoom in to see {count - 12} more.

} -
-
- + /> ); } -function SupplierPopup({ supplier }: { supplier: SupplierPoint }) { +function SupplierPopup({ supplier, editMode }: { supplier: SupplierPoint; editMode: boolean }) { return (

{supplier.suppliername || "Unnamed supplier"}

@@ -174,15 +176,24 @@ function SupplierPopup({ supplier }: { supplier: SupplierPoint }) { {supplier.suppliernumber &&

Phone: {supplier.suppliernumber}

} {supplier.email &&

Email: {supplier.email}

} {supplier.supply_tags &&

Tags: {supplier.supply_tags}

} + {editMode && ( +

+ Drag this marker to correct its saved location. +

+ )}
); } export function SupplierMap() { + const { resolvedTheme } = useTheme(); const [suppliers, setSuppliers] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [zoom, setZoom] = useState(10); + const [isFullscreen, setIsFullscreen] = useState(false); + const [editMode, setEditMode] = useState(false); + const isDarkMap = resolvedTheme === "dark"; const fetchSuppliers = async () => { setLoading(true); @@ -199,6 +210,25 @@ export function SupplierMap() { fetchSuppliers(); }, []); + const saveSupplierLocation = async (supplier: SupplierPoint, lat: number, lng: number) => { + const coordinates = `${lat.toFixed(6)}, ${lng.toFixed(6)}`; + + setSuppliers((current) => + current.map((item) => (item.id === supplier.id ? { ...item, coordinates } : item)) + ); + + try { + const pb = getPocketBase(); + await pb.collection("suppliers").update(supplier.id, { coordinates }); + toast.success(`Updated ${supplier.suppliername || "supplier"} location`); + } catch (err) { + setSuppliers((current) => + current.map((item) => (item.id === supplier.id ? { ...item, coordinates: supplier.coordinates } : item)) + ); + toast.error(err instanceof Error ? err.message : "Failed to update supplier location"); + } + }; + const { mapped, missing } = useMemo(() => { const filtered = suppliers.filter((supplier) => { const query = search.trim().toLowerCase(); @@ -259,7 +289,7 @@ export function SupplierMap() {

{clusters.length}

-

Grouped within {CLUSTER_DISTANCE_KM}km

+

Grouped within {clusterDistanceKm}km at this zoom

@@ -283,20 +313,47 @@ export function SupplierMap() { className="pl-9" /> - +
+ + +
- -
+ +
+
+ {editMode && ( +
+ Drag pins to save locations +
+ )} + +
- + {!showIndividualSuppliers && clusters.map((cluster) => ( @@ -320,6 +377,14 @@ export function SupplierMap() { key={supplier.id} position={[supplier.lat, supplier.lng]} icon={markerIcon(cluster?.color || CLUSTER_COLORS[0], label)} + draggable={editMode} + eventHandlers={{ + dragend: (event) => { + const marker = event.target as L.Marker; + const position = marker.getLatLng(); + void saveSupplierLocation(supplier, position.lat, position.lng); + }, + }} > {showLabels && ( @@ -327,7 +392,7 @@ export function SupplierMap() { )} - + );