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
This commit is contained in:
marco370 2025-10-23 15:07:13 +00:00
parent d6b9811c2b
commit 50b74cdaba
7 changed files with 1031 additions and 0 deletions

View File

@ -19,6 +19,10 @@ externalPort = 80
localPort = 33035
externalPort = 3001
[[ports]]
localPort = 36851
externalPort = 6000
[[ports]]
localPort = 41295
externalPort = 5173

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

@ -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

@ -2756,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 {