Compare commits

...

3 Commits

Author SHA1 Message Date
Marco Lanzara
84cb770877 🚀 Release v1.0.44
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.44_20251023_143746.sql.gz
- Data: 2025-10-23 14:38:03
2025-10-23 14:38:03 +00:00
marco370
f05f05ca57 Improve map interactions for planning and guard assignment
Fix geocoding bug, implement map zooming to site, allow guard assignment to sites, and enable patrol route sequencing via map marker clicks.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/ZaT6tFl
2025-10-23 14:37:33 +00:00
marco370
32e5647dd3 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
2025-10-23 14:34:31 +00:00
5 changed files with 308 additions and 55 deletions

View File

@ -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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' attribution='&copy; <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" />

View File

@ -86,6 +86,31 @@ To prevent timezone-related bugs, especially when assigning shifts, dates should
- Graceful fallback for sites without coordinates - Graceful fallback for sites without coordinates
- **Impact**: Planning Mobile now fully functional with interactive map for patrol/inspection route planning - **Impact**: Planning Mobile now fully functional with interactive map for patrol/inspection route planning
### Planning Mobile - Interactive Features (October 23, 2025)
- **Issue**: Planning Mobile needed interactive map controls and patrol route sequencing workflow
- **Critical Bug Fix - Geocoding**:
- **Problem**: Toast showed "Indirizzo: undefined" when geocoding sites
- **Root Cause**: `apiRequest()` returns Response object, not parsed JSON
- **Fix**: Added `const result = await response.json()` after apiRequest in both handleGeocode() and handleGeocodeEdit()
- **Impact**: Geocoding now correctly displays full address from Nominatim (e.g., "Via Tiburtina, Roma, Lazio, Italia")
- **New Features**:
- **Zoom-to-Site**:
- MapController component with useMap() hook for programmatic map control
- Navigation button on each site card triggers flyTo() animation (zoom level 16)
- Toast feedback: "Mappa centrata - Visualizzazione di [nome sito]"
- **Guard Assignment**:
- "Assegna Guardia" button on site cards
- Validates guard selection from dropdown
- Toast feedback: "[Sito] assegnato a [Nome Cognome Guardia]"
- **Patrol Route Sequencing**:
- Click markers on map to build patrol route sequence
- Visual feedback: Green "Tappa N" badges on markers and site cards
- Dedicated UI section showing route in construction with numbered sequence
- Remove/clear/save route controls
- Auto-reset route when changing guard or location
- **Note**: Backend persistence (save to database) not yet implemented - marked as TODO for future development
- **Impact**: Operators can now interact with the map, assign guards to sites, and visually plan patrol routes by clicking sites in sequence
### Sites Form Fix - ServiceTypeId Integration (October 2025) ### Sites Form Fix - ServiceTypeId Integration (October 2025)
- **Issue**: Sites form used hardcoded `shiftType` enum values instead of dynamic service types from the database - **Issue**: Sites form used hardcoded `shiftType` enum values instead of dynamic service types from the database
- **Solution**: - **Solution**:

View File

@ -1,7 +1,13 @@
{ {
"version": "1.0.43", "version": "1.0.44",
"lastUpdate": "2025-10-23T14:05:10.759Z", "lastUpdate": "2025-10-23T14:38:03.431Z",
"changelog": [ "changelog": [
{
"version": "1.0.44",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.44"
},
{ {
"version": "1.0.43", "version": "1.0.43",
"date": "2025-10-23", "date": "2025-10-23",