Compare commits
6 Commits
fc63a3a081
...
6d2e92c76e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2e92c76e | ||
|
|
c7c0830780 | ||
|
|
cc92c26836 | ||
|
|
50b74cdaba | ||
|
|
d6b9811c2b | ||
|
|
897a674eee |
@ -27,6 +27,9 @@ import GeneralPlanning from "@/pages/general-planning";
|
|||||||
import ServicePlanning from "@/pages/service-planning";
|
import ServicePlanning from "@/pages/service-planning";
|
||||||
import Customers from "@/pages/customers";
|
import Customers from "@/pages/customers";
|
||||||
import PlanningMobile from "@/pages/planning-mobile";
|
import PlanningMobile from "@/pages/planning-mobile";
|
||||||
|
import MyShiftsFixed from "@/pages/my-shifts-fixed";
|
||||||
|
import MyShiftsMobile from "@/pages/my-shifts-mobile";
|
||||||
|
import SitePlanningView from "@/pages/site-planning-view";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
@ -51,6 +54,9 @@ function Router() {
|
|||||||
<Route path="/service-planning" component={ServicePlanning} />
|
<Route path="/service-planning" component={ServicePlanning} />
|
||||||
<Route path="/planning-mobile" component={PlanningMobile} />
|
<Route path="/planning-mobile" component={PlanningMobile} />
|
||||||
<Route path="/advanced-planning" component={AdvancedPlanning} />
|
<Route path="/advanced-planning" component={AdvancedPlanning} />
|
||||||
|
<Route path="/my-shifts-fixed" component={MyShiftsFixed} />
|
||||||
|
<Route path="/my-shifts-mobile" component={MyShiftsMobile} />
|
||||||
|
<Route path="/site-planning-view" component={SitePlanningView} />
|
||||||
<Route path="/reports" component={Reports} />
|
<Route path="/reports" component={Reports} />
|
||||||
<Route path="/notifications" component={Notifications} />
|
<Route path="/notifications" component={Notifications} />
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={Users} />
|
||||||
|
|||||||
234
client/src/pages/my-shifts-fixed.tsx
Normal file
234
client/src/pages/my-shifts-fixed.tsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar, MapPin, Clock, Shield, Car, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
|
||||||
|
import { it } from "date-fns/locale";
|
||||||
|
|
||||||
|
interface ShiftAssignment {
|
||||||
|
id: string;
|
||||||
|
shiftId: string;
|
||||||
|
plannedStartTime: string;
|
||||||
|
plannedEndTime: string;
|
||||||
|
armed: boolean;
|
||||||
|
vehicleId: string | null;
|
||||||
|
vehiclePlate: string | null;
|
||||||
|
site: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
location: string;
|
||||||
|
};
|
||||||
|
shift: {
|
||||||
|
shiftDate: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyShiftsFixed() {
|
||||||
|
// Data iniziale: inizio settimana corrente
|
||||||
|
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
||||||
|
const today = new Date();
|
||||||
|
return startOfWeek(today, { weekStartsOn: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query per recuperare i turni fissi della guardia loggata
|
||||||
|
const { data: user } = useQuery<any>({
|
||||||
|
queryKey: ["/api/auth/user"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: myShifts, isLoading } = useQuery<ShiftAssignment[]>({
|
||||||
|
queryKey: ["/api/my-shifts/fixed", currentWeekStart.toISOString()],
|
||||||
|
queryFn: async () => {
|
||||||
|
const weekEnd = addDays(currentWeekStart, 6);
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/my-shifts/fixed?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch shifts");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Naviga settimana precedente
|
||||||
|
const handlePreviousWeek = () => {
|
||||||
|
setCurrentWeekStart(addDays(currentWeekStart, -7));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Naviga settimana successiva
|
||||||
|
const handleNextWeek = () => {
|
||||||
|
setCurrentWeekStart(addDays(currentWeekStart, 7));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Raggruppa i turni per giorno
|
||||||
|
const shiftsByDay = myShifts?.reduce((acc, shift) => {
|
||||||
|
const date = shift.shift.shiftDate;
|
||||||
|
if (!acc[date]) acc[date] = [];
|
||||||
|
acc[date].push(shift);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, ShiftAssignment[]>) || {};
|
||||||
|
|
||||||
|
// Genera array di 7 giorni della settimana
|
||||||
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
|
||||||
|
|
||||||
|
const locationLabels: Record<string, string> = {
|
||||||
|
roccapiemonte: "Roccapiemonte",
|
||||||
|
milano: "Milano",
|
||||||
|
roma: "Roma",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold" data-testid="title-my-shifts-fixed">
|
||||||
|
I Miei Turni Fissi
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Visualizza i tuoi turni con orari e dotazioni operative
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigazione settimana */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePreviousWeek}
|
||||||
|
data-testid="button-prev-week"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
Settimana Precedente
|
||||||
|
</Button>
|
||||||
|
<CardTitle className="text-center">
|
||||||
|
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
|
||||||
|
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextWeek}
|
||||||
|
data-testid="button-next-week"
|
||||||
|
>
|
||||||
|
Settimana Successiva
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
Caricamento turni...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Griglia giorni settimana */}
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const dateStr = format(day, "yyyy-MM-dd");
|
||||||
|
const dayShifts = shiftsByDay[dateStr] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{format(day, "EEEE dd/MM", { locale: it })}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{dayShifts.length === 0
|
||||||
|
? "Nessun turno"
|
||||||
|
: `${dayShifts.length} turno${dayShifts.length > 1 ? "i" : ""}`}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{dayShifts.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||||
|
Riposo
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
dayShifts.map((shift) => {
|
||||||
|
// Parsing sicuro orari
|
||||||
|
let startTime = "N/A";
|
||||||
|
let endTime = "N/A";
|
||||||
|
|
||||||
|
if (shift.plannedStartTime) {
|
||||||
|
const parsedStart = parseISO(shift.plannedStartTime);
|
||||||
|
if (isValid(parsedStart)) {
|
||||||
|
startTime = format(parsedStart, "HH:mm");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shift.plannedEndTime) {
|
||||||
|
const parsedEnd = parseISO(shift.plannedEndTime);
|
||||||
|
if (isValid(parsedEnd)) {
|
||||||
|
endTime = format(parsedEnd, "HH:mm");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={shift.id}
|
||||||
|
className="border rounded-lg p-3 space-y-2"
|
||||||
|
data-testid={`shift-${shift.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="font-semibold text-sm">{shift.site.name}</p>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
<span>{locationLabels[shift.site.location] || shift.site.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{startTime} - {endTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dotazioni */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{shift.armed && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
Armato
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{shift.vehicleId && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Car className="h-3 w-3 mr-1" />
|
||||||
|
{shift.vehiclePlate || "Automezzo"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-1 border-t text-xs text-muted-foreground">
|
||||||
|
{shift.site.address}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
client/src/pages/my-shifts-mobile.tsx
Normal file
247
client/src/pages/my-shifts-mobile.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar, MapPin, Navigation, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
|
||||||
|
import { it } from "date-fns/locale";
|
||||||
|
|
||||||
|
interface PatrolRouteStop {
|
||||||
|
siteId: string;
|
||||||
|
siteName: string;
|
||||||
|
siteAddress: string;
|
||||||
|
sequenceOrder: number;
|
||||||
|
latitude: string | null;
|
||||||
|
longitude: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PatrolRoute {
|
||||||
|
id: string;
|
||||||
|
shiftDate: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
location: string;
|
||||||
|
status: string;
|
||||||
|
vehicleId: string | null;
|
||||||
|
vehiclePlate: string | null;
|
||||||
|
stops: PatrolRouteStop[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyShiftsMobile() {
|
||||||
|
// Data iniziale: inizio settimana corrente
|
||||||
|
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
||||||
|
const today = new Date();
|
||||||
|
return startOfWeek(today, { weekStartsOn: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query per recuperare i turni mobile della guardia loggata
|
||||||
|
const { data: user } = useQuery<any>({
|
||||||
|
queryKey: ["/api/auth/user"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: myRoutes, isLoading } = useQuery<PatrolRoute[]>({
|
||||||
|
queryKey: ["/api/my-shifts/mobile", currentWeekStart.toISOString()],
|
||||||
|
queryFn: async () => {
|
||||||
|
const weekEnd = addDays(currentWeekStart, 6);
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/my-shifts/mobile?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch patrol routes");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Naviga settimana precedente
|
||||||
|
const handlePreviousWeek = () => {
|
||||||
|
setCurrentWeekStart(addDays(currentWeekStart, -7));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Naviga settimana successiva
|
||||||
|
const handleNextWeek = () => {
|
||||||
|
setCurrentWeekStart(addDays(currentWeekStart, 7));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Raggruppa i patrol routes per giorno
|
||||||
|
const routesByDay = myRoutes?.reduce((acc, route) => {
|
||||||
|
const date = route.shiftDate;
|
||||||
|
if (!acc[date]) acc[date] = [];
|
||||||
|
acc[date].push(route);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, PatrolRoute[]>) || {};
|
||||||
|
|
||||||
|
// Genera array di 7 giorni della settimana
|
||||||
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
|
||||||
|
|
||||||
|
const locationLabels: Record<string, string> = {
|
||||||
|
roccapiemonte: "Roccapiemonte",
|
||||||
|
milano: "Milano",
|
||||||
|
roma: "Roma",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
planned: "Pianificato",
|
||||||
|
in_progress: "In Corso",
|
||||||
|
completed: "Completato",
|
||||||
|
cancelled: "Annullato",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
planned: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||||
|
in_progress: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||||
|
completed: "bg-gray-500/10 text-gray-500 border-gray-500/20",
|
||||||
|
cancelled: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold" data-testid="title-my-shifts-mobile">
|
||||||
|
I Miei Turni Pattuglia
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Visualizza i tuoi percorsi di pattuglia con sequenza tappe
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigazione settimana */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePreviousWeek}
|
||||||
|
data-testid="button-prev-week"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
Settimana Precedente
|
||||||
|
</Button>
|
||||||
|
<CardTitle className="text-center">
|
||||||
|
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
|
||||||
|
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextWeek}
|
||||||
|
data-testid="button-next-week"
|
||||||
|
>
|
||||||
|
Settimana Successiva
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
Caricamento turni pattuglia...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Griglia giorni settimana */}
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const dateStr = format(day, "yyyy-MM-dd");
|
||||||
|
const dayRoutes = routesByDay[dateStr] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{format(day, "EEEE dd/MM", { locale: it })}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{dayRoutes.length === 0
|
||||||
|
? "Nessuna pattuglia"
|
||||||
|
: `${dayRoutes.length} pattuglia${dayRoutes.length > 1 ? "e" : ""}`}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{dayRoutes.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||||
|
Riposo
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
dayRoutes.map((route) => (
|
||||||
|
<div
|
||||||
|
key={route.id}
|
||||||
|
className="border rounded-lg p-3 space-y-3"
|
||||||
|
data-testid={`patrol-route-${route.id}`}
|
||||||
|
>
|
||||||
|
{/* Header pattuglia */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Navigation className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-semibold text-sm">
|
||||||
|
Pattuglia {locationLabels[route.location]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
<span>{route.stops.length} tappe</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={statusColors[route.status] || ""}
|
||||||
|
>
|
||||||
|
{statusLabels[route.status] || route.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sequenza tappe */}
|
||||||
|
<div className="space-y-2 pl-4 border-l-2 border-muted">
|
||||||
|
{route.stops
|
||||||
|
.sort((a, b) => a.sequenceOrder - b.sequenceOrder)
|
||||||
|
.map((stop, index) => (
|
||||||
|
<div
|
||||||
|
key={stop.siteId}
|
||||||
|
className="space-y-1"
|
||||||
|
data-testid={`stop-${index}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Badge className="bg-green-600 h-5 w-5 p-0 flex items-center justify-center text-xs">
|
||||||
|
{stop.sequenceOrder}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex-1 space-y-0.5">
|
||||||
|
<p className="text-sm font-medium leading-tight">
|
||||||
|
{stop.siteName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground leading-tight">
|
||||||
|
{stop.siteAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info veicolo */}
|
||||||
|
{route.vehiclePlate && (
|
||||||
|
<div className="pt-2 border-t text-xs text-muted-foreground">
|
||||||
|
Automezzo: {route.vehiclePlate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from "react";
|
import { useState, useMemo, useRef, useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation } 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";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -12,6 +12,8 @@ import { it } from "date-fns/locale";
|
|||||||
import { MapContainer, TileLayer, Marker, Popup, useMap } 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";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { queryClient } from "@/lib/queryClient";
|
||||||
|
|
||||||
// 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;
|
||||||
@ -21,6 +23,17 @@ L.Icon.Default.mergeOptions({
|
|||||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom icon verde per marker selezionati nella patrol route
|
||||||
|
const greenIcon = new L.Icon({
|
||||||
|
iconRetinaUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
|
||||||
|
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
shadowSize: [41, 41]
|
||||||
|
});
|
||||||
|
|
||||||
type Location = "roccapiemonte" | "milano" | "roma";
|
type Location = "roccapiemonte" | "milano" | "roma";
|
||||||
|
|
||||||
type MobileSite = {
|
type MobileSite = {
|
||||||
@ -94,6 +107,24 @@ export default function PlanningMobile() {
|
|||||||
enabled: !!selectedLocation && !!selectedDate,
|
enabled: !!selectedLocation && !!selectedDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Query patrol routes esistenti per guardia selezionata e data
|
||||||
|
const { data: existingPatrolRoutes } = useQuery<any[]>({
|
||||||
|
queryKey: ["/api/patrol-routes", selectedGuardId, selectedDate, selectedLocation],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedGuardId && selectedGuardId !== "all") {
|
||||||
|
params.set("guardId", selectedGuardId);
|
||||||
|
}
|
||||||
|
params.set("date", selectedDate);
|
||||||
|
params.set("location", selectedLocation);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/patrol-routes?${params.toString()}`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch patrol routes");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedDate && !!selectedLocation,
|
||||||
|
});
|
||||||
|
|
||||||
const locationLabels: Record<Location, string> = {
|
const locationLabels: Record<Location, string> = {
|
||||||
roccapiemonte: "Roccapiemonte",
|
roccapiemonte: "Roccapiemonte",
|
||||||
milano: "Milano",
|
milano: "Milano",
|
||||||
@ -202,6 +233,29 @@ export default function PlanningMobile() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mutation per salvare patrol route
|
||||||
|
const savePatrolRouteMutation = useMutation({
|
||||||
|
mutationFn: async (data: any) => {
|
||||||
|
const response = await apiRequest("POST", "/api/patrol-routes", data);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Turno pattuglia salvato",
|
||||||
|
description: `${patrolRoute.length} tappe assegnate a ${selectedGuard?.firstName} ${selectedGuard?.lastName}`,
|
||||||
|
});
|
||||||
|
setPatrolRoute([]);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/patrol-routes"] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Errore salvataggio",
|
||||||
|
description: error.message || "Impossibile salvare il turno pattuglia",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Funzione per salvare il turno pattuglia
|
// Funzione per salvare il turno pattuglia
|
||||||
const handleSavePatrolRoute = () => {
|
const handleSavePatrolRoute = () => {
|
||||||
if (!selectedGuard) {
|
if (!selectedGuard) {
|
||||||
@ -222,19 +276,46 @@ export default function PlanningMobile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implementare chiamata API per salvare turno
|
// Prepara i dati per il salvataggio
|
||||||
toast({
|
const patrolRouteData = {
|
||||||
title: "Turno pattuglia creato",
|
guardId: selectedGuard.id,
|
||||||
description: `${patrolRoute.length} tappe assegnate a ${selectedGuard.firstName} ${selectedGuard.lastName}`,
|
shiftDate: selectedDate,
|
||||||
});
|
startTime: "08:00", // TODO: permettere all'utente di configurare
|
||||||
|
endTime: "20:00",
|
||||||
setPatrolRoute([]);
|
location: selectedLocation,
|
||||||
|
status: "planned",
|
||||||
|
stops: patrolRoute.map((site) => ({
|
||||||
|
siteId: site.id,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset patrol route quando cambia guardia o location
|
savePatrolRouteMutation.mutate(patrolRouteData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Carica patrol route esistente quando si seleziona una guardia
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Reset route quando cambia guardia o location
|
||||||
setPatrolRoute([]);
|
setPatrolRoute([]);
|
||||||
}, [selectedGuardId, selectedLocation]);
|
|
||||||
|
// Carica route esistente se c'è una guardia selezionata
|
||||||
|
if (selectedGuardId && selectedGuardId !== "all" && existingPatrolRoutes && existingPatrolRoutes.length > 0) {
|
||||||
|
const guardRoute = existingPatrolRoutes.find(r => r.guardId === selectedGuardId);
|
||||||
|
if (guardRoute && guardRoute.stops && guardRoute.stops.length > 0) {
|
||||||
|
// Ricostruisci il patrol route dai stops esistenti
|
||||||
|
const loadedRoute = guardRoute.stops
|
||||||
|
.sort((a: any, b: any) => a.sequenceOrder - b.sequenceOrder)
|
||||||
|
.map((stop: any) => mobileSites?.find(s => s.id === stop.siteId))
|
||||||
|
.filter((site: any) => site !== undefined) as MobileSite[];
|
||||||
|
|
||||||
|
setPatrolRoute(loadedRoute);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Turno pattuglia caricato",
|
||||||
|
description: `${loadedRoute.length} tappe caricate per ${selectedGuard?.firstName} ${selectedGuard?.lastName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedGuardId, selectedLocation, existingPatrolRoutes, mobileSites]);
|
||||||
|
|
||||||
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">
|
||||||
@ -390,6 +471,7 @@ export default function PlanningMobile() {
|
|||||||
<Marker
|
<Marker
|
||||||
key={site.id}
|
key={site.id}
|
||||||
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
||||||
|
icon={isInRoute ? greenIcon : undefined}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: () => handleAddToRoute(site),
|
click: () => handleAddToRoute(site),
|
||||||
}}
|
}}
|
||||||
|
|||||||
274
client/src/pages/site-planning-view.tsx
Normal file
274
client/src/pages/site-planning-view.tsx
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { MapPin, Shield, Car, Clock, User, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
|
||||||
|
import { it } from "date-fns/locale";
|
||||||
|
|
||||||
|
interface GuardAssignment {
|
||||||
|
guardId: string;
|
||||||
|
guardName: string;
|
||||||
|
badgeNumber: string;
|
||||||
|
plannedStartTime: string;
|
||||||
|
plannedEndTime: string;
|
||||||
|
armed: boolean;
|
||||||
|
vehicleId: string | null;
|
||||||
|
vehiclePlate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteDayPlan {
|
||||||
|
date: string;
|
||||||
|
guards: GuardAssignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Site {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SitePlanningView() {
|
||||||
|
const [selectedSiteId, setSelectedSiteId] = useState<string>("");
|
||||||
|
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
||||||
|
const today = new Date();
|
||||||
|
return startOfWeek(today, { weekStartsOn: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query sites
|
||||||
|
const { data: sites } = useQuery<Site[]>({
|
||||||
|
queryKey: ["/api/sites"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query site planning
|
||||||
|
const { data: sitePlanning, isLoading } = useQuery<SiteDayPlan[]>({
|
||||||
|
queryKey: ["/api/site-planning", selectedSiteId, currentWeekStart.toISOString()],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!selectedSiteId) return [];
|
||||||
|
const weekEnd = addDays(currentWeekStart, 6);
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/site-planning/${selectedSiteId}?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch site planning");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedSiteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Naviga settimana precedente
|
||||||
|
const handlePreviousWeek = () => {
|
||||||
|
setCurrentWeekStart(addDays(currentWeekStart, -7));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Naviga settimana successiva
|
||||||
|
const handleNextWeek = () => {
|
||||||
|
setCurrentWeekStart(addDays(currentWeekStart, 7));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Raggruppa per giorno
|
||||||
|
const planningByDay = sitePlanning?.reduce((acc, day) => {
|
||||||
|
acc[day.date] = day.guards;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, GuardAssignment[]>) || {};
|
||||||
|
|
||||||
|
// Genera array di 7 giorni della settimana
|
||||||
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
|
||||||
|
|
||||||
|
const selectedSite = sites?.find(s => s.id === selectedSiteId);
|
||||||
|
|
||||||
|
const locationLabels: Record<string, string> = {
|
||||||
|
roccapiemonte: "Roccapiemonte",
|
||||||
|
milano: "Milano",
|
||||||
|
roma: "Roma",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold" data-testid="title-site-planning-view">
|
||||||
|
Planning per Sito
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Visualizza tutti gli agenti assegnati a un sito con dotazioni
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selettore sito */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Seleziona Sito</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Select value={selectedSiteId} onValueChange={setSelectedSiteId}>
|
||||||
|
<SelectTrigger data-testid="select-site">
|
||||||
|
<SelectValue placeholder="Seleziona un sito..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sites?.map((site) => (
|
||||||
|
<SelectItem key={site.id} value={site.id} data-testid={`site-option-${site.id}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{site.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({locationLabels[site.location] || site.location})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{selectedSite && (
|
||||||
|
<div className="p-3 border rounded-lg bg-muted/20">
|
||||||
|
<p className="font-semibold">{selectedSite.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedSite.address}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Navigazione settimana */}
|
||||||
|
{selectedSiteId && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePreviousWeek}
|
||||||
|
data-testid="button-prev-week"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
|
Settimana Precedente
|
||||||
|
</Button>
|
||||||
|
<CardTitle className="text-center">
|
||||||
|
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
|
||||||
|
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextWeek}
|
||||||
|
data-testid="button-next-week"
|
||||||
|
>
|
||||||
|
Settimana Successiva
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
Caricamento planning sito...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Griglia giorni settimana */}
|
||||||
|
{selectedSiteId && !isLoading && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const dateStr = format(day, "yyyy-MM-dd");
|
||||||
|
const dayGuards = planningByDay[dateStr] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{format(day, "EEEE dd/MM", { locale: it })}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{dayGuards.length === 0
|
||||||
|
? "Nessun agente"
|
||||||
|
: `${dayGuards.length} agente${dayGuards.length > 1 ? "i" : ""}`}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{dayGuards.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||||
|
Nessuna copertura
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
dayGuards.map((guard, index) => {
|
||||||
|
// Parsing sicuro orari
|
||||||
|
let startTime = "N/A";
|
||||||
|
let endTime = "N/A";
|
||||||
|
|
||||||
|
if (guard.plannedStartTime) {
|
||||||
|
const parsedStart = parseISO(guard.plannedStartTime);
|
||||||
|
if (isValid(parsedStart)) {
|
||||||
|
startTime = format(parsedStart, "HH:mm");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guard.plannedEndTime) {
|
||||||
|
const parsedEnd = parseISO(guard.plannedEndTime);
|
||||||
|
if (isValid(parsedEnd)) {
|
||||||
|
endTime = format(parsedEnd, "HH:mm");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${guard.guardId}-${index}`}
|
||||||
|
className="border rounded-lg p-3 space-y-2"
|
||||||
|
data-testid={`guard-assignment-${index}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-semibold text-sm">{guard.guardName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Matricola: {guard.badgeNumber}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{startTime} - {endTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dotazioni */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{guard.armed && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
Armato
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{guard.vehicleId && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Car className="h-3 w-3 mr-1" />
|
||||||
|
{guard.vehiclePlate || "Automezzo"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.46_20251023_152240.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.46_20251023_152240.sql.gz
Normal file
Binary file not shown.
22
replit.md
22
replit.md
@ -120,6 +120,28 @@ To prevent timezone-related bugs, especially when assigning shifts, dates should
|
|||||||
- Card display now shows service type label from database instead of hardcoded labels
|
- Card display now shows service type label from database instead of hardcoded labels
|
||||||
- **Impact**: Sites now correctly reference service types configured in "Tipologie Servizi" page, ensuring consistency across the system
|
- **Impact**: Sites now correctly reference service types configured in "Tipologie Servizi" page, ensuring consistency across the system
|
||||||
|
|
||||||
|
### Advanced Planning System Complete (October 23, 2025)
|
||||||
|
- **Implementation**: Full planning system with guard views, exclusivity constraints, and database persistence
|
||||||
|
- **Features**:
|
||||||
|
- **Patrol Route Database Persistence**:
|
||||||
|
- Backend endpoints: GET/POST/PUT/DELETE `/api/patrol-routes`
|
||||||
|
- Database schema: `patrol_routes` table with `patrol_route_stops` for sequence
|
||||||
|
- Planning Mobile loads existing routes when guard selected, saves to DB
|
||||||
|
- Green markers on map for sites in current patrol route
|
||||||
|
- **Exclusivity Constraint (fisso/mobile)**:
|
||||||
|
- Validation in 3 backend endpoints: POST `/api/patrol-routes`, POST `/api/shift-assignments`, POST `/api/shifts/:shiftId/assignments`
|
||||||
|
- Guards cannot be assigned to both fixed posts and mobile patrols on same date
|
||||||
|
- Clear error messages when constraint violated
|
||||||
|
- **Guard Planning Views**:
|
||||||
|
- `/my-shifts-fixed`: Guards view their fixed post shifts with orari, dotazioni (armato, automezzo), location, sito
|
||||||
|
- `/my-shifts-mobile`: Guards view patrol routes with sequenced site list, addresses, vehicle assignment
|
||||||
|
- Backend endpoints: GET `/api/my-shifts/fixed`, GET `/api/my-shifts/mobile` with date range filters
|
||||||
|
- **Site Planning View**:
|
||||||
|
- `/site-planning-view`: Coordinators view all guards assigned to a site across a week
|
||||||
|
- Shows guard name, badge, orari, dotazioni for each assignment
|
||||||
|
- Backend endpoint: GET `/api/site-planning/:siteId` with date range filters
|
||||||
|
- **Impact**: Complete end-to-end planning system supporting both coordinator and guard roles with database-backed route planning and operational equipment tracking
|
||||||
|
|
||||||
## External Dependencies
|
## External Dependencies
|
||||||
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
|
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
|
||||||
- **Neon**: Managed PostgreSQL database service.
|
- **Neon**: Managed PostgreSQL database service.
|
||||||
|
|||||||
@ -138,13 +138,38 @@ export async function setupLocalAuth(app: Express) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route login locale POST
|
// Route login locale POST
|
||||||
app.post("/api/local-login", passport.authenticate("local"), (req, res) => {
|
app.post("/api/local-login", (req, res, next) => {
|
||||||
res.json({
|
passport.authenticate("local", (err: any, user: any, info: any) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Errore durante il login"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: info?.message || "Email o password non corretti"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.login(user, (loginErr) => {
|
||||||
|
if (loginErr) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Errore durante il login"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
message: "Login effettuato con successo"
|
message: "Login effettuato con successo"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Route logout
|
// Route logout
|
||||||
|
|||||||
496
server/routes.ts
496
server/routes.ts
@ -4,7 +4,7 @@ import { storage } from "./storage";
|
|||||||
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
||||||
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema, customers } from "@shared/schema";
|
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema, customers, patrolRoutes, patrolRouteStops, insertPatrolRouteSchema } from "@shared/schema";
|
||||||
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
||||||
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -2646,6 +2646,32 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
// ============= SHIFT ASSIGNMENT ROUTES =============
|
// ============= SHIFT ASSIGNMENT ROUTES =============
|
||||||
app.post("/api/shift-assignments", isAuthenticated, async (req, res) => {
|
app.post("/api/shift-assignments", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const { shiftId, guardId } = req.body;
|
||||||
|
|
||||||
|
// Recupera il shift per ottenere la data
|
||||||
|
const [shift] = await db.select().from(shifts).where(eq(shifts.id, shiftId)).limit(1);
|
||||||
|
if (!shift) {
|
||||||
|
return res.status(404).json({ message: "Turno non trovato" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// VINCOLO ESCLUSIVITÀ: Verifica che la guardia NON abbia patrol routes (turni mobile) nella stessa data
|
||||||
|
const existingMobileShifts = await db
|
||||||
|
.select()
|
||||||
|
.from(patrolRoutes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(patrolRoutes.guardId, guardId),
|
||||||
|
eq(patrolRoutes.shiftDate, shift.shiftDate)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingMobileShifts.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: `Vincolo esclusività: la guardia è già assegnata a un turno pattuglia mobile in questa data. Una guardia non può essere assegnata contemporaneamente a turni fissi e mobile.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const assignment = await storage.createShiftAssignment(req.body);
|
const assignment = await storage.createShiftAssignment(req.body);
|
||||||
res.json(assignment);
|
res.json(assignment);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -2688,6 +2714,30 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
return res.status(400).json({ message: "plannedEndTime must be after plannedStartTime" });
|
return res.status(400).json({ message: "plannedEndTime must be after plannedStartTime" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recupera il shift per ottenere la data
|
||||||
|
const [shift] = await db.select().from(shifts).where(eq(shifts.id, shiftId)).limit(1);
|
||||||
|
if (!shift) {
|
||||||
|
return res.status(404).json({ message: "Turno non trovato" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// VINCOLO ESCLUSIVITÀ: Verifica che la guardia NON abbia patrol routes (turni mobile) nella stessa data
|
||||||
|
const existingMobileShifts = await db
|
||||||
|
.select()
|
||||||
|
.from(patrolRoutes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(patrolRoutes.guardId, guardId),
|
||||||
|
eq(patrolRoutes.shiftDate, shift.shiftDate)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingMobileShifts.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: `Vincolo esclusività: la guardia è già assegnata a un turno pattuglia mobile in questa data. Una guardia non può essere assegnata contemporaneamente a turni fissi e mobile.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create assignment
|
// Create assignment
|
||||||
const assignment = await storage.createShiftAssignment({
|
const assignment = await storage.createShiftAssignment({
|
||||||
shiftId,
|
shiftId,
|
||||||
@ -2706,6 +2756,250 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============= MY SHIFTS (GUARD VIEW) ROUTES =============
|
||||||
|
// GET - Turni fissi della guardia loggata
|
||||||
|
app.get("/api/my-shifts/fixed", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const currentUser = await storage.getUser(userId);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return res.status(401).json({ message: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trova la guardia associata all'utente
|
||||||
|
const [guard] = await db
|
||||||
|
.select()
|
||||||
|
.from(guards)
|
||||||
|
.where(eq(guards.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!guard) {
|
||||||
|
return res.status(404).json({ message: "Guardia non trovata per questo utente" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrai filtri data (opzionali)
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
// Query per recuperare i turni fissi assegnati alla guardia
|
||||||
|
let query = db
|
||||||
|
.select({
|
||||||
|
id: shiftAssignments.id,
|
||||||
|
shiftId: shiftAssignments.shiftId,
|
||||||
|
plannedStartTime: shiftAssignments.plannedStartTime,
|
||||||
|
plannedEndTime: shiftAssignments.plannedEndTime,
|
||||||
|
armed: shiftAssignments.armed,
|
||||||
|
vehicleId: shiftAssignments.vehicleId,
|
||||||
|
vehiclePlate: vehicles.licensePlate,
|
||||||
|
site: {
|
||||||
|
id: sites.id,
|
||||||
|
name: sites.name,
|
||||||
|
address: sites.address,
|
||||||
|
location: sites.location,
|
||||||
|
},
|
||||||
|
shift: {
|
||||||
|
shiftDate: shifts.shiftDate,
|
||||||
|
startTime: shifts.startTime,
|
||||||
|
endTime: shifts.endTime,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(shiftAssignments)
|
||||||
|
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||||
|
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||||||
|
.leftJoin(vehicles, eq(shiftAssignments.vehicleId, vehicles.id))
|
||||||
|
.where(eq(shiftAssignments.guardId, guard.id));
|
||||||
|
|
||||||
|
// Applica filtri data se presenti
|
||||||
|
if (startDate && endDate) {
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isValid(start) && isValid(end)) {
|
||||||
|
query = query.where(
|
||||||
|
and(
|
||||||
|
eq(shiftAssignments.guardId, guard.id),
|
||||||
|
gte(shifts.shiftDate, format(start, "yyyy-MM-dd")),
|
||||||
|
lte(shifts.shiftDate, format(end, "yyyy-MM-dd"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const myShifts = await query.orderBy(asc(shifts.shiftDate), asc(shiftAssignments.plannedStartTime));
|
||||||
|
|
||||||
|
res.json(myShifts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching guard's fixed shifts:", error);
|
||||||
|
res.status(500).json({ message: "Errore caricamento turni fissi" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET - Turni pattuglia mobile della guardia loggata
|
||||||
|
app.get("/api/my-shifts/mobile", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const currentUser = await storage.getUser(userId);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return res.status(401).json({ message: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trova la guardia associata all'utente
|
||||||
|
const [guard] = await db
|
||||||
|
.select()
|
||||||
|
.from(guards)
|
||||||
|
.where(eq(guards.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!guard) {
|
||||||
|
return res.status(404).json({ message: "Guardia non trovata per questo utente" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estrai filtri data (opzionali)
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
// Query per recuperare i patrol routes assegnati alla guardia
|
||||||
|
let query = db
|
||||||
|
.select({
|
||||||
|
id: patrolRoutes.id,
|
||||||
|
shiftDate: patrolRoutes.shiftDate,
|
||||||
|
startTime: patrolRoutes.startTime,
|
||||||
|
endTime: patrolRoutes.endTime,
|
||||||
|
location: patrolRoutes.location,
|
||||||
|
status: patrolRoutes.status,
|
||||||
|
vehicleId: patrolRoutes.vehicleId,
|
||||||
|
vehiclePlate: vehicles.licensePlate,
|
||||||
|
})
|
||||||
|
.from(patrolRoutes)
|
||||||
|
.leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id))
|
||||||
|
.where(eq(patrolRoutes.guardId, guard.id));
|
||||||
|
|
||||||
|
// Applica filtri data se presenti
|
||||||
|
if (startDate && endDate) {
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isValid(start) && isValid(end)) {
|
||||||
|
query = query.where(
|
||||||
|
and(
|
||||||
|
eq(patrolRoutes.guardId, guard.id),
|
||||||
|
gte(patrolRoutes.shiftDate, format(start, "yyyy-MM-dd")),
|
||||||
|
lte(patrolRoutes.shiftDate, format(end, "yyyy-MM-dd"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = await query.orderBy(asc(patrolRoutes.shiftDate), asc(patrolRoutes.startTime));
|
||||||
|
|
||||||
|
// Per ogni route, recupera gli stops
|
||||||
|
const routesWithStops = await Promise.all(
|
||||||
|
routes.map(async (route) => {
|
||||||
|
const stops = await db
|
||||||
|
.select({
|
||||||
|
siteId: patrolRouteStops.siteId,
|
||||||
|
siteName: sites.name,
|
||||||
|
siteAddress: sites.address,
|
||||||
|
sequenceOrder: patrolRouteStops.sequenceOrder,
|
||||||
|
latitude: sites.latitude,
|
||||||
|
longitude: sites.longitude,
|
||||||
|
})
|
||||||
|
.from(patrolRouteStops)
|
||||||
|
.leftJoin(sites, eq(patrolRouteStops.siteId, sites.id))
|
||||||
|
.where(eq(patrolRouteStops.patrolRouteId, route.id))
|
||||||
|
.orderBy(asc(patrolRouteStops.sequenceOrder));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
stops,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(routesWithStops);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching guard's patrol routes:", error);
|
||||||
|
res.status(500).json({ message: "Errore caricamento turni pattuglia" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET - Planning per un sito specifico (tutti gli agenti assegnati)
|
||||||
|
app.get("/api/site-planning/:siteId", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { siteId } = req.params;
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Missing required parameters: startDate, endDate"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (!isValid(start) || !isValid(end)) {
|
||||||
|
return res.status(400).json({ message: "Invalid date format" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query per recuperare tutti i turni del sito nel range di date
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
guardId: guards.id,
|
||||||
|
guardName: sql<string>`${guards.firstName} || ' ' || ${guards.lastName}`,
|
||||||
|
badgeNumber: guards.badgeNumber,
|
||||||
|
shiftDate: shifts.shiftDate,
|
||||||
|
plannedStartTime: shiftAssignments.plannedStartTime,
|
||||||
|
plannedEndTime: shiftAssignments.plannedEndTime,
|
||||||
|
armed: shiftAssignments.armed,
|
||||||
|
vehicleId: shiftAssignments.vehicleId,
|
||||||
|
vehiclePlate: vehicles.licensePlate,
|
||||||
|
})
|
||||||
|
.from(shiftAssignments)
|
||||||
|
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||||
|
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
|
||||||
|
.leftJoin(vehicles, eq(shiftAssignments.vehicleId, vehicles.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(shifts.siteId, siteId),
|
||||||
|
gte(shifts.shiftDate, format(start, "yyyy-MM-dd")),
|
||||||
|
lte(shifts.shiftDate, format(end, "yyyy-MM-dd"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(shifts.shiftDate), asc(shiftAssignments.plannedStartTime));
|
||||||
|
|
||||||
|
// Raggruppa per data
|
||||||
|
const byDay = assignments.reduce((acc, assignment) => {
|
||||||
|
const date = assignment.shiftDate;
|
||||||
|
if (!acc[date]) {
|
||||||
|
acc[date] = [];
|
||||||
|
}
|
||||||
|
acc[date].push({
|
||||||
|
guardId: assignment.guardId,
|
||||||
|
guardName: assignment.guardName,
|
||||||
|
badgeNumber: assignment.badgeNumber,
|
||||||
|
plannedStartTime: assignment.plannedStartTime,
|
||||||
|
plannedEndTime: assignment.plannedEndTime,
|
||||||
|
armed: assignment.armed,
|
||||||
|
vehicleId: assignment.vehicleId,
|
||||||
|
vehiclePlate: assignment.vehiclePlate,
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any[]>);
|
||||||
|
|
||||||
|
// Converti in array
|
||||||
|
const result = Object.entries(byDay).map(([date, guards]) => ({
|
||||||
|
date,
|
||||||
|
guards,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching site planning:", error);
|
||||||
|
res.status(500).json({ message: "Errore caricamento planning sito" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============= NOTIFICATION ROUTES =============
|
// ============= NOTIFICATION ROUTES =============
|
||||||
app.get("/api/notifications", isAuthenticated, async (req: any, res) => {
|
app.get("/api/notifications", isAuthenticated, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
@ -3303,6 +3597,206 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============= PATROL ROUTES API =============
|
||||||
|
|
||||||
|
// GET patrol routes per guardia e data
|
||||||
|
app.get("/api/patrol-routes", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { guardId, date, location } = req.query;
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (guardId && guardId !== "all") {
|
||||||
|
conditions.push(eq(patrolRoutes.guardId, guardId));
|
||||||
|
}
|
||||||
|
if (date) {
|
||||||
|
conditions.push(eq(patrolRoutes.shiftDate, date));
|
||||||
|
}
|
||||||
|
if (location) {
|
||||||
|
conditions.push(eq(patrolRoutes.location, location as any));
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = await db
|
||||||
|
.select({
|
||||||
|
id: patrolRoutes.id,
|
||||||
|
guardId: patrolRoutes.guardId,
|
||||||
|
shiftDate: patrolRoutes.shiftDate,
|
||||||
|
startTime: patrolRoutes.startTime,
|
||||||
|
endTime: patrolRoutes.endTime,
|
||||||
|
status: patrolRoutes.status,
|
||||||
|
location: patrolRoutes.location,
|
||||||
|
vehicleId: patrolRoutes.vehicleId,
|
||||||
|
isArmedRoute: patrolRoutes.isArmedRoute,
|
||||||
|
notes: patrolRoutes.notes,
|
||||||
|
guardFirstName: guards.firstName,
|
||||||
|
guardLastName: guards.lastName,
|
||||||
|
vehiclePlate: vehicles.licensePlate,
|
||||||
|
})
|
||||||
|
.from(patrolRoutes)
|
||||||
|
.leftJoin(guards, eq(patrolRoutes.guardId, guards.id))
|
||||||
|
.leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id))
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(patrolRoutes.shiftDate));
|
||||||
|
|
||||||
|
// Carica stops per ogni route
|
||||||
|
const routesWithStops = await Promise.all(
|
||||||
|
routes.map(async (route) => {
|
||||||
|
const stops = await db
|
||||||
|
.select({
|
||||||
|
id: patrolRouteStops.id,
|
||||||
|
siteId: patrolRouteStops.siteId,
|
||||||
|
siteName: sites.name,
|
||||||
|
siteAddress: sites.address,
|
||||||
|
latitude: sites.latitude,
|
||||||
|
longitude: sites.longitude,
|
||||||
|
sequenceOrder: patrolRouteStops.sequenceOrder,
|
||||||
|
estimatedArrivalTime: patrolRouteStops.estimatedArrivalTime,
|
||||||
|
isCompleted: patrolRouteStops.isCompleted,
|
||||||
|
notes: patrolRouteStops.notes,
|
||||||
|
})
|
||||||
|
.from(patrolRouteStops)
|
||||||
|
.leftJoin(sites, eq(patrolRouteStops.siteId, sites.id))
|
||||||
|
.where(eq(patrolRouteStops.patrolRouteId, route.id))
|
||||||
|
.orderBy(asc(patrolRouteStops.sequenceOrder));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
stops,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(routesWithStops);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching patrol routes:", error);
|
||||||
|
res.status(500).json({ message: "Errore caricamento turni pattuglia" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST - Crea nuovo patrol route con stops
|
||||||
|
app.post("/api/patrol-routes", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const routeData = insertPatrolRouteSchema.parse(req.body);
|
||||||
|
const { stops } = req.body; // Array di siti in sequenza
|
||||||
|
|
||||||
|
// Verifica che non esista già un patrol route per questa guardia/data
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(patrolRoutes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(patrolRoutes.guardId, routeData.guardId),
|
||||||
|
eq(patrolRoutes.shiftDate, routeData.shiftDate)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Esiste già un turno pattuglia per questa guardia in questa data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// VINCOLO ESCLUSIVITÀ: Verifica che la guardia NON abbia shift assignments (turni fissi) nella stessa data
|
||||||
|
const existingFixedShifts = await db
|
||||||
|
.select({
|
||||||
|
shiftId: shifts.id,
|
||||||
|
siteName: sites.name,
|
||||||
|
})
|
||||||
|
.from(shiftAssignments)
|
||||||
|
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||||
|
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(shiftAssignments.guardId, routeData.guardId),
|
||||||
|
eq(shifts.shiftDate, routeData.shiftDate)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingFixedShifts.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: `Vincolo esclusività: la guardia è già assegnata a un turno fisso (${existingFixedShifts[0].siteName}) in questa data. Una guardia non può essere assegnata contemporaneamente a turni fissi e mobile.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crea patrol route
|
||||||
|
const [newRoute] = await db.insert(patrolRoutes).values(routeData).returning();
|
||||||
|
|
||||||
|
// Crea stops se presenti
|
||||||
|
if (stops && Array.isArray(stops) && stops.length > 0) {
|
||||||
|
const stopsData = stops.map((stop: any, index: number) => ({
|
||||||
|
patrolRouteId: newRoute.id,
|
||||||
|
siteId: stop.siteId,
|
||||||
|
sequenceOrder: index + 1,
|
||||||
|
estimatedArrivalTime: stop.estimatedArrivalTime || null,
|
||||||
|
notes: stop.notes || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(patrolRouteStops).values(stopsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(newRoute);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating patrol route:", error);
|
||||||
|
res.status(500).json({ message: "Errore creazione turno pattuglia" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT - Aggiorna patrol route esistente
|
||||||
|
app.put("/api/patrol-routes/:id", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { stops, ...routeData } = req.body;
|
||||||
|
|
||||||
|
// Aggiorna patrol route
|
||||||
|
const [updated] = await db
|
||||||
|
.update(patrolRoutes)
|
||||||
|
.set(routeData)
|
||||||
|
.where(eq(patrolRoutes.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(404).json({ message: "Turno pattuglia non trovato" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se ci sono stops, elimina quelli vecchi e inserisci i nuovi
|
||||||
|
if (stops && Array.isArray(stops)) {
|
||||||
|
await db.delete(patrolRouteStops).where(eq(patrolRouteStops.patrolRouteId, id));
|
||||||
|
|
||||||
|
if (stops.length > 0) {
|
||||||
|
const stopsData = stops.map((stop: any, index: number) => ({
|
||||||
|
patrolRouteId: id,
|
||||||
|
siteId: stop.siteId,
|
||||||
|
sequenceOrder: index + 1,
|
||||||
|
estimatedArrivalTime: stop.estimatedArrivalTime || null,
|
||||||
|
notes: stop.notes || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(patrolRouteStops).values(stopsData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating patrol route:", error);
|
||||||
|
res.status(500).json({ message: "Errore aggiornamento turno pattuglia" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE - Elimina patrol route
|
||||||
|
app.delete("/api/patrol-routes/:id", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
await db.delete(patrolRoutes).where(eq(patrolRoutes.id, id));
|
||||||
|
|
||||||
|
res.json({ message: "Turno pattuglia eliminato" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting patrol route:", error);
|
||||||
|
res.status(500).json({ message: "Errore eliminazione turno pattuglia" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============= GEOCODING API (Nominatim/OSM) =============
|
// ============= GEOCODING API (Nominatim/OSM) =============
|
||||||
|
|
||||||
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
||||||
|
|||||||
10
version.json
10
version.json
@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.45",
|
"version": "1.0.46",
|
||||||
"lastUpdate": "2025-10-23T14:53:12.164Z",
|
"lastUpdate": "2025-10-23T15:23:00.124Z",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
{
|
||||||
|
"version": "1.0.46",
|
||||||
|
"date": "2025-10-23",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.46"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.45",
|
"version": "1.0.45",
|
||||||
"date": "2025-10-23",
|
"date": "2025-10-23",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user