Compare commits
3 Commits
92ac90315a
...
84cb770877
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84cb770877 | ||
|
|
f05f05ca57 | ||
|
|
32e5647dd3 |
@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -6,11 +6,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { 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 { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// Fix Leaflet default icon issue with Webpack
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
@ -43,10 +44,29 @@ type AvailableGuard = {
|
||||
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() {
|
||||
const { toast } = useToast();
|
||||
const [selectedDate, setSelectedDate] = useState(format(new Date(), "yyyy-MM-dd"));
|
||||
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||
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
|
||||
const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({
|
||||
@ -103,6 +123,119 @@ export default function PlanningMobile() {
|
||||
) || [];
|
||||
}, [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 (
|
||||
<div className="h-full flex flex-col p-6 space-y-6">
|
||||
{/* Header */}
|
||||
@ -167,6 +300,61 @@ export default function PlanningMobile() {
|
||||
</CardContent>
|
||||
</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 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-1">
|
||||
{/* Mappa Siti */}
|
||||
@ -189,17 +377,25 @@ export default function PlanningMobile() {
|
||||
className="h-full w-full min-h-[400px]"
|
||||
data-testid="map-container"
|
||||
>
|
||||
<MapController center={mapCenter} zoom={mapZoom} />
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{sitesWithCoordinates.map((site) => (
|
||||
{sitesWithCoordinates.map((site) => {
|
||||
const isInRoute = patrolRoute.some(s => s.id === site.id);
|
||||
const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={site.id}
|
||||
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
||||
eventHandlers={{
|
||||
click: () => handleAddToRoute(site),
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-sm">{site.name}</h4>
|
||||
<p className="text-xs text-muted-foreground">{site.address}</p>
|
||||
{site.serviceTypeName && (
|
||||
@ -207,10 +403,16 @@ export default function PlanningMobile() {
|
||||
{site.serviceTypeName}
|
||||
</Badge>
|
||||
)}
|
||||
{isInRoute && (
|
||||
<Badge className="text-xs bg-green-600">
|
||||
Tappa {routeIndex + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center bg-muted/20">
|
||||
@ -242,10 +444,14 @@ export default function PlanningMobile() {
|
||||
{sitesLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Caricamento...</p>
|
||||
) : mobileSites && mobileSites.length > 0 ? (
|
||||
mobileSites.map((site) => (
|
||||
mobileSites.map((site) => {
|
||||
const isInRoute = patrolRoute.some(s => s.id === site.id);
|
||||
const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={site.id}
|
||||
className="p-4 border rounded-lg space-y-2 hover-elevate cursor-pointer"
|
||||
className="p-4 border rounded-lg space-y-2"
|
||||
data-testid={`site-card-${site.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@ -265,18 +471,34 @@ export default function PlanningMobile() {
|
||||
<Badge variant="outline" data-testid={`badge-service-${site.id}`}>
|
||||
{site.serviceTypeName}
|
||||
</Badge>
|
||||
{isInRoute && (
|
||||
<Badge className="bg-green-600">
|
||||
Tappa {routeIndex + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button size="sm" variant="default" data-testid={`button-assign-${site.id}`}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handleAssignGuard(site)}
|
||||
data-testid={`button-assign-${site.id}`}
|
||||
>
|
||||
Assegna Guardia
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" data-testid={`button-view-${site.id}`}>
|
||||
Dettagli
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleZoomToSite(site)}
|
||||
data-testid={`button-zoom-${site.id}`}
|
||||
>
|
||||
<Navigation className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-8 space-y-2">
|
||||
<MapPin className="h-12 w-12 mx-auto text-muted-foreground" />
|
||||
|
||||
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.44_20251023_143746.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.44_20251023_143746.sql.gz
Normal file
Binary file not shown.
25
replit.md
25
replit.md
@ -86,6 +86,31 @@ To prevent timezone-related bugs, especially when assigning shifts, dates should
|
||||
- Graceful fallback for sites without coordinates
|
||||
- **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)
|
||||
- **Issue**: Sites form used hardcoded `shiftType` enum values instead of dynamic service types from the database
|
||||
- **Solution**:
|
||||
|
||||
10
version.json
10
version.json
@ -1,7 +1,13 @@
|
||||
{
|
||||
"version": "1.0.43",
|
||||
"lastUpdate": "2025-10-23T14:05:10.759Z",
|
||||
"version": "1.0.44",
|
||||
"lastUpdate": "2025-10-23T14:38:03.431Z",
|
||||
"changelog": [
|
||||
{
|
||||
"version": "1.0.44",
|
||||
"date": "2025-10-23",
|
||||
"type": "patch",
|
||||
"description": "Deployment automatico v1.0.44"
|
||||
},
|
||||
{
|
||||
"version": "1.0.43",
|
||||
"date": "2025-10-23",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user