Compare commits

...

7 Commits

Author SHA1 Message Date
Marco Lanzara
3fc0c55fd2 🚀 Release v1.0.58
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.58_20251025_090445.sql.gz
- Data: 2025-10-25 09:05:02
2025-10-25 09:05:02 +00:00
marco370
2eb53bb1b2 Improve security by updating user authentication protocols
Update authentication logic and dependencies to enhance system security and compliance.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 09:01:45 +00:00
marco370
a9f3453755 Improve mobile patrol planning with smart assignments and route optimization
Enhance Planning Mobile functionality with smart site assignment indicators, drag-and-drop reordering using @dnd-kit, and OSRM API integration for route optimization.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 09:01:11 +00:00
marco370
dd84ddb35b Add route optimization and display results in mobile planning
Integrate a new API endpoint for route optimization, update the UI to include Sparkles icon, and implement state management for optimization results including total distance and estimated time.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 08:59:53 +00:00
marco370
5c22ec14f1 Add route optimization to improve patrol efficiency
Implement a new API endpoint that uses OSRM and the Nearest Neighbor algorithm to optimize delivery routes, calculating distances, durations, and providing an ordered list of stops.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 08:53:50 +00:00
marco370
efa056dd98 Allow users to reorder planned stops using drag-and-drop
Integrate @dnd-kit for implementing drag-and-drop functionality to reorder stops within the mobile planning view.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 08:52:36 +00:00
marco370
b132082ffc Improve patrol planning by showing assigned guards and scrolling to sequences
Add functionality to display assigned guards for patrol routes and scroll to patrol sequences section.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/Z8fg4as
2025-10-25 08:50:05 +00:00
8 changed files with 492 additions and 45 deletions

View File

@ -6,8 +6,25 @@ 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, Copy } from "lucide-react"; import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered, Copy, GripVertical, Sparkles } from "lucide-react";
import { format, parseISO, isValid, addDays } from "date-fns"; import { format, parseISO, isValid, addDays } from "date-fns";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -61,6 +78,50 @@ type MobileSite = {
longitude: string | null; longitude: string | null;
}; };
// Componente per tappa draggable
function SortableStop({ site, index, onRemove }: { site: MobileSite; index: number; onRemove: () => void }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: site.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20 cursor-move"
data-testid={`route-stop-${index}`}
>
<div {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
<Badge className="bg-green-600">
{index + 1}
</Badge>
<span className="text-sm font-medium flex-1">{site.name}</span>
<Button
size="sm"
variant="ghost"
onClick={onRemove}
className="h-6 w-6 p-0"
data-testid={`button-remove-stop-${index}`}
>
</Button>
</div>
);
}
type AvailableGuard = { type AvailableGuard = {
id: string; id: string;
firstName: string; firstName: string;
@ -108,6 +169,41 @@ export default function PlanningMobile() {
selectedDuplicateGuardId: "", selectedDuplicateGuardId: "",
}); });
// State per dialog risultati ottimizzazione
const [optimizationResults, setOptimizationResults] = useState<{
isOpen: boolean;
totalDistanceKm: string;
estimatedTime: string;
}>({
isOpen: false,
totalDistanceKm: "",
estimatedTime: "",
});
// Ref per scroll alla sezione sequenze pattuglia
const patrolSequencesRef = useRef<HTMLDivElement>(null);
// Sensors per drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Handler per riordinare le tappe con drag-and-drop
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setPatrolRoute((items) => {
const oldIndex = items.findIndex(item => item.id === active.id);
const newIndex = items.findIndex(item => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
// Query siti mobile per location // Query siti mobile per location
const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({ const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({
queryKey: ["/api/planning-mobile/sites", selectedLocation], queryKey: ["/api/planning-mobile/sites", selectedLocation],
@ -254,6 +350,34 @@ export default function PlanningMobile() {
} }
}; };
// Funzione per trovare la guardia assegnata a un sito
const findAssignedGuard = (siteId: string) => {
if (!existingPatrolRoutes) return null;
for (const route of existingPatrolRoutes) {
const hasStop = route.stops?.some((stop: any) => stop.siteId === siteId);
if (hasStop) {
const guard = availableGuards?.find(g => g.id === route.guardId);
return guard || null;
}
}
return null;
};
// Funzione per fare scroll alla sezione sequenze pattuglia
const handleScrollToPatrolSequences = () => {
if (patrolSequencesRef.current) {
patrolSequencesRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
toast({
title: "Sequenza visualizzata",
description: "Scorri la lista delle sequenze pattuglia",
});
}
};
// Funzione per assegnare guardia a un sito // Funzione per assegnare guardia a un sito
const handleAssignGuard = (site: MobileSite) => { const handleAssignGuard = (site: MobileSite) => {
if (!selectedGuard) { if (!selectedGuard) {
@ -358,6 +482,36 @@ export default function PlanningMobile() {
}); });
}; };
// Mutation per ottimizzare percorso
const optimizeRouteMutation = useMutation({
mutationFn: async (coordinates: { lat: string; lon: string; id: string; name: string }[]) => {
const response = await apiRequest("POST", "/api/optimize-route", { coordinates });
return response.json();
},
onSuccess: (data: any) => {
// Riordina le tappe secondo l'ordine ottimizzato
const optimizedStops = data.optimizedRoute.map((coord: any) =>
patrolRoute.find(site => site.id === coord.id)
).filter((site: any) => site !== undefined) as MobileSite[];
setPatrolRoute(optimizedStops);
// Mostra dialog con risultati
setOptimizationResults({
isOpen: true,
totalDistanceKm: data.totalDistanceKm,
estimatedTime: data.estimatedTimeFormatted,
});
},
onError: (error: any) => {
toast({
title: "Errore ottimizzazione",
description: error.message || "Impossibile ottimizzare il percorso",
variant: "destructive",
});
},
});
// Mutation per salvare patrol route // Mutation per salvare patrol route
const savePatrolRouteMutation = useMutation({ const savePatrolRouteMutation = useMutation({
mutationFn: async ({ data, existingRouteId }: { data: any; existingRouteId?: string }) => { mutationFn: async ({ data, existingRouteId }: { data: any; existingRouteId?: string }) => {
@ -388,6 +542,45 @@ export default function PlanningMobile() {
}, },
}); });
// Funzione per ottimizzare il percorso
const handleOptimizeRoute = () => {
// Verifica che ci siano almeno 2 tappe
if (patrolRoute.length < 2) {
toast({
title: "Tappe insufficienti",
description: "Servono almeno 2 tappe per ottimizzare il percorso",
variant: "destructive",
});
return;
}
// Verifica che tutte le tappe abbiano coordinate GPS
const sitesWithCoords = patrolRoute.filter(site =>
site.latitude && site.longitude &&
!isNaN(parseFloat(site.latitude)) &&
!isNaN(parseFloat(site.longitude))
);
if (sitesWithCoords.length !== patrolRoute.length) {
toast({
title: "Coordinate GPS mancanti",
description: `${patrolRoute.length - sitesWithCoords.length} tappe non hanno coordinate GPS valide`,
variant: "destructive",
});
return;
}
// Prepara dati per ottimizzazione
const coordinates = sitesWithCoords.map(site => ({
lat: site.latitude!,
lon: site.longitude!,
id: site.id,
name: site.name,
}));
optimizeRouteMutation.mutate(coordinates);
};
// Funzione per salvare il turno pattuglia // Funzione per salvare il turno pattuglia
const handleSavePatrolRoute = () => { const handleSavePatrolRoute = () => {
if (!selectedGuard) { if (!selectedGuard) {
@ -531,38 +724,48 @@ export default function PlanningMobile() {
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{patrolRoute.length} tappe programmate per il turno del {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })} {patrolRoute.length} tappe programmate per il turno del {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
<br />
<span className="text-xs">Trascina le tappe per riordinarle</span>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex gap-2 flex-wrap"> <DndContext
{patrolRoute.map((site, index) => ( sensors={sensors}
<div collisionDetection={closestCenter}
key={site.id} onDragEnd={handleDragEnd}
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20" >
data-testid={`route-stop-${index}`} <SortableContext
> items={patrolRoute.map(site => site.id)}
<Badge className="bg-green-600"> strategy={verticalListSortingStrategy}
{index + 1} >
</Badge> <div className="space-y-2">
<span className="text-sm font-medium">{site.name}</span> {patrolRoute.map((site, index) => (
<Button <SortableStop
size="sm" key={site.id}
variant="ghost" site={site}
onClick={() => handleRemoveFromRoute(site.id)} index={index}
className="h-6 w-6 p-0" onRemove={() => handleRemoveFromRoute(site.id)}
data-testid={`button-remove-stop-${index}`} />
> ))}
</Button>
</div> </div>
))} </SortableContext>
</div> </DndContext>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2 flex-wrap">
<Button <Button
onClick={handleSavePatrolRoute} onClick={handleSavePatrolRoute}
disabled={savePatrolRouteMutation.isPending}
data-testid="button-save-patrol-route" data-testid="button-save-patrol-route"
> >
Salva Turno Pattuglia {savePatrolRouteMutation.isPending ? "Salvataggio..." : "Salva Turno Pattuglia"}
</Button>
<Button
variant="secondary"
onClick={handleOptimizeRoute}
disabled={optimizeRouteMutation.isPending || patrolRoute.length < 2}
data-testid="button-optimize-route"
>
<Sparkles className="h-4 w-4 mr-2" />
{optimizeRouteMutation.isPending ? "Ottimizzazione..." : "Ottimizza Sequenza"}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -669,6 +872,7 @@ export default function PlanningMobile() {
mobileSites.map((site) => { mobileSites.map((site) => {
const isInRoute = patrolRoute.some(s => s.id === site.id); const isInRoute = patrolRoute.some(s => s.id === site.id);
const routeIndex = patrolRoute.findIndex(s => s.id === site.id); const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
const assignedGuard = findAssignedGuard(site.id);
return ( return (
<div <div
@ -700,15 +904,22 @@ export default function PlanningMobile() {
)} )}
</div> </div>
)} )}
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2 items-center">
<Button {assignedGuard ? (
size="sm" <Button
variant="default" size="sm"
onClick={() => handleAssignGuard(site)} variant="default"
data-testid={`button-assign-${site.id}`} onClick={handleScrollToPatrolSequences}
> data-testid={`button-assigned-${site.id}`}
Assegna Guardia >
</Button> <User className="h-4 w-4 mr-2" />
Assegnato a {assignedGuard.firstName} {assignedGuard.lastName}
</Button>
) : (
<span className="text-sm text-muted-foreground" data-testid={`text-not-assigned-${site.id}`}>
Non assegnato
</span>
)}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -788,7 +999,7 @@ export default function PlanningMobile() {
</Card> </Card>
{/* Sequenze Pattuglia del Giorno */} {/* Sequenze Pattuglia del Giorno */}
<Card> <Card ref={patrolSequencesRef}>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<ListOrdered className="h-5 w-5" /> <ListOrdered className="h-5 w-5" />
@ -975,6 +1186,67 @@ export default function PlanningMobile() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Dialog Risultati Ottimizzazione */}
<Dialog open={optimizationResults.isOpen} onOpenChange={(open) => {
if (!open) {
setOptimizationResults({
isOpen: false,
totalDistanceKm: "",
estimatedTime: "",
});
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
Percorso Ottimizzato
</DialogTitle>
<DialogDescription>
Il percorso è stato ottimizzato per ridurre i chilometri percorsi
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-muted-foreground">Distanza Totale</Label>
<div className="text-2xl font-bold">{optimizationResults.totalDistanceKm} km</div>
<p className="text-xs text-muted-foreground">
Partenza e ritorno dalla prima tappa
</p>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Tempo Stimato</Label>
<div className="text-2xl font-bold">{optimizationResults.estimatedTime}</div>
<p className="text-xs text-muted-foreground">
Tempo di percorrenza stimato
</p>
</div>
</div>
<div className="p-3 bg-muted/30 rounded-md">
<p className="text-sm">
Le tappe sono state riordinate per minimizzare la distanza percorsa.
Salva il turno pattuglia per confermare le modifiche.
</p>
</div>
</div>
<DialogFooter>
<Button
onClick={() => setOptimizationResults({
isOpen: false,
totalDistanceKm: "",
estimatedTime: "",
})}
data-testid="button-close-optimization-results"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

56
package-lock.json generated
View File

@ -9,6 +9,9 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4", "@neondatabase/serverless": "^0.10.4",
@ -417,6 +420,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@drizzle-team/brocli": { "node_modules/@drizzle-team/brocli": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",

View File

@ -11,6 +11,9 @@
"db:push": "drizzle-kit push" "db:push": "drizzle-kit push"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4", "@neondatabase/serverless": "^0.10.4",

View File

@ -35,7 +35,12 @@ The database supports managing users, guards, certifications, sites, shifts, shi
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch. - **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch.
- **Service Type Classification**: Classifies services as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules. - **Service Type Classification**: Classifies services as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules.
- **Planning Fissi**: Weekly planning grid for fixed posts, enabling shift creation with guard availability checks. Includes weekly shift duplication feature with confirmation dialog and automatic navigation. - **Planning Fissi**: Weekly planning grid for fixed posts, enabling shift creation with guard availability checks. Includes weekly shift duplication feature with confirmation dialog and automatic navigation.
- **Planning Mobile**: Guard-centric interface for mobile services, displaying guard availability and hours, with an interactive Leaflet map showing sites. Includes patrol sequence list view and duplication/modification dialog. - **Planning Mobile**: Guard-centric interface for mobile services, displaying guard availability and hours, with an interactive Leaflet map showing sites. Features include:
- **Smart Site Assignment Indicators**: Sites already in patrol routes display "Assegnato a [Guard Name]" button with scroll-to functionality; unassigned sites show "Non assegnato" text
- **Drag-and-Drop Reordering**: Interactive drag-and-drop using @dnd-kit library for patrol route stops with visual feedback and automatic sequenceOrder persistence
- **Route Optimization**: OSRM API integration with TSP (Traveling Salesman Problem) nearest neighbor algorithm; displays total distance (km) and estimated travel time in dedicated dialog
- **Patrol Sequence List View**: Daily view of planned patrol routes with stops visualization
- **Duplication/Modification Dialog**: Copy routes to different dates or modify assigned guard
- **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export. - **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export.
- **Dashboard Operativa**: Live KPIs and real-time shift status. - **Dashboard Operativa**: Live KPIs and real-time shift status.
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers. - **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
@ -68,3 +73,5 @@ The system handles timezone conversions for shift times, converting Italy local
- **date-fns**: For date manipulation and formatting. - **date-fns**: For date manipulation and formatting.
- **Leaflet**: Interactive map library with react-leaflet bindings and OpenStreetMap tiles. - **Leaflet**: Interactive map library with react-leaflet bindings and OpenStreetMap tiles.
- **Nominatim**: OpenStreetMap geocoding API for automatic address-to-coordinates conversion. - **Nominatim**: OpenStreetMap geocoding API for automatic address-to-coordinates conversion.
- **OSRM (Open Source Routing Machine)**: Public API (router.project-osrm.org) for distance matrix calculation and route optimization in Planning Mobile. No authentication required.
- **@dnd-kit**: Drag-and-drop library (@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities) for interactive patrol route reordering.

View File

@ -4412,6 +4412,115 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// ============= ROUTE OPTIMIZATION API (OSRM + TSP) =============
app.post("/api/optimize-route", isAuthenticated, async (req: any, res) => {
try {
const { coordinates } = req.body;
// Validazione: array di coordinate [{lat, lon, id}]
if (!Array.isArray(coordinates) || coordinates.length < 2) {
return res.status(400).json({
message: "Almeno 2 coordinate richieste per l'ottimizzazione"
});
}
// Verifica formato coordinate
for (const coord of coordinates) {
if (!coord.lat || !coord.lon || !coord.id) {
return res.status(400).json({
message: "Ogni coordinata deve avere lat, lon e id"
});
}
}
// STEP 1: Calcola matrice distanze usando OSRM Table API
const coordsString = coordinates.map(c => `${c.lon},${c.lat}`).join(';');
const osrmTableUrl = `https://router.project-osrm.org/table/v1/driving/${coordsString}?annotations=distance,duration`;
const osrmResponse = await fetch(osrmTableUrl);
if (!osrmResponse.ok) {
throw new Error(`OSRM API error: ${osrmResponse.status}`);
}
const osrmData = await osrmResponse.json();
if (osrmData.code !== 'Ok' || !osrmData.distances || !osrmData.durations) {
throw new Error("OSRM non ha restituito dati validi");
}
const distances = osrmData.distances; // Matrice NxN in metri
const durations = osrmData.durations; // Matrice NxN in secondi
// STEP 2: Applica algoritmo TSP Nearest Neighbor
// Inizia dalla prima tappa (indice 0)
const n = coordinates.length;
const visited = new Set<number>();
const route: number[] = [];
let current = 0;
let totalDistance = 0;
let totalDuration = 0;
visited.add(current);
route.push(current);
// Trova sempre il vicino più vicino non visitato
for (let i = 1; i < n; i++) {
let nearest = -1;
let minDistance = Infinity;
for (let j = 0; j < n; j++) {
if (!visited.has(j) && distances[current][j] < minDistance) {
minDistance = distances[current][j];
nearest = j;
}
}
if (nearest !== -1) {
totalDistance += distances[current][nearest];
totalDuration += durations[current][nearest];
visited.add(nearest);
route.push(nearest);
current = nearest;
}
}
// Ritorna al punto di partenza (circuito chiuso)
totalDistance += distances[current][0];
totalDuration += durations[current][0];
// STEP 3: Prepara risposta
const optimizedRoute = route.map(index => ({
...coordinates[index],
order: route.indexOf(index) + 1,
}));
res.json({
optimizedRoute,
totalDistanceMeters: Math.round(totalDistance),
totalDistanceKm: (totalDistance / 1000).toFixed(2),
totalDurationSeconds: Math.round(totalDuration),
totalDurationMinutes: Math.round(totalDuration / 60),
estimatedTimeFormatted: formatDuration(totalDuration),
});
} catch (error) {
console.error("Error optimizing route:", error);
res.status(500).json({ message: "Errore durante l'ottimizzazione del percorso" });
}
});
// Helper per formattare durata in ore e minuti
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
const httpServer = createServer(app); const httpServer = createServer(app);
return httpServer; return httpServer;
} }

View File

@ -1,7 +1,13 @@
{ {
"version": "1.0.57", "version": "1.0.58",
"lastUpdate": "2025-10-25T08:02:29.362Z", "lastUpdate": "2025-10-25T09:05:02.565Z",
"changelog": [ "changelog": [
{
"version": "1.0.58",
"date": "2025-10-25",
"type": "patch",
"description": "Deployment automatico v1.0.58"
},
{ {
"version": "1.0.57", "version": "1.0.57",
"date": "2025-10-25", "date": "2025-10-25",
@ -295,12 +301,6 @@
"date": "2025-10-17", "date": "2025-10-17",
"type": "patch", "type": "patch",
"description": "Deployment automatico v1.0.9" "description": "Deployment automatico v1.0.9"
},
{
"version": "1.0.8",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.8"
} }
] ]
} }