Compare commits
No commits in common. "84cb7708778c38bec9c021d98a7ca180909d552a" and "92ac90315abe2d1bf3ee058d5b4c9c283e85341f" have entirely different histories.
84cb770877
...
92ac90315a
@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from "react";
|
import { useState, useMemo } 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,12 +6,11 @@ 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, Navigation, ListOrdered } from "lucide-react";
|
import { Calendar, MapPin, User, Car, Clock } 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, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup } 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;
|
||||||
@ -44,29 +43,10 @@ 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[]>({
|
||||||
@ -123,119 +103,6 @@ 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 */}
|
||||||
@ -300,61 +167,6 @@ 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 */}
|
||||||
@ -377,25 +189,17 @@ 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) => (
|
||||||
const isInRoute = patrolRoute.some(s => s.id === site.id);
|
|
||||||
const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Marker
|
<Marker
|
||||||
key={site.id}
|
key={site.id}
|
||||||
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
||||||
eventHandlers={{
|
|
||||||
click: () => handleAddToRoute(site),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<h4 className="font-semibold text-sm">{site.name}</h4>
|
<h4 className="font-semibold text-sm">{site.name}</h4>
|
||||||
<p className="text-xs text-muted-foreground">{site.address}</p>
|
<p className="text-xs text-muted-foreground">{site.address}</p>
|
||||||
{site.serviceTypeName && (
|
{site.serviceTypeName && (
|
||||||
@ -403,16 +207,10 @@ export default function PlanningMobile() {
|
|||||||
{site.serviceTypeName}
|
{site.serviceTypeName}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isInRoute && (
|
|
||||||
<Badge className="text-xs bg-green-600">
|
|
||||||
Tappa {routeIndex + 1}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</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">
|
||||||
@ -444,14 +242,10 @@ 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) => (
|
||||||
const isInRoute = patrolRoute.some(s => s.id === site.id);
|
|
||||||
const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={site.id}
|
key={site.id}
|
||||||
className="p-4 border rounded-lg space-y-2"
|
className="p-4 border rounded-lg space-y-2 hover-elevate cursor-pointer"
|
||||||
data-testid={`site-card-${site.id}`}
|
data-testid={`site-card-${site.id}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@ -471,34 +265,18 @@ export default function PlanningMobile() {
|
|||||||
<Badge variant="outline" data-testid={`badge-service-${site.id}`}>
|
<Badge variant="outline" data-testid={`badge-service-${site.id}`}>
|
||||||
{site.serviceTypeName}
|
{site.serviceTypeName}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isInRoute && (
|
|
||||||
<Badge className="bg-green-600">
|
|
||||||
Tappa {routeIndex + 1}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button
|
<Button size="sm" variant="default" data-testid={`button-assign-${site.id}`}>
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
onClick={() => handleAssignGuard(site)}
|
|
||||||
data-testid={`button-assign-${site.id}`}
|
|
||||||
>
|
|
||||||
Assegna Guardia
|
Assegna Guardia
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" variant="outline" data-testid={`button-view-${site.id}`}>
|
||||||
size="sm"
|
Dettagli
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleZoomToSite(site)}
|
|
||||||
data-testid={`button-zoom-${site.id}`}
|
|
||||||
>
|
|
||||||
<Navigation className="h-4 w-4" />
|
|
||||||
</Button>
|
</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" />
|
||||||
|
|||||||
BIN
database-backups/vigilanzaturni_v1.0.34_20251023_080247.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.34_20251023_080247.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
25
replit.md
25
replit.md
@ -86,31 +86,6 @@ 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**:
|
||||||
|
|||||||
10
version.json
10
version.json
@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.44",
|
"version": "1.0.43",
|
||||||
"lastUpdate": "2025-10-23T14:38:03.431Z",
|
"lastUpdate": "2025-10-23T14:05:10.759Z",
|
||||||
"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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user