Compare commits

..

6 Commits

Author SHA1 Message Date
Marco Lanzara
6d2e92c76e 🚀 Release v1.0.46
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.46_20251023_152240.sql.gz
- Data: 2025-10-23 15:23:00
2025-10-23 15:23:00 +00:00
marco370
c7c0830780 Improve local login handling with Passport.js authentication
Update the `/api/local-login` route to use Passport.js middleware for robust local authentication, including error handling for authentication failures and successful login.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/WbUtQAg
2025-10-23 15:21:02 +00:00
marco370
cc92c26836 Improve security by adding authentication and authorization
Implement JWT authentication and role-based authorization middleware to secure API endpoints and control user access.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/WbUtQAg
2025-10-23 15:10:25 +00:00
marco370
50b74cdaba Add detailed planning views for guards and site coordinators
Implement new routes and UI components for guards to view fixed and mobile shifts, and for coordinators to view site-specific guard assignments.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/WbUtQAg
2025-10-23 15:07:13 +00:00
marco370
d6b9811c2b Enforce exclusive assignments for guards across shift types
Adds checks to prevent guards from being assigned to both fixed shifts and mobile patrol routes on the same date by validating against existing assignments in the database for the specified shift and guard.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/ZaT6tFl
2025-10-23 15:01:18 +00:00
marco370
897a674eee Add patrol route planning and display for mobile users
Implement a new API endpoint and client-side logic for creating, fetching, and displaying patrol routes, including stop details, on the mobile planning interface.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/ZaT6tFl
2025-10-23 14:58:08 +00:00
11 changed files with 1409 additions and 19 deletions

View File

@ -27,6 +27,9 @@ import GeneralPlanning from "@/pages/general-planning";
import ServicePlanning from "@/pages/service-planning";
import Customers from "@/pages/customers";
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() {
const { isAuthenticated, isLoading } = useAuth();
@ -51,6 +54,9 @@ function Router() {
<Route path="/service-planning" component={ServicePlanning} />
<Route path="/planning-mobile" component={PlanningMobile} />
<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="/notifications" component={Notifications} />
<Route path="/users" component={Users} />

View 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>
);
}

View 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>
);
}

View File

@ -1,5 +1,5 @@
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 { Button } from "@/components/ui/button";
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 L from 'leaflet';
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { queryClient } from "@/lib/queryClient";
// Fix Leaflet default icon issue with Webpack
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',
});
// 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 MobileSite = {
@ -94,6 +107,24 @@ export default function PlanningMobile() {
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> = {
roccapiemonte: "Roccapiemonte",
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
const handleSavePatrolRoute = () => {
if (!selectedGuard) {
@ -222,19 +276,46 @@ export default function PlanningMobile() {
return;
}
// TODO: Implementare chiamata API per salvare turno
toast({
title: "Turno pattuglia creato",
description: `${patrolRoute.length} tappe assegnate a ${selectedGuard.firstName} ${selectedGuard.lastName}`,
});
setPatrolRoute([]);
// Prepara i dati per il salvataggio
const patrolRouteData = {
guardId: selectedGuard.id,
shiftDate: selectedDate,
startTime: "08:00", // TODO: permettere all'utente di configurare
endTime: "20:00",
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(() => {
// Reset route quando cambia guardia o location
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 (
<div className="h-full flex flex-col p-6 space-y-6">
@ -390,6 +471,7 @@ export default function PlanningMobile() {
<Marker
key={site.id}
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
icon={isInRoute ? greenIcon : undefined}
eventHandlers={{
click: () => handleAddToRoute(site),
}}

View 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>
);
}

View File

@ -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
- **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
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
- **Neon**: Managed PostgreSQL database service.

View File

@ -138,13 +138,38 @@ export async function setupLocalAuth(app: Express) {
});
// Route login locale POST
app.post("/api/local-login", passport.authenticate("local"), (req, res) => {
res.json({
app.post("/api/local-login", (req, res, next) => {
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,
user: req.user,
message: "Login effettuato con successo"
});
});
})(req, res, next);
});
// Route logout

View File

@ -4,7 +4,7 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
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 { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
import { z } from "zod";
@ -2646,6 +2646,32 @@ export async function registerRoutes(app: Express): Promise<Server> {
// ============= SHIFT ASSIGNMENT ROUTES =============
app.post("/api/shift-assignments", isAuthenticated, async (req, res) => {
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);
res.json(assignment);
} 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" });
}
// 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
const assignment = await storage.createShiftAssignment({
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 =============
app.get("/api/notifications", isAuthenticated, async (req: any, res) => {
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) =============
// Rate limiter semplice per rispettare 1 req/sec di Nominatim

View File

@ -1,7 +1,13 @@
{
"version": "1.0.45",
"lastUpdate": "2025-10-23T14:53:12.164Z",
"version": "1.0.46",
"lastUpdate": "2025-10-23T15:23:00.124Z",
"changelog": [
{
"version": "1.0.46",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.46"
},
{
"version": "1.0.45",
"date": "2025-10-23",