Compare commits
No commits in common. "3fc0c55fd285d5d8bf1802ba918539680c4e7f7b" and "e5ce415aeb214ce343f02772da52f3a56a28a508" have entirely different histories.
3fc0c55fd2
...
e5ce415aeb
@ -6,25 +6,8 @@ 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, GripVertical, Sparkles } from "lucide-react";
|
import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered, Copy } 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,
|
||||||
@ -78,50 +61,6 @@ 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;
|
||||||
@ -169,41 +108,6 @@ 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],
|
||||||
@ -350,34 +254,6 @@ 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) {
|
||||||
@ -482,36 +358,6 @@ 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 }) => {
|
||||||
@ -542,45 +388,6 @@ 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) {
|
||||||
@ -724,48 +531,38 @@ 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">
|
||||||
<DndContext
|
<div className="flex gap-2 flex-wrap">
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={patrolRoute.map(site => site.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{patrolRoute.map((site, index) => (
|
{patrolRoute.map((site, index) => (
|
||||||
<SortableStop
|
<div
|
||||||
key={site.id}
|
key={site.id}
|
||||||
site={site}
|
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20"
|
||||||
index={index}
|
data-testid={`route-stop-${index}`}
|
||||||
onRemove={() => handleRemoveFromRoute(site.id)}
|
>
|
||||||
/>
|
<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>
|
||||||
</SortableContext>
|
<div className="flex gap-2 pt-2">
|
||||||
</DndContext>
|
|
||||||
<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"
|
||||||
>
|
>
|
||||||
{savePatrolRouteMutation.isPending ? "Salvataggio..." : "Salva Turno Pattuglia"}
|
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"
|
||||||
@ -872,7 +669,6 @@ 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
|
||||||
@ -904,22 +700,15 @@ export default function PlanningMobile() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 pt-2 items-center">
|
<div className="flex gap-2 pt-2">
|
||||||
{assignedGuard ? (
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={handleScrollToPatrolSequences}
|
onClick={() => handleAssignGuard(site)}
|
||||||
data-testid={`button-assigned-${site.id}`}
|
data-testid={`button-assign-${site.id}`}
|
||||||
>
|
>
|
||||||
<User className="h-4 w-4 mr-2" />
|
Assegna Guardia
|
||||||
Assegnato a {assignedGuard.firstName} {assignedGuard.lastName}
|
|
||||||
</Button>
|
</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"
|
||||||
@ -999,7 +788,7 @@ export default function PlanningMobile() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Sequenze Pattuglia del Giorno */}
|
{/* Sequenze Pattuglia del Giorno */}
|
||||||
<Card ref={patrolSequencesRef}>
|
<Card>
|
||||||
<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" />
|
||||||
@ -1186,67 +975,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
database-backups/vigilanzaturni_v1.0.48_20251023_160305.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.48_20251023_160305.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
56
package-lock.json
generated
56
package-lock.json
generated
@ -9,9 +9,6 @@
|
|||||||
"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",
|
||||||
@ -420,59 +417,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -11,9 +11,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -35,12 +35,7 @@ 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. Features include:
|
- **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.
|
||||||
- **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.
|
||||||
@ -73,5 +68,3 @@ 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.
|
|
||||||
109
server/routes.ts
109
server/routes.ts
@ -4412,115 +4412,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
16
version.json
16
version.json
@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.58",
|
"version": "1.0.57",
|
||||||
"lastUpdate": "2025-10-25T09:05:02.565Z",
|
"lastUpdate": "2025-10-25T08:02:29.362Z",
|
||||||
"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",
|
||||||
@ -301,6 +295,12 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user