Add interactive map features and guard assignment functionality
Implement map centering on site selection, guard assignment to sites, and patrol route creation with Toast notifications and Leaflet map controls. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/ZaT6tFl
This commit is contained in:
parent
92ac90315a
commit
32e5647dd3
6
.replit
6
.replit
@ -21,7 +21,7 @@ externalPort = 3001
|
|||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41295
|
localPort = 41295
|
||||||
externalPort = 5173
|
externalPort = 6000
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41343
|
localPort = 41343
|
||||||
@ -43,6 +43,10 @@ externalPort = 5000
|
|||||||
localPort = 43267
|
localPort = 43267
|
||||||
externalPort = 3003
|
externalPort = 3003
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 45047
|
||||||
|
externalPort = 5173
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "5000"
|
PORT = "5000"
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useRef, useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -6,11 +6,12 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Calendar, MapPin, User, Car, Clock } from "lucide-react";
|
import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered } from "lucide-react";
|
||||||
import { format, parseISO, isValid } from "date-fns";
|
import { format, parseISO, isValid } from "date-fns";
|
||||||
import { it } from "date-fns/locale";
|
import { it } from "date-fns/locale";
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
// Fix Leaflet default icon issue with Webpack
|
// Fix Leaflet default icon issue with Webpack
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
@ -43,10 +44,29 @@ type AvailableGuard = {
|
|||||||
availableHours: number;
|
availableHours: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Componente per controllare la mappa da fuori
|
||||||
|
function MapController({ center, zoom }: { center: [number, number] | null; zoom: number }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (center) {
|
||||||
|
map.flyTo(center, zoom, {
|
||||||
|
duration: 1.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [center, zoom, map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PlanningMobile() {
|
export default function PlanningMobile() {
|
||||||
|
const { toast } = useToast();
|
||||||
const [selectedDate, setSelectedDate] = useState(format(new Date(), "yyyy-MM-dd"));
|
const [selectedDate, setSelectedDate] = useState(format(new Date(), "yyyy-MM-dd"));
|
||||||
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||||
const [selectedGuardId, setSelectedGuardId] = useState<string>("all");
|
const [selectedGuardId, setSelectedGuardId] = useState<string>("all");
|
||||||
|
const [mapCenter, setMapCenter] = useState<[number, number] | null>(null);
|
||||||
|
const [mapZoom, setMapZoom] = useState(12);
|
||||||
|
const [patrolRoute, setPatrolRoute] = useState<MobileSite[]>([]);
|
||||||
|
|
||||||
// Query siti mobile per location
|
// Query siti mobile per location
|
||||||
const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({
|
const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({
|
||||||
@ -103,6 +123,119 @@ export default function PlanningMobile() {
|
|||||||
) || [];
|
) || [];
|
||||||
}, [mobileSites]);
|
}, [mobileSites]);
|
||||||
|
|
||||||
|
// Trova guardia selezionata
|
||||||
|
const selectedGuard = useMemo(() => {
|
||||||
|
if (selectedGuardId === "all") return null;
|
||||||
|
return availableGuards?.find(g => g.id === selectedGuardId) || null;
|
||||||
|
}, [selectedGuardId, availableGuards]);
|
||||||
|
|
||||||
|
// Funzione per zoomare su un sito
|
||||||
|
const handleZoomToSite = (site: MobileSite) => {
|
||||||
|
if (site.latitude && site.longitude) {
|
||||||
|
setMapCenter([parseFloat(site.latitude), parseFloat(site.longitude)]);
|
||||||
|
setMapZoom(16); // Zoom ravvicinato
|
||||||
|
toast({
|
||||||
|
title: "Mappa centrata",
|
||||||
|
description: `Visualizzazione di ${site.name}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Coordinate mancanti",
|
||||||
|
description: "Questo sito non ha coordinate GPS",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Funzione per assegnare guardia a un sito
|
||||||
|
const handleAssignGuard = (site: MobileSite) => {
|
||||||
|
if (!selectedGuard) {
|
||||||
|
toast({
|
||||||
|
title: "Guardia non selezionata",
|
||||||
|
description: "Seleziona una guardia dal filtro in alto",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Guardia assegnata",
|
||||||
|
description: `${site.name} assegnato a ${selectedGuard.firstName} ${selectedGuard.lastName}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Funzione per aggiungere sito alla patrol route
|
||||||
|
const handleAddToRoute = (site: MobileSite) => {
|
||||||
|
if (!selectedGuard) {
|
||||||
|
toast({
|
||||||
|
title: "Seleziona una guardia",
|
||||||
|
description: "Prima seleziona una guardia per creare il turno pattuglia",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controlla se il sito è già nella route
|
||||||
|
const alreadyInRoute = patrolRoute.some(s => s.id === site.id);
|
||||||
|
if (alreadyInRoute) {
|
||||||
|
toast({
|
||||||
|
title: "Sito già in sequenza",
|
||||||
|
description: `${site.name} è già nella sequenza di pattuglia`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPatrolRoute([...patrolRoute, site]);
|
||||||
|
toast({
|
||||||
|
title: "Sito aggiunto alla sequenza",
|
||||||
|
description: `${site.name} - Tappa ${patrolRoute.length + 1} di ${patrolRoute.length + 1}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Funzione per rimuovere sito dalla route
|
||||||
|
const handleRemoveFromRoute = (siteId: string) => {
|
||||||
|
setPatrolRoute(patrolRoute.filter(s => s.id !== siteId));
|
||||||
|
toast({
|
||||||
|
title: "Sito rimosso",
|
||||||
|
description: "Sito rimosso dalla sequenza di pattuglia",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Funzione per salvare il turno pattuglia
|
||||||
|
const handleSavePatrolRoute = () => {
|
||||||
|
if (!selectedGuard) {
|
||||||
|
toast({
|
||||||
|
title: "Guardia non selezionata",
|
||||||
|
description: "Seleziona una guardia per salvare il turno",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patrolRoute.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: "Nessun sito nella sequenza",
|
||||||
|
description: "Aggiungi almeno un sito alla sequenza di pattuglia",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implementare chiamata API per salvare turno
|
||||||
|
toast({
|
||||||
|
title: "Turno pattuglia creato",
|
||||||
|
description: `${patrolRoute.length} tappe assegnate a ${selectedGuard.firstName} ${selectedGuard.lastName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPatrolRoute([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset patrol route quando cambia guardia o location
|
||||||
|
useEffect(() => {
|
||||||
|
setPatrolRoute([]);
|
||||||
|
}, [selectedGuardId, selectedLocation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col p-6 space-y-6">
|
<div className="h-full flex flex-col p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -167,6 +300,61 @@ export default function PlanningMobile() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Sequenza Pattuglia */}
|
||||||
|
{selectedGuard && patrolRoute.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<ListOrdered className="h-5 w-5" />
|
||||||
|
Sequenza Pattuglia - {selectedGuard.firstName} {selectedGuard.lastName}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{patrolRoute.length} tappe programmate per il turno del {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{patrolRoute.map((site, index) => (
|
||||||
|
<div
|
||||||
|
key={site.id}
|
||||||
|
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20"
|
||||||
|
data-testid={`route-stop-${index}`}
|
||||||
|
>
|
||||||
|
<Badge className="bg-green-600">
|
||||||
|
{index + 1}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium">{site.name}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRemoveFromRoute(site.id)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
data-testid={`button-remove-stop-${index}`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSavePatrolRoute}
|
||||||
|
data-testid="button-save-patrol-route"
|
||||||
|
>
|
||||||
|
Salva Turno Pattuglia
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setPatrolRoute([])}
|
||||||
|
data-testid="button-clear-patrol-route"
|
||||||
|
>
|
||||||
|
Cancella Sequenza
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Grid: Mappa + Siti */}
|
{/* Grid: Mappa + Siti */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-1">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-1">
|
||||||
{/* Mappa Siti */}
|
{/* Mappa Siti */}
|
||||||
@ -189,28 +377,42 @@ export default function PlanningMobile() {
|
|||||||
className="h-full w-full min-h-[400px]"
|
className="h-full w-full min-h-[400px]"
|
||||||
data-testid="map-container"
|
data-testid="map-container"
|
||||||
>
|
>
|
||||||
|
<MapController center={mapCenter} zoom={mapZoom} />
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
{sitesWithCoordinates.map((site) => (
|
{sitesWithCoordinates.map((site) => {
|
||||||
<Marker
|
const isInRoute = patrolRoute.some(s => s.id === site.id);
|
||||||
key={site.id}
|
const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
|
||||||
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
|
||||||
>
|
return (
|
||||||
<Popup>
|
<Marker
|
||||||
<div className="space-y-1">
|
key={site.id}
|
||||||
<h4 className="font-semibold text-sm">{site.name}</h4>
|
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
||||||
<p className="text-xs text-muted-foreground">{site.address}</p>
|
eventHandlers={{
|
||||||
{site.serviceTypeName && (
|
click: () => handleAddToRoute(site),
|
||||||
<Badge variant="outline" className="text-xs">
|
}}
|
||||||
{site.serviceTypeName}
|
>
|
||||||
</Badge>
|
<Popup>
|
||||||
)}
|
<div className="space-y-2">
|
||||||
</div>
|
<h4 className="font-semibold text-sm">{site.name}</h4>
|
||||||
</Popup>
|
<p className="text-xs text-muted-foreground">{site.address}</p>
|
||||||
</Marker>
|
{site.serviceTypeName && (
|
||||||
))}
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{site.serviceTypeName}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isInRoute && (
|
||||||
|
<Badge className="text-xs bg-green-600">
|
||||||
|
Tappa {routeIndex + 1}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center bg-muted/20">
|
<div className="h-full flex items-center justify-center bg-muted/20">
|
||||||
@ -242,41 +444,61 @@ export default function PlanningMobile() {
|
|||||||
{sitesLoading ? (
|
{sitesLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">Caricamento...</p>
|
<p className="text-sm text-muted-foreground">Caricamento...</p>
|
||||||
) : mobileSites && mobileSites.length > 0 ? (
|
) : mobileSites && mobileSites.length > 0 ? (
|
||||||
mobileSites.map((site) => (
|
mobileSites.map((site) => {
|
||||||
<div
|
const isInRoute = patrolRoute.some(s => s.id === site.id);
|
||||||
key={site.id}
|
const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
|
||||||
className="p-4 border rounded-lg space-y-2 hover-elevate cursor-pointer"
|
|
||||||
data-testid={`site-card-${site.id}`}
|
return (
|
||||||
>
|
<div
|
||||||
<div className="flex items-start justify-between gap-2">
|
key={site.id}
|
||||||
<div className="space-y-1 flex-1">
|
className="p-4 border rounded-lg space-y-2"
|
||||||
<h4 className="font-semibold">{site.name}</h4>
|
data-testid={`site-card-${site.id}`}
|
||||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
>
|
||||||
<MapPin className="h-3 w-3" />
|
<div className="flex items-start justify-between gap-2">
|
||||||
{site.address}
|
<div className="space-y-1 flex-1">
|
||||||
</p>
|
<h4 className="font-semibold">{site.name}</h4>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
<Badge className={locationColors[site.location]} data-testid={`badge-location-${site.id}`}>
|
<MapPin className="h-3 w-3" />
|
||||||
{locationLabels[site.location]}
|
{site.address}
|
||||||
</Badge>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{site.serviceTypeName && (
|
<Badge className={locationColors[site.location]} data-testid={`badge-location-${site.id}`}>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
{locationLabels[site.location]}
|
||||||
<Badge variant="outline" data-testid={`badge-service-${site.id}`}>
|
|
||||||
{site.serviceTypeName}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{site.serviceTypeName && (
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Button size="sm" variant="default" data-testid={`button-assign-${site.id}`}>
|
<Badge variant="outline" data-testid={`badge-service-${site.id}`}>
|
||||||
Assegna Guardia
|
{site.serviceTypeName}
|
||||||
</Button>
|
</Badge>
|
||||||
<Button size="sm" variant="outline" data-testid={`button-view-${site.id}`}>
|
{isInRoute && (
|
||||||
Dettagli
|
<Badge className="bg-green-600">
|
||||||
</Button>
|
Tappa {routeIndex + 1}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => handleAssignGuard(site)}
|
||||||
|
data-testid={`button-assign-${site.id}`}
|
||||||
|
>
|
||||||
|
Assegna Guardia
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleZoomToSite(site)}
|
||||||
|
data-testid={`button-zoom-${site.id}`}
|
||||||
|
>
|
||||||
|
<Navigation className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 space-y-2">
|
<div className="text-center py-8 space-y-2">
|
||||||
<MapPin className="h-12 w-12 mx-auto text-muted-foreground" />
|
<MapPin className="h-12 w-12 mx-auto text-muted-foreground" />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user