223 lines
7.8 KiB
TypeScript
223 lines
7.8 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useMemo, useState } from 'react';
|
||
|
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||
|
|
import { Collection, Supplier } from '@/lib/types';
|
||
|
|
import { formatCurrency, calculateDistance } from '@/lib/utils';
|
||
|
|
import { Navigation, Check, AlertTriangle } from 'lucide-react';
|
||
|
|
import L from 'leaflet';
|
||
|
|
import 'leaflet/dist/leaflet.css';
|
||
|
|
|
||
|
|
// Fix Leaflet default markers in Next.js
|
||
|
|
const defaultIcon = L.icon({
|
||
|
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||
|
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||
|
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||
|
|
iconSize: [25, 41],
|
||
|
|
iconAnchor: [12, 41],
|
||
|
|
popupAnchor: [1, -34],
|
||
|
|
shadowSize: [41, 41],
|
||
|
|
});
|
||
|
|
|
||
|
|
const currentLocationIcon = L.divIcon({
|
||
|
|
className: 'custom-div-icon',
|
||
|
|
html: `
|
||
|
|
<div style="
|
||
|
|
width: 20px;
|
||
|
|
height: 20px;
|
||
|
|
background: #ea580c;
|
||
|
|
border: 3px solid white;
|
||
|
|
border-radius: 50%;
|
||
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||
|
|
"></div>
|
||
|
|
`,
|
||
|
|
iconSize: [20, 20],
|
||
|
|
iconAnchor: [10, 10],
|
||
|
|
});
|
||
|
|
|
||
|
|
interface MapViewProps {
|
||
|
|
collections: Collection[];
|
||
|
|
onNavigate: (lat: number, lng: number, name: string) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function MapBounds({ collections }: { collections: Collection[] }) {
|
||
|
|
const map = useMap();
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const validPoints = collections
|
||
|
|
.filter((c) => c.expand?.supplier?.latitude && c.expand?.supplier?.longitude)
|
||
|
|
.map((c) => [c.expand!.supplier!.latitude!, c.expand!.supplier!.longitude!] as [number, number]);
|
||
|
|
|
||
|
|
if (validPoints.length > 0) {
|
||
|
|
const bounds = L.latLngBounds(validPoints);
|
||
|
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||
|
|
}
|
||
|
|
}, [map, collections]);
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function CollectionMap({ collections, onNavigate }: MapViewProps) {
|
||
|
|
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
||
|
|
const [selectedSupplier, setSelectedSupplier] = useState<string | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if ('geolocation' in navigator) {
|
||
|
|
navigator.geolocation.getCurrentPosition(
|
||
|
|
(pos) => {
|
||
|
|
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
|
||
|
|
},
|
||
|
|
(error) => console.error('Could not get location:', error),
|
||
|
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||
|
|
);
|
||
|
|
|
||
|
|
const watchId = navigator.geolocation.watchPosition(
|
||
|
|
(pos) => {
|
||
|
|
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
|
||
|
|
},
|
||
|
|
(error) => console.error('Watch error:', error),
|
||
|
|
{ enableHighAccuracy: true }
|
||
|
|
);
|
||
|
|
|
||
|
|
return () => navigator.geolocation.clearWatch(watchId);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Group collections by supplier
|
||
|
|
const suppliers = useMemo(() => {
|
||
|
|
const map = new Map<string, { supplier: Supplier; collections: Collection[] }>();
|
||
|
|
|
||
|
|
collections.forEach((collection) => {
|
||
|
|
if (!collection.expand?.supplier) return;
|
||
|
|
const supplier = collection.expand.supplier;
|
||
|
|
if (!supplier.latitude || !supplier.longitude) return;
|
||
|
|
|
||
|
|
const key = supplier.id;
|
||
|
|
if (!map.has(key)) {
|
||
|
|
map.set(key, { supplier, collections: [] });
|
||
|
|
}
|
||
|
|
map.get(key)!.collections.push(collection);
|
||
|
|
});
|
||
|
|
|
||
|
|
return Array.from(map.values());
|
||
|
|
}, [collections]);
|
||
|
|
|
||
|
|
const defaultCenter: [number, number] = [-33.9249, 18.4241]; // Cape Town default
|
||
|
|
const center = userLocation || defaultCenter;
|
||
|
|
|
||
|
|
const getSupplierDistance = (supplier: Supplier) => {
|
||
|
|
if (!userLocation || !supplier.latitude || !supplier.longitude) return null;
|
||
|
|
return calculateDistance(
|
||
|
|
userLocation[0],
|
||
|
|
userLocation[1],
|
||
|
|
supplier.latitude,
|
||
|
|
supplier.longitude
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="h-[calc(100vh-4rem)] w-full relative">
|
||
|
|
<MapContainer
|
||
|
|
center={center}
|
||
|
|
zoom={12}
|
||
|
|
className="h-full w-full"
|
||
|
|
zoomControl={false}
|
||
|
|
>
|
||
|
|
<TileLayer
|
||
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<MapBounds collections={collections} />
|
||
|
|
|
||
|
|
{userLocation && (
|
||
|
|
<Marker position={userLocation} icon={currentLocationIcon}>
|
||
|
|
<Popup>
|
||
|
|
<div className="text-sm font-medium">Your Location</div>
|
||
|
|
</Popup>
|
||
|
|
</Marker>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{suppliers.map(({ supplier, collections: supplierCollections }) => {
|
||
|
|
const distance = getSupplierDistance(supplier);
|
||
|
|
const hasProblem = supplierCollections.some((c) => c.status === 'problem');
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Marker
|
||
|
|
key={supplier.id}
|
||
|
|
position={[supplier.latitude!, supplier.longitude!]}
|
||
|
|
icon={defaultIcon}
|
||
|
|
eventHandlers={{
|
||
|
|
click: () => setSelectedSupplier(supplier.id),
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Popup>
|
||
|
|
<div className="min-w-[200px] max-w-[250px]">
|
||
|
|
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
|
||
|
|
<p className="text-xs text-gray-500 mb-2">{supplier.address}</p>
|
||
|
|
|
||
|
|
{distance && (
|
||
|
|
<p className="text-xs text-orange-600 mb-2 font-medium">
|
||
|
|
{distance.toFixed(1)} km away
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="space-y-1.5 mb-3">
|
||
|
|
{supplierCollections.map((c) => (
|
||
|
|
<div
|
||
|
|
key={c.id}
|
||
|
|
className="text-xs p-1.5 bg-gray-50 rounded"
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
{c.status === 'problem' && (
|
||
|
|
<AlertTriangle size={12} className="text-red-500" />
|
||
|
|
)}
|
||
|
|
<span className="font-medium truncate">
|
||
|
|
{c.is_manual
|
||
|
|
? c.description
|
||
|
|
: c.expand?.order_item?.description || 'Unknown'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<span className="text-gray-400">
|
||
|
|
Qty: {c.quantity} {c.unit || c.expand?.order_item?.unit || 'units'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<button
|
||
|
|
onClick={() =>
|
||
|
|
onNavigate(supplier.latitude!, supplier.longitude!, supplier.suppliername)
|
||
|
|
}
|
||
|
|
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-blue-500 text-white rounded-lg text-sm font-medium active:bg-orange-600 transition-colors"
|
||
|
|
>
|
||
|
|
<Navigation size={14} />
|
||
|
|
Navigate
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</Popup>
|
||
|
|
</Marker>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</MapContainer>
|
||
|
|
|
||
|
|
{/* Legend overlay */}
|
||
|
|
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm rounded-xl p-3 shadow-lg z-[400]">
|
||
|
|
<p className="text-xs font-medium text-gray-700 mb-2">Legend</p>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className="w-3 h-3 rounded-full bg-blue-500 border-2 border-white shadow-sm" />
|
||
|
|
<span className="text-[10px] text-gray-600">Your Location</span>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className="w-4 h-4 relative">
|
||
|
|
<div className="absolute inset-0 bg-blue-500 rounded-sm rotate-45 transform origin-bottom" />
|
||
|
|
</div>
|
||
|
|
<span className="text-[10px] text-gray-600">Collection Stop</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|