Compare commits

..

No commits in common. "main" and "v1.0.37" have entirely different histories.

39 changed files with 345 additions and 5307 deletions

10
.replit
View File

@ -19,13 +19,9 @@ externalPort = 80
localPort = 33035
externalPort = 3001
[[ports]]
localPort = 40417
externalPort = 8000
[[ports]]
localPort = 41295
externalPort = 5173
externalPort = 6000
[[ports]]
localPort = 41343
@ -40,8 +36,8 @@ localPort = 42175
externalPort = 3002
[[ports]]
localPort = 42187
externalPort = 6800
localPort = 42423
externalPort = 5173
[[ports]]
localPort = 43169

View File

@ -18,11 +18,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
</head>
<body>
<div id="root"></div>

View File

@ -27,10 +27,6 @@ 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";
import WeeklyGuards from "@/pages/weekly-guards";
function Router() {
const { isAuthenticated, isLoading } = useAuth();
@ -55,10 +51,6 @@ 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="/weekly-guards" component={WeeklyGuards} />
<Route path="/reports" component={Reports} />
<Route path="/notifications" component={Notifications} />
<Route path="/users" component={Users} />

View File

@ -12,11 +12,6 @@ import {
Car,
Briefcase,
Navigation,
ChevronDown,
FileText,
FolderKanban,
Building2,
Wrench,
} from "lucide-react";
import { Link, useLocation } from "wouter";
import {
@ -28,31 +23,15 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarHeader,
SidebarFooter,
} from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useAuth } from "@/hooks/useAuth";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme-toggle";
interface MenuItem {
title: string;
url?: string;
icon: any;
roles: string[];
items?: MenuItem[];
}
const menuItems: MenuItem[] = [
const menuItems = [
{
title: "Dashboard",
url: "/",
@ -60,123 +39,100 @@ const menuItems: MenuItem[] = [
roles: ["admin", "coordinator", "guard", "client"],
},
{
title: "Planning",
icon: FolderKanban,
roles: ["admin", "coordinator"],
items: [
{
title: "Fissi",
url: "/general-planning",
icon: Calendar,
roles: ["admin", "coordinator"],
},
{
title: "Mobili",
url: "/planning-mobile",
icon: Navigation,
roles: ["admin", "coordinator"],
},
{
title: "Vista",
url: "/service-planning",
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Guardie Settimanale",
url: "/weekly-guards",
icon: Users,
roles: ["admin", "coordinator"],
},
],
title: "Turni",
url: "/shifts",
icon: Calendar,
roles: ["admin", "coordinator", "guard"],
},
{
title: "Scadenziario",
url: "/advanced-planning",
title: "Pianificazione",
url: "/planning",
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Pianificazione Operativa",
url: "/operational-planning",
icon: Calendar,
roles: ["admin", "coordinator"],
},
{
title: "Anagrafiche",
icon: Building2,
title: "Planning Fissi",
url: "/general-planning",
icon: BarChart3,
roles: ["admin", "coordinator"],
items: [
{
title: "Guardie",
url: "/guards",
icon: Users,
roles: ["admin", "coordinator"],
},
{
title: "Siti",
url: "/sites",
icon: MapPin,
roles: ["admin", "coordinator", "client"],
},
{
title: "Clienti",
url: "/customers",
icon: Briefcase,
roles: ["admin", "coordinator"],
},
{
title: "Automezzi",
url: "/vehicles",
icon: Car,
roles: ["admin", "coordinator"],
},
],
},
{
title: "Tipologia",
icon: Wrench,
title: "Planning Mobile",
url: "/planning-mobile",
icon: Navigation,
roles: ["admin", "coordinator"],
},
{
title: "Planning di Servizio",
url: "/service-planning",
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Gestione Pianificazioni",
url: "/advanced-planning",
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Guardie",
url: "/guards",
icon: Users,
roles: ["admin", "coordinator"],
},
{
title: "Siti",
url: "/sites",
icon: MapPin,
roles: ["admin", "coordinator", "client"],
},
{
title: "Clienti",
url: "/customers",
icon: Briefcase,
roles: ["admin", "coordinator"],
},
{
title: "Servizi",
url: "/services",
icon: Briefcase,
roles: ["admin", "coordinator"],
},
{
title: "Parco Automezzi",
url: "/vehicles",
icon: Car,
roles: ["admin", "coordinator"],
items: [
{
title: "Servizi",
url: "/services",
icon: Briefcase,
roles: ["admin", "coordinator"],
},
{
title: "Contratti",
url: "/parameters",
icon: Settings,
roles: ["admin", "coordinator"],
},
],
},
{
title: "Report",
url: "/reports",
icon: BarChart3,
roles: ["admin", "coordinator", "client"],
items: [
{
title: "Report Amministrativo",
url: "/reports",
icon: FileText,
roles: ["admin", "coordinator", "client"],
},
],
},
{
title: "Utilità",
icon: Settings,
title: "Notifiche",
url: "/notifications",
icon: Bell,
roles: ["admin", "coordinator", "guard"],
items: [
{
title: "Utenti",
url: "/users",
icon: UserCog,
roles: ["admin"],
},
{
title: "Notifiche",
url: "/notifications",
icon: Bell,
roles: ["admin", "coordinator", "guard"],
},
],
},
{
title: "Utenti",
url: "/users",
icon: UserCog,
roles: ["admin"],
},
{
title: "Parametri",
url: "/parameters",
icon: Settings,
roles: ["admin", "coordinator"],
},
];
@ -184,78 +140,9 @@ export function AppSidebar() {
const { user } = useAuth();
const [location] = useLocation();
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
if (!user) return [];
return items.filter((item) => {
const hasRole = item.roles.includes(user.role);
if (!hasRole) return false;
if (item.items) {
item.items = filterMenuItems(item.items);
return item.items.length > 0;
}
return true;
});
};
const filteredItems = filterMenuItems(menuItems);
const renderMenuItem = (item: MenuItem) => {
// Menu item con sottomenu
if (item.items && item.items.length > 0) {
const isAnySubItemActive = item.items.some((subItem) => location === subItem.url);
return (
<Collapsible key={item.title} defaultOpen={isAnySubItemActive} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton data-testid={`menu-${item.title.toLowerCase()}`}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={location === subItem.url}
data-testid={`link-${subItem.title.toLowerCase().replace(/\s+/g, '-')}`}
>
<Link href={subItem.url!}>
<subItem.icon className="h-4 w-4" />
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
}
// Menu item semplice
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={location === item.url}
data-testid={`link-${item.title.toLowerCase().replace(/\s+/g, '-')}`}
>
<Link href={item.url!}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
};
const filteredItems = menuItems.filter(
(item) => user && item.roles.includes(user.role)
);
return (
<Sidebar>
@ -274,7 +161,20 @@ export function AppSidebar() {
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{filteredItems.map(renderMenuItem)}
{filteredItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={location === item.url}
data-testid={`link-${item.title.toLowerCase()}`}
>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock, Copy, Circle } from "lucide-react";
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
@ -85,14 +85,14 @@ interface GeneralPlanningResponse {
}
// Helper per formattare orario in formato italiano 24h (HH:MM)
// IMPORTANTE: Gli orari nel DB sono UTC, visualizzali in timezone Europe/Rome
// IMPORTANTE: usa timeZone UTC per evitare shift di +2 ore
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome" // Converti da UTC a Italy time
timeZone: "UTC" // Evita conversione timezone locale (+2h in Italia)
});
};
@ -117,7 +117,6 @@ export default function GeneralPlanning() {
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
const [ccnlConfirmation, setCcnlConfirmation] = useState<{ message: string; data: any } | null>(null);
const [showCopyWeekConfirmation, setShowCopyWeekConfirmation] = useState<boolean>(false);
// Query per dati planning settimanale
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
@ -276,54 +275,6 @@ export default function GeneralPlanning() {
},
});
// Mutation per copiare turni settimanali
const copyWeekMutation = useMutation({
mutationFn: async () => {
return apiRequest("POST", "/api/shift-assignments/copy-week", {
weekStart: format(weekStart, "yyyy-MM-dd"),
location: selectedLocation,
});
},
onSuccess: async (response: any) => {
const data = await response.json();
toast({
title: "Settimana copiata!",
description: `${data.copiedShifts} turni e ${data.copiedAssignments} assegnazioni copiate nella settimana successiva`,
});
// Invalida cache e naviga alla settimana successiva
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
setWeekStart(addWeeks(weekStart, 1)); // Naviga alla settimana copiata
setShowCopyWeekConfirmation(false);
},
onError: (error: any) => {
let errorMessage = "Impossibile copiare la settimana";
if (error.message) {
const match = error.message.match(/^(\d+):\s*(.+)$/);
if (match) {
try {
const parsed = JSON.parse(match[2]);
errorMessage = parsed.message || errorMessage;
} catch {
errorMessage = match[2];
}
} else {
errorMessage = error.message;
}
}
toast({
title: "Errore Copia Settimana",
description: errorMessage,
variant: "destructive",
});
setShowCopyWeekConfirmation(false);
},
});
// Handler per submit form assegnazione guardia
const handleAssignGuard = () => {
if (!selectedCell || !selectedGuardId) return;
@ -407,7 +358,7 @@ export default function GeneralPlanning() {
</div>
{/* Navigazione settimana */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
@ -434,16 +385,6 @@ export default function GeneralPlanning() {
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="default"
onClick={() => setShowCopyWeekConfirmation(true)}
disabled={isLoading || !planningData || copyWeekMutation.isPending}
data-testid="button-copy-week"
>
<Copy className="h-4 w-4 mr-2" />
{copyWeekMutation.isPending ? "Copia in corso..." : "Copia Turno Settimanale"}
</Button>
</div>
{/* Info settimana */}
@ -717,19 +658,19 @@ export default function GeneralPlanning() {
})()}
</div>
{/* Select guardia (tutte, evidenziate in rosso se impegnate) */}
{/* Select guardia disponibile */}
{(() => {
// Mostra TUTTE le guardie, ma filtra solo per ore ordinarie/straordinario
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
const filteredGuards = availableGuards?.filter(g =>
showOvertimeGuards || !g.requiresOvertime
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
) || [];
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime) || false;
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime && g.isAvailable) || false;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="guard-select">Guardia</Label>
<Label htmlFor="guard-select">Guardia Disponibile</Label>
{!isLoadingGuards && hasOvertimeGuards && (
<Button
variant="outline"
@ -758,20 +699,15 @@ export default function GeneralPlanning() {
{filteredGuards.length > 0 ? (
filteredGuards.map((guard) => (
<SelectItem key={guard.guardId} value={guard.guardId}>
<div className={`flex items-center gap-1.5 ${guard.isAvailable ? "" : "text-destructive font-medium"}`}>
{!guard.isAvailable && <Circle className="h-3 w-3 fill-current" />}
<span>
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
{guard.requiresOvertime && " 🔸"}
</span>
</div>
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
{guard.requiresOvertime && " 🔸"}
</SelectItem>
))
) : (
<SelectItem value="no-guards" disabled>
{showOvertimeGuards
? "Nessuna guardia"
? "Nessuna guardia disponibile"
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
</SelectItem>
)}
@ -779,7 +715,7 @@ export default function GeneralPlanning() {
</Select>
{filteredGuards.length === 0 && !showOvertimeGuards && hasOvertimeGuards && (
<p className="text-xs text-muted-foreground">
Alcune guardie richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
</p>
)}
{filteredGuards.length > 0 && selectedGuardId && (
@ -1052,60 +988,6 @@ export default function GeneralPlanning() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Dialog conferma copia settimana */}
<AlertDialog open={showCopyWeekConfirmation} onOpenChange={setShowCopyWeekConfirmation}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
Copia Turno Settimanale
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p className="text-foreground font-medium">
Vuoi copiare tutti i turni della settimana corrente nella settimana successiva?
</p>
{planningData && (
<div className="space-y-2 bg-muted/30 p-3 rounded-md">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Settimana corrente:</span>
<span className="font-medium">
{format(new Date(planningData.weekStart), "dd MMM", { locale: it })} -{" "}
{format(new Date(planningData.weekEnd), "dd MMM yyyy", { locale: it })}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Verrà copiata in:</span>
<span className="font-medium">
{format(addWeeks(new Date(planningData.weekStart), 1), "dd MMM", { locale: it })} -{" "}
{format(addWeeks(new Date(planningData.weekEnd), 1), "dd MMM yyyy", { locale: it })}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Sede:</span>
<span className="font-medium">{formatLocation(selectedLocation)}</span>
</div>
</div>
)}
<p className="text-sm text-muted-foreground">
Tutti i turni e le assegnazioni guardie verranno duplicati con le stesse caratteristiche (orari, dotazioni, veicoli).
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-cancel-copy-week">Annulla</AlertDialogCancel>
<AlertDialogAction
onClick={() => copyWeekMutation.mutate()}
data-testid="button-confirm-copy-week"
disabled={copyWeekMutation.isPending}
>
{copyWeekMutation.isPending ? "Copia in corso..." : "Conferma Copia"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -1,244 +0,0 @@
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 (DB in UTC → visualizza in Europe/Rome)
let startTime = "N/A";
let endTime = "N/A";
if (shift.plannedStartTime) {
const parsedStart = new Date(shift.plannedStartTime);
if (isValid(parsedStart)) {
startTime = parsedStart.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome"
});
}
}
if (shift.plannedEndTime) {
const parsedEnd = new Date(shift.plannedEndTime);
if (isValid(parsedEnd)) {
endTime = parsedEnd.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome"
});
}
}
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

@ -1,247 +0,0 @@
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>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { format, addWeeks, addDays, startOfWeek } from "date-fns";
import { it } from "date-fns/locale";
import { ChevronLeft, ChevronRight, Users, Building2, Navigation, Shield, Car as CarIcon, MapPin } from "lucide-react";
import { ChevronLeft, ChevronRight, Users, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -12,15 +12,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type Location = "roccapiemonte" | "milano" | "roma";
interface FixedShiftDetail {
interface ShiftDetail {
shiftId: string;
date: string;
from: string;
to: string;
siteName: string;
siteAddress: string;
siteId: string;
isArmed: boolean;
vehicle?: {
licensePlate: string;
brand: string;
@ -29,42 +27,14 @@ interface FixedShiftDetail {
hours: number;
}
interface FixedGuardSchedule {
interface GuardSchedule {
guardId: string;
guardName: string;
badgeNumber: string;
shifts: FixedShiftDetail[];
shifts: ShiftDetail[];
totalHours: number;
}
interface PatrolRoute {
routeId: string;
guardId: string;
shiftDate: string;
startTime: string;
endTime: string;
isArmedRoute: boolean;
vehicle?: {
licensePlate: string;
brand: string;
model: string;
};
stops: {
siteId: string;
siteName: string;
siteAddress: string;
sequenceOrder: number;
}[];
}
interface MobileGuardSchedule {
guardId: string;
guardName: string;
badgeNumber: string;
routes: PatrolRoute[];
totalRoutes: number;
}
interface SiteSchedule {
siteId: string;
siteName: string;
@ -78,7 +48,6 @@ interface SiteSchedule {
guardName: string;
badgeNumber: string;
hours: number;
isArmed: boolean;
}[];
vehicle?: {
licensePlate: string;
@ -95,30 +64,20 @@ interface SiteSchedule {
export default function ServicePlanning() {
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
const [weekStart, setWeekStart] = useState<Date>(startOfWeek(new Date(), { weekStartsOn: 1 }));
const [viewMode, setViewMode] = useState<"guard-fixed" | "guard-mobile" | "site">("guard-fixed");
const [viewMode, setViewMode] = useState<"guard" | "site">("guard");
const weekStartStr = format(weekStart, "yyyy-MM-dd");
const weekEndStr = format(addDays(weekStart, 6), "yyyy-MM-dd");
// Query per vista Agenti Fissi
const { data: fixedGuardSchedules, isLoading: isLoadingFixedGuards } = useQuery<FixedGuardSchedule[]>({
queryKey: ["/api/service-planning/guards-fixed", weekStartStr, selectedLocation],
// Query per vista Guardie
const { data: guardSchedules, isLoading: isLoadingGuards } = useQuery<GuardSchedule[]>({
queryKey: ["/api/service-planning/by-guard", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/guards-fixed?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch fixed guard schedules");
const response = await fetch(`/api/service-planning/by-guard?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch guard schedules");
return response.json();
},
enabled: viewMode === "guard-fixed",
});
// Query per vista Agenti Mobili
const { data: mobileGuardSchedules, isLoading: isLoadingMobileGuards } = useQuery<MobileGuardSchedule[]>({
queryKey: ["/api/service-planning/guards-mobile", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/guards-mobile?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch mobile guard schedules");
return response.json();
},
enabled: viewMode === "guard-mobile",
enabled: viewMode === "guard",
});
// Query per vista Siti
@ -142,7 +101,7 @@ export default function ServicePlanning() {
<div>
<h1 className="text-3xl font-bold">Planning di Servizio</h1>
<p className="text-muted-foreground">
Visualizza orari e dotazioni per agente fisso, agente mobile o per sito
Visualizza orari e dotazioni per guardia o sito
</p>
</div>
</div>
@ -186,15 +145,11 @@ export default function ServicePlanning() {
</Card>
{/* Tabs per vista */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard-fixed" | "guard-mobile" | "site")}>
<TabsList className="grid w-full max-w-2xl grid-cols-3">
<TabsTrigger value="guard-fixed" data-testid="tab-guard-fixed-view">
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard" | "site")}>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="guard" data-testid="tab-guard-view">
<Users className="h-4 w-4 mr-2" />
Agenti Fissi
</TabsTrigger>
<TabsTrigger value="guard-mobile" data-testid="tab-guard-mobile-view">
<Navigation className="h-4 w-4 mr-2" />
Agenti Mobili
Vista Agente
</TabsTrigger>
<TabsTrigger value="site" data-testid="tab-site-view">
<Building2 className="h-4 w-4 mr-2" />
@ -202,18 +157,18 @@ export default function ServicePlanning() {
</TabsTrigger>
</TabsList>
{/* Vista Agenti Fissi */}
<TabsContent value="guard-fixed" className="space-y-4 mt-6">
{isLoadingFixedGuards ? (
{/* Vista Agente */}
<TabsContent value="guard" className="space-y-4 mt-6">
{isLoadingGuards ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : fixedGuardSchedules && fixedGuardSchedules.length > 0 ? (
) : guardSchedules && guardSchedules.length > 0 ? (
<div className="grid gap-4">
{fixedGuardSchedules.map((guard) => (
<Card key={guard.guardId} data-testid={`card-guard-fixed-${guard.guardId}`}>
{guardSchedules.map((guard) => (
<Card key={guard.guardId} data-testid={`card-guard-${guard.guardId}`}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
@ -224,134 +179,25 @@ export default function ServicePlanning() {
</CardHeader>
<CardContent>
{guard.shifts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nessun turno fisso assegnato</p>
<p className="text-sm text-muted-foreground">Nessun turno assegnato</p>
) : (
<div className="space-y-3">
{guard.shifts.map((shift) => (
<div
key={shift.shiftId}
className="p-3 rounded-md bg-muted/50 space-y-2"
className="flex items-start justify-between p-3 rounded-md bg-muted/50"
data-testid={`shift-${shift.shiftId}`}
>
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<div className="font-medium">{shift.siteName}</div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-3 w-3" />
{shift.siteAddress}
</div>
<div className="text-sm text-muted-foreground">
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} {shift.from} - {shift.to} ({shift.hours}h)
</div>
</div>
<div className="flex flex-col items-end gap-1">
{shift.isArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{shift.vehicle && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
{shift.vehicle.licensePlate}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Nessun agente con turni fissi assegnati</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Vista Agenti Mobili */}
<TabsContent value="guard-mobile" className="space-y-4 mt-6">
{isLoadingMobileGuards ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : mobileGuardSchedules && mobileGuardSchedules.length > 0 ? (
<div className="grid gap-4">
{mobileGuardSchedules.map((guard) => (
<Card key={guard.guardId} data-testid={`card-guard-mobile-${guard.guardId}`}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
{guard.guardName} <Badge variant="outline">{guard.badgeNumber}</Badge>
</CardTitle>
<Badge>{guard.totalRoutes} {guard.totalRoutes === 1 ? 'percorso' : 'percorsi'}</Badge>
</div>
</CardHeader>
<CardContent>
{guard.routes.length === 0 ? (
<p className="text-sm text-muted-foreground">Nessun percorso pattuglia assegnato</p>
) : (
<div className="space-y-4">
{guard.routes.map((route) => (
<div
key={route.routeId}
className="p-3 rounded-md bg-muted/50 space-y-3"
data-testid={`route-${route.routeId}`}
>
<div className="flex items-center justify-between">
<div className="font-medium">
{format(new Date(route.shiftDate), "EEEE d MMM yyyy", { locale: it })}
</div>
<div className="space-y-1">
<div className="font-medium">{shift.siteName}</div>
<div className="text-sm text-muted-foreground">
{route.startTime} - {route.endTime}
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} {shift.from} - {shift.to} ({shift.hours}h)
</div>
</div>
<div className="flex gap-2">
{route.isArmedRoute && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
{shift.vehicle && (
<div className="text-xs text-muted-foreground">
🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model})
</div>
)}
{route.vehicle && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
{route.vehicle.licensePlate}
</Badge>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-1">
<Navigation className="h-4 w-4" />
Percorso ({route.stops.length} {route.stops.length === 1 ? 'tappa' : 'tappe'}):
</div>
<div className="space-y-1 pl-5">
{route.stops.map((stop) => (
<div key={stop.siteId} className="text-sm text-muted-foreground flex items-start gap-2">
<Badge variant="secondary" className="text-xs">
{stop.sequenceOrder}
</Badge>
<div className="flex-1">
<div className="font-medium text-foreground">{stop.siteName}</div>
<div className="text-xs flex items-center gap-1">
<MapPin className="h-3 w-3" />
{stop.siteAddress}
</div>
</div>
</div>
))}
</div>
</div>
</div>
))}
@ -364,7 +210,7 @@ export default function ServicePlanning() {
) : (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Nessun agente con percorsi pattuglia assegnati</p>
<p className="text-center text-muted-foreground">Nessuna guardia con turni assegnati</p>
</CardContent>
</Card>
)}
@ -406,29 +252,20 @@ export default function ServicePlanning() {
<div className="font-medium">
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} {shift.from} - {shift.to}
</div>
<div className="flex gap-1">
<Badge variant="secondary">{shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"}</Badge>
{shift.vehicle && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
{shift.vehicle.licensePlate}
</Badge>
)}
</div>
<Badge variant="secondary">{shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"}</Badge>
</div>
<div className="space-y-1">
{shift.guards.map((guard, idx) => (
<div key={idx} className="text-sm text-muted-foreground flex items-center justify-between">
<span>{guard.guardName} ({guard.badgeNumber}) - {guard.hours}h</span>
{guard.isArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
<div key={idx} className="text-sm text-muted-foreground">
👤 {guard.guardName} ({guard.badgeNumber}) - {guard.hours}h
</div>
))}
</div>
{shift.vehicle && (
<div className="text-xs text-muted-foreground">
🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model})
</div>
)}
</div>
))}
</div>

View File

@ -1,284 +0,0 @@
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 (DB in UTC → visualizza in Europe/Rome)
let startTime = "N/A";
let endTime = "N/A";
if (guard.plannedStartTime) {
const parsedStart = new Date(guard.plannedStartTime);
if (isValid(parsedStart)) {
startTime = parsedStart.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome"
});
}
}
if (guard.plannedEndTime) {
const parsedEnd = new Date(guard.plannedEndTime);
if (isValid(parsedEnd)) {
endTime = parsedEnd.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome"
});
}
}
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

@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Site, InsertSite, Customer, ServiceType } from "@shared/schema";
import { Site, InsertSite, Customer } from "@shared/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
@ -18,6 +18,13 @@ import { StatusBadge } from "@/components/status-badge";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
const shiftTypeLabels: Record<string, string> = {
fixed_post: "Presidio Fisso",
patrol: "Pattugliamento",
night_inspection: "Ispettorato Notturno",
quick_response: "Pronto Intervento",
};
const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
@ -28,8 +35,6 @@ export default function Sites() {
const { toast } = useToast();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingSite, setEditingSite] = useState<Site | null>(null);
const [isGeocoding, setIsGeocoding] = useState(false);
const [isGeocodingEdit, setIsGeocodingEdit] = useState(false);
const { data: sites, isLoading } = useQuery<Site[]>({
queryKey: ["/api/sites"],
@ -39,18 +44,12 @@ export default function Sites() {
queryKey: ["/api/customers"],
});
const { data: serviceTypes } = useQuery<ServiceType[]>({
queryKey: ["/api/service-types"],
});
const form = useForm<InsertSite>({
resolver: zodResolver(insertSiteSchema),
defaultValues: {
name: "",
address: "",
customerId: undefined,
location: "roccapiemonte",
serviceTypeId: undefined,
shiftType: "fixed_post",
minGuards: 1,
requiresArmed: false,
requiresDriverLicense: false,
@ -59,8 +58,6 @@ export default function Sites() {
contractEndDate: undefined,
serviceStartTime: "",
serviceEndTime: "",
latitude: undefined,
longitude: undefined,
isActive: true,
},
});
@ -70,9 +67,7 @@ export default function Sites() {
defaultValues: {
name: "",
address: "",
customerId: undefined,
location: "roccapiemonte",
serviceTypeId: undefined,
shiftType: "fixed_post",
minGuards: 1,
requiresArmed: false,
requiresDriverLicense: false,
@ -81,8 +76,6 @@ export default function Sites() {
contractEndDate: undefined,
serviceStartTime: "",
serviceEndTime: "",
latitude: undefined,
longitude: undefined,
isActive: true,
},
});
@ -131,82 +124,6 @@ export default function Sites() {
},
});
const handleGeocode = async () => {
const address = form.getValues("address");
if (!address) {
toast({
title: "Indirizzo mancante",
description: "Inserisci un indirizzo prima di cercare le coordinate",
variant: "destructive",
});
return;
}
setIsGeocoding(true);
try {
const response = await apiRequest(
"POST",
"/api/geocode",
{ address }
);
const result = await response.json();
form.setValue("latitude", result.latitude);
form.setValue("longitude", result.longitude);
toast({
title: "Coordinate trovate",
description: `Indirizzo: ${result.displayName}`,
});
} catch (error: any) {
toast({
title: "Errore geocodifica",
description: error.message || "Impossibile trovare le coordinate per questo indirizzo",
variant: "destructive",
});
} finally {
setIsGeocoding(false);
}
};
const handleGeocodeEdit = async () => {
const address = editForm.getValues("address");
if (!address) {
toast({
title: "Indirizzo mancante",
description: "Inserisci un indirizzo prima di cercare le coordinate",
variant: "destructive",
});
return;
}
setIsGeocodingEdit(true);
try {
const response = await apiRequest(
"POST",
"/api/geocode",
{ address }
);
const result = await response.json();
editForm.setValue("latitude", result.latitude);
editForm.setValue("longitude", result.longitude);
toast({
title: "Coordinate trovate",
description: `Indirizzo: ${result.displayName}`,
});
} catch (error: any) {
toast({
title: "Errore geocodifica",
description: error.message || "Impossibile trovare le coordinate per questo indirizzo",
variant: "destructive",
});
} finally {
setIsGeocodingEdit(false);
}
};
const onSubmit = (data: InsertSite) => {
createMutation.mutate(data);
};
@ -221,12 +138,9 @@ export default function Sites() {
setEditingSite(site);
editForm.reset({
name: site.name,
address: site.address || "",
latitude: site.latitude || "",
longitude: site.longitude || "",
customerId: site.customerId ?? undefined,
address: site.address,
location: site.location,
serviceTypeId: site.serviceTypeId ?? undefined,
shiftType: site.shiftType,
minGuards: site.minGuards,
requiresArmed: site.requiresArmed,
requiresDriverLicense: site.requiresDriverLicense,
@ -318,82 +232,20 @@ export default function Sites() {
)}
/>
<div className="border rounded-lg p-4 space-y-4 bg-muted/50">
<div className="flex items-center justify-between">
<p className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" />
Coordinate GPS (per mappa)
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGeocode}
disabled={isGeocoding || !form.watch("address")}
data-testid="button-geocode"
>
{isGeocoding ? "Ricerca in corso..." : "📍 Trova Coordinate"}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="latitude"
render={({ field }) => (
<FormItem>
<FormLabel>Latitudine</FormLabel>
<FormControl>
<Input
placeholder="41.9028"
{...field}
value={field.value || ""}
data-testid="input-latitude"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="longitude"
render={({ field }) => (
<FormItem>
<FormLabel>Longitudine</FormLabel>
<FormControl>
<Input
placeholder="12.4964"
{...field}
value={field.value || ""}
data-testid="input-longitude"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<p className="text-xs text-muted-foreground">
Le coordinate GPS permettono di visualizzare il sito sulla mappa in Planning Mobile
</p>
</div>
<FormField
control={form.control}
name="customerId"
render={({ field }) => (
<FormItem>
<FormLabel>Cliente (opzionale)</FormLabel>
<Select onValueChange={(value) => field.onChange(value || undefined)} value={field.value ?? undefined}>
<FormLabel>Cliente</FormLabel>
<Select onValueChange={field.onChange} value={field.value || undefined}>
<FormControl>
<SelectTrigger data-testid="select-customer">
<SelectValue placeholder="Nessun cliente" />
<SelectValue placeholder="Seleziona cliente (opzionale)" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="">Nessun cliente</SelectItem>
{customers?.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
@ -489,22 +341,21 @@ export default function Sites() {
<FormField
control={form.control}
name="serviceTypeId"
name="shiftType"
render={({ field }) => (
<FormItem>
<FormLabel>Tipologia Servizio (opzionale)</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? undefined}>
<FormLabel>Tipologia Servizio</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-service-type">
<SelectTrigger data-testid="select-shift-type">
<SelectValue placeholder="Seleziona tipo servizio" />
</SelectTrigger>
</FormControl>
<SelectContent>
{serviceTypes?.filter(st => st.isActive).map((serviceType) => (
<SelectItem key={serviceType.id} value={serviceType.id}>
{serviceType.label}
</SelectItem>
))}
<SelectItem value="fixed_post">Presidio Fisso</SelectItem>
<SelectItem value="patrol">Pattugliamento</SelectItem>
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
<SelectItem value="quick_response">Pronto Intervento</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@ -668,82 +519,20 @@ export default function Sites() {
)}
/>
<div className="border rounded-lg p-4 space-y-4 bg-muted/50">
<div className="flex items-center justify-between">
<p className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" />
Coordinate GPS (per mappa)
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGeocodeEdit}
disabled={isGeocodingEdit || !editForm.watch("address")}
data-testid="button-geocode-edit"
>
{isGeocodingEdit ? "Ricerca in corso..." : "📍 Trova Coordinate"}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="latitude"
render={({ field }) => (
<FormItem>
<FormLabel>Latitudine</FormLabel>
<FormControl>
<Input
placeholder="41.9028"
{...field}
value={field.value || ""}
data-testid="input-edit-latitude"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="longitude"
render={({ field }) => (
<FormItem>
<FormLabel>Longitudine</FormLabel>
<FormControl>
<Input
placeholder="12.4964"
{...field}
value={field.value || ""}
data-testid="input-edit-longitude"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<p className="text-xs text-muted-foreground">
Le coordinate GPS permettono di visualizzare il sito sulla mappa in Planning Mobile
</p>
</div>
<FormField
control={editForm.control}
name="customerId"
render={({ field }) => (
<FormItem>
<FormLabel>Cliente (opzionale)</FormLabel>
<Select onValueChange={(value) => field.onChange(value || undefined)} value={field.value ?? undefined}>
<FormLabel>Cliente</FormLabel>
<Select onValueChange={field.onChange} value={field.value || undefined}>
<FormControl>
<SelectTrigger data-testid="select-edit-customer">
<SelectValue placeholder="Nessun cliente" />
<SelectValue placeholder="Seleziona cliente (opzionale)" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="">Nessun cliente</SelectItem>
{customers?.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
@ -839,22 +628,21 @@ export default function Sites() {
<FormField
control={editForm.control}
name="serviceTypeId"
name="shiftType"
render={({ field }) => (
<FormItem>
<FormLabel>Tipologia Servizio (opzionale)</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? undefined}>
<FormLabel>Tipologia Servizio</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-edit-service-type">
<SelectTrigger data-testid="select-edit-shift-type">
<SelectValue placeholder="Seleziona tipo servizio" />
</SelectTrigger>
</FormControl>
<SelectContent>
{serviceTypes?.filter(st => st.isActive).map((serviceType) => (
<SelectItem key={serviceType.id} value={serviceType.id}>
{serviceType.label}
</SelectItem>
))}
<SelectItem value="fixed_post">Presidio Fisso</SelectItem>
<SelectItem value="patrol">Pattugliamento</SelectItem>
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
<SelectItem value="quick_response">Pronto Intervento</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@ -1033,14 +821,9 @@ export default function Sites() {
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
{site.serviceTypeId && serviceTypes && (() => {
const serviceType = serviceTypes.find(st => st.id === site.serviceTypeId);
return serviceType ? (
<Badge variant="outline" data-testid={`badge-service-type-${site.id}`}>
{serviceType.label}
</Badge>
) : null;
})()}
<Badge variant="outline">
{shiftTypeLabels[site.shiftType]}
</Badge>
{(() => {
const status = getContractStatus(site);
const statusInfo = contractStatusLabels[status];

View File

@ -1,448 +0,0 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Users, Clock, MapPin, Navigation, ExternalLink } from "lucide-react";
import { format, parseISO, addDays, startOfWeek, addWeeks } from "date-fns";
import { it } from "date-fns/locale";
import { Link } from "wouter";
type AbsenceType = "sick_leave" | "vacation" | "personal_leave" | "injury";
interface GuardScheduleData {
guard: {
id: string;
firstName: string;
lastName: string;
badgeNumber: string;
};
fixedShifts: Array<{
assignmentId: string;
shiftId: string;
plannedStartTime: Date;
plannedEndTime: Date;
siteName: string;
siteId: string;
}>;
mobileShifts: Array<{
routeId: string;
shiftDate: string;
startTime: string;
endTime: string;
}>;
absences: Array<{
id: string;
type: AbsenceType;
startDate: string;
endDate: string;
}>;
}
interface WeeklyScheduleResponse {
weekStart: string;
weekEnd: string;
location: string;
guards: GuardScheduleData[];
}
const ABSENCE_LABELS: Record<AbsenceType, string> = {
sick_leave: "Malattia",
vacation: "Ferie",
personal_leave: "Permesso",
injury: "Infortunio",
};
type DialogData = {
type: "fixed" | "mobile";
guardName: string;
date: string;
data: any;
} | null;
export default function WeeklyGuards() {
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 }) // Inizia lunedì
);
const [dialogData, setDialogData] = useState<DialogData>(null);
const { data: scheduleData, isLoading, error } = useQuery<WeeklyScheduleResponse>({
queryKey: ["/api/weekly-guards-schedule", selectedLocation, format(currentWeekStart, "yyyy-MM-dd")],
queryFn: async () => {
const startDate = format(currentWeekStart, "yyyy-MM-dd");
const response = await fetch(
`/api/weekly-guards-schedule?location=${selectedLocation}&startDate=${startDate}`
);
if (!response.ok) {
throw new Error("Failed to fetch weekly schedule");
}
return response.json();
},
enabled: !!selectedLocation,
});
// Helper per ottenere i giorni della settimana
const getWeekDays = () => {
const days = [];
for (let i = 0; i < 7; i++) {
days.push(addDays(currentWeekStart, i));
}
return days;
};
const weekDays = getWeekDays();
// Helper per trovare l'attività di una guardia in un giorno specifico
const getDayActivity = (guardData: GuardScheduleData, date: Date) => {
const dateStr = format(date, "yyyy-MM-dd");
// Controlla assenze
const absence = guardData.absences.find(abs => {
const startDate = abs.startDate;
const endDate = abs.endDate;
return dateStr >= startDate && dateStr <= endDate;
});
if (absence) {
return {
type: "absence" as const,
label: ABSENCE_LABELS[absence.type],
data: absence,
};
}
// Controlla turni fissi
const fixedShift = guardData.fixedShifts.find(shift => {
const shiftDate = format(new Date(shift.plannedStartTime), "yyyy-MM-dd");
return shiftDate === dateStr;
});
if (fixedShift) {
const startTime = format(new Date(fixedShift.plannedStartTime), "HH:mm");
const endTime = format(new Date(fixedShift.plannedEndTime), "HH:mm");
return {
type: "fixed" as const,
label: `${fixedShift.siteName} ${startTime}-${endTime}`,
data: fixedShift,
};
}
// Controlla turni mobili
const mobileShift = guardData.mobileShifts.find(shift => shift.shiftDate === dateStr);
if (mobileShift) {
return {
type: "mobile" as const,
label: `Pattuglia ${mobileShift.startTime}-${mobileShift.endTime}`,
data: mobileShift,
};
}
return null;
};
const handlePreviousWeek = () => {
setCurrentWeekStart(prev => addWeeks(prev, -1));
};
const handleNextWeek = () => {
setCurrentWeekStart(prev => addWeeks(prev, 1));
};
const handleCellClick = (guardData: GuardScheduleData, activity: ReturnType<typeof getDayActivity>, date: Date) => {
if (!activity || activity.type === "absence") return;
const guardName = `${guardData.guard.lastName} ${guardData.guard.firstName}`;
const dateStr = format(date, "EEEE dd MMMM yyyy", { locale: it });
setDialogData({
type: activity.type,
guardName,
date: dateStr,
data: activity.data,
});
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Users className="h-8 w-8 text-primary" />
Guardie Settimanale
</h1>
<p className="text-muted-foreground mt-1">
Vista riepilogativa delle assegnazioni settimanali per sede
</p>
</div>
</div>
{/* Filtri */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Filtri Visualizzazione
</CardTitle>
<CardDescription>
Seleziona sede e settimana per visualizzare le assegnazioni
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select
value={selectedLocation}
onValueChange={setSelectedLocation}
data-testid="select-location"
>
<SelectTrigger>
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handlePreviousWeek}
data-testid="button-previous-week"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="px-4 py-2 border rounded-md bg-muted min-w-[280px] text-center">
<span className="font-medium">
{format(currentWeekStart, "d MMM", { locale: it })} - {format(addDays(currentWeekStart, 6), "d MMM yyyy", { locale: it })}
</span>
</div>
<Button
variant="outline"
size="icon"
onClick={handleNextWeek}
data-testid="button-next-week"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Griglia Settimanale */}
{isLoading ? (
<Card>
<CardContent className="p-6">
<p className="text-center text-muted-foreground">Caricamento...</p>
</CardContent>
</Card>
) : error ? (
<Card>
<CardContent className="p-6">
<p className="text-center text-destructive">
Errore nel caricamento della pianificazione. Riprova più tardi.
</p>
</CardContent>
</Card>
) : scheduleData && scheduleData.guards.length > 0 ? (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b bg-muted/50">
<th className="sticky left-0 z-10 bg-muted/50 text-left p-3 font-medium min-w-[180px]">
Guardia
</th>
{weekDays.map((day, index) => (
<th key={index} className="text-center p-3 font-medium min-w-[200px]">
<div>{format(day, "EEE", { locale: it })}</div>
<div className="text-xs text-muted-foreground font-normal">
{format(day, "dd/MM")}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{scheduleData.guards.map((guardData) => (
<tr
key={guardData.guard.id}
className="border-b hover:bg-muted/30"
data-testid={`row-guard-${guardData.guard.id}`}
>
<td className="sticky left-0 z-10 bg-background p-3 font-medium border-r">
<div className="flex flex-col">
<span className="text-sm">
{guardData.guard.lastName} {guardData.guard.firstName}
</span>
<span className="text-xs text-muted-foreground">
#{guardData.guard.badgeNumber}
</span>
</div>
</td>
{weekDays.map((day, dayIndex) => {
const activity = getDayActivity(guardData, day);
return (
<td
key={dayIndex}
className="p-2 text-center align-middle"
>
{activity ? (
activity.type === "absence" ? (
<div
className="text-xs px-2 py-1.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-300"
data-testid={`cell-absence-${guardData.guard.id}-${dayIndex}`}
>
{activity.label}
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full h-auto text-xs px-2 py-1.5 whitespace-normal hover-elevate"
onClick={() => handleCellClick(guardData, activity, day)}
data-testid={`button-shift-${guardData.guard.id}-${dayIndex}`}
>
{activity.label}
</Button>
)
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-6">
<p className="text-center text-muted-foreground">
Nessuna guardia trovata per la sede selezionata
</p>
</CardContent>
</Card>
)}
{/* Dialog Dettaglio Turno */}
<Dialog open={!!dialogData} onOpenChange={() => setDialogData(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{dialogData?.type === "fixed" ? (
<>
<MapPin className="h-5 w-5" />
Turno Fisso - {dialogData?.guardName}
</>
) : (
<>
<Navigation className="h-5 w-5" />
Turno Mobile - {dialogData?.guardName}
</>
)}
</DialogTitle>
<DialogDescription>
{dialogData?.date}
</DialogDescription>
</DialogHeader>
{dialogData && (
<div className="space-y-4">
{dialogData.type === "fixed" ? (
// Dettagli turno fisso
<div className="space-y-3">
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Sito</span>
<span className="text-sm font-medium">{dialogData.data.siteName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Orario</span>
<div className="flex items-center gap-1 text-sm font-medium">
<Clock className="h-3 w-3" />
{format(new Date(dialogData.data.plannedStartTime), "HH:mm")} - {format(new Date(dialogData.data.plannedEndTime), "HH:mm")}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Durata</span>
<span className="text-sm font-bold">
{Math.round((new Date(dialogData.data.plannedEndTime).getTime() - new Date(dialogData.data.plannedStartTime).getTime()) / (1000 * 60 * 60))}h
</span>
</div>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
<p className="text-sm text-muted-foreground">
Per modificare questo turno, vai alla pagina <Link href="/general-planning" className="text-primary font-medium hover:underline">Planning Fissi</Link>
</p>
</div>
</div>
) : (
// Dettagli turno mobile
<div className="space-y-3">
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tipo</span>
<Badge variant="outline">Pattuglia</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Orario</span>
<div className="flex items-center gap-1 text-sm font-medium">
<Clock className="h-3 w-3" />
{dialogData.data.startTime} - {dialogData.data.endTime}
</div>
</div>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
<p className="text-sm text-muted-foreground">
Per visualizzare il percorso completo e modificare il turno, vai alla pagina <Link href="/planning-mobile" className="text-primary font-medium hover:underline">Planning Mobile</Link>
</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogData(null)}>
Chiudi
</Button>
{dialogData?.type === "fixed" ? (
<Link href="/general-planning">
<Button data-testid="button-goto-planning-fissi">
<ExternalLink className="h-4 w-4 mr-2" />
Vai a Planning Fissi
</Button>
</Link>
) : (
<Link href="/planning-mobile">
<Button data-testid="button-goto-planning-mobile">
<ExternalLink className="h-4 w-4 mr-2" />
Vai a Planning Mobile
</Button>
</Link>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

105
package-lock.json generated
View File

@ -9,9 +9,6 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4",
@ -44,7 +41,6 @@
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"@types/bcrypt": "^6.0.0",
"@types/leaflet": "^1.9.21",
"@types/memoizee": "^0.4.12",
"@types/pg": "^8.15.5",
"bcrypt": "^6.0.0",
@ -60,7 +56,6 @@
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"input-otp": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.453.0",
"memoizee": "^0.4.17",
"memorystore": "^1.6.7",
@ -74,7 +69,6 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.55.0",
"react-icons": "^5.4.0",
"react-leaflet": "^4.2.1",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"tailwind-merge": "^2.6.0",
@ -420,59 +414,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@ -2849,17 +2790,6 @@
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
},
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@replit/vite-plugin-cartographer": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@replit/vite-plugin-cartographer/-/vite-plugin-cartographer-0.3.1.tgz",
@ -3641,12 +3571,6 @@
"@types/express": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
@ -3654,15 +3578,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/memoizee": {
"version": "0.4.12",
"resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz",
@ -5634,12 +5549,6 @@
"node": ">=6"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
@ -6911,20 +6820,6 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@ -11,9 +11,6 @@
"db:push": "drizzle-kit push"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4",
@ -46,7 +43,6 @@
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"@types/bcrypt": "^6.0.0",
"@types/leaflet": "^1.9.21",
"@types/memoizee": "^0.4.12",
"@types/pg": "^8.15.5",
"bcrypt": "^6.0.0",
@ -62,7 +58,6 @@
"express-session": "^1.18.1",
"framer-motion": "^11.13.1",
"input-otp": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.453.0",
"memoizee": "^0.4.17",
"memorystore": "^1.6.7",
@ -76,7 +71,6 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.55.0",
"react-icons": "^5.4.0",
"react-leaflet": "^4.2.1",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"tailwind-merge": "^2.6.0",

View File

@ -1,7 +1,7 @@
# VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza
## Overview
VigilanzaTurni is a professional 24/7 shift management system for security companies, designed to streamline operations and enhance efficiency. It supports multi-role authentication (Admin, Coordinator, Guard, Client) and multi-location operations, managing over 250 security personnel across different branches. Key capabilities include comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system. The project aims to provide a robust, scalable solution for security companies, improving operational control and resource allocation.
VigilanzaTurni is a professional 24/7 shift management system for security companies, designed to streamline operations and enhance efficiency. It supports multi-role authentication (Admin, Coordinator, Guard, Client) and multi-location operations, managing over 250 security personnel across different branches (Roccapiemonte, Milano, Roma). Key capabilities include comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system.
## User Preferences
- Interfaccia in italiano
@ -19,7 +19,6 @@ VigilanzaTurni is a professional 24/7 shift management system for security compa
- **Autenticazione**: Replit Auth (OIDC)
- **State Management**: TanStack Query v5
- **Routing**: Wouter
- **Maps**: Leaflet + react-leaflet + OpenStreetMap tiles
### Design System
- **Font Principale**: Inter (sans-serif)
@ -29,41 +28,20 @@ VigilanzaTurni is a professional 24/7 shift management system for security compa
- **Componenti**: Shadcn UI with an operational design.
### Database Schema
The database supports managing users, guards, certifications, sites, shifts, shift assignments, notifications, customers, and service types. It also includes tables for advanced scheduling constraints such as guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Service types include specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, and `responseTimeMinutes`.
The database includes tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, `notifications`, `customers`, and various tables for advanced scheduling and constraints (`guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, `absence_affected_shifts`). Service types include specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, and `responseTimeMinutes`. Sites support multi-location (`location` field) and contract management (`contractReference`, `contractStartDate`, `contractEndDate`).
### Core Features
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch.
- **Service Type Classification**: Classifies services as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules.
- **Planning Fissi**: Weekly planning grid for fixed posts, enabling shift creation with guard availability checks. Includes weekly shift duplication feature with confirmation dialog and automatic navigation.
- **Planning Mobile**: Guard-centric interface for mobile services, displaying guard availability and hours, with an interactive Leaflet map showing sites. Features include:
- **Smart Site Assignment Indicators**: Sites already in patrol routes display "Assegnato a [Guard Name]" button with scroll-to functionality; unassigned sites show "Non assegnato" text
- **Drag-and-Drop Reordering**: Interactive drag-and-drop using @dnd-kit library for patrol route stops with visual feedback and automatic sequenceOrder persistence
- **Route Optimization**: OSRM API integration with TSP (Traveling Salesman Problem) nearest neighbor algorithm; displays total distance (km) and estimated travel time in dedicated dialog
- **Patrol Sequence List View**: Daily view of planned patrol routes with stops visualization
- **Custom Shift Timing**: Configurable start time and duration for each patrol route (replaces hardcoded 08:00-20:00)
- **Shift Overlap Validation**: POST /api/patrol-routes/check-overlaps endpoint verifies:
- No conflicts with existing fixed post shifts (shift_assignments)
- No conflicts with other mobile patrol routes
- Weekly hours compliance with contract parameters (maxHoursPerWeek + maxOvertimePerWeek)
- **Force-Save Dialog**: Interactive conflict resolution when saving patrol routes with overlaps or contractual limit violations; shows detailed conflict information and allows coordinator override
- **Multi-Day Duplication**: Duplication dialog supports "numero giorni consecutivi" field to create patrol sequences across N consecutive days; includes overlap validation (conservative approach: blocks entire operation if any day has conflicts)
- **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export.
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering sites, guards, and vehicles by selected branch.
- **Service Type Classification**: Service types are classified as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules (Planning Fissi, Planning Mobile).
- **Planning Fissi**: Weekly planning grid showing all sites with active contracts, allowing direct shift creation for multiple days with guard availability checks.
- **Planning Mobile**: Dedicated guard-centric interface for mobile services, displaying guard availability and hours for mobile-classified sites. Includes a map placeholder for future integration.
- **Customer Management**: Full CRUD operations for customers with comprehensive details.
- **Customer-Centric Reports**: New reports aggregating data by customer, replacing site-based billing, with specific counters for fixed posts (hours), patrols (passages), inspections, and interventions. CSV export is supported.
- **Dashboard Operativa**: Live KPIs and real-time shift status.
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
- **Gestione Siti/Commesse**: Sites are associated with service types, including schedule, contract management, and location assignment. Automatic geocoding is supported.
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and unique badge numbers.
- **Gestione Siti/Commesse**: Service types with specialized parameters and minimum requirements. Sites include service schedule, contract management, and location assignment.
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses.
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Includes patrol route persistence and exclusivity constraints between fixed and mobile shifts.
- **Guard Planning Views**: Dedicated views for guards to see their fixed post shifts and mobile patrol routes.
- **Site Planning View**: Coordinators can view all guards assigned to a specific site over a week.
- **Shift Duplication Features**:
- **Weekly Copy (Planning Fissi)**: POST /api/shift-assignments/copy-week endpoint duplicates all shifts and assignments from selected week to next week (+7 days) with atomic transaction. Frontend includes confirmation dialog with week details and success feedback.
- **Patrol Sequence Duplication (Planning Mobili)**: POST /api/patrol-routes/duplicate endpoint with dual behavior: UPDATE when target date = source date (modifies guard), CREATE when different date (duplicates route with all stops). Frontend shows daily sequence list with duplication dialog (date picker defaulting to next day, guard selector pre-filled but changeable).
- **Guardie Settimanale**: Compact weekly schedule view showing all guards' assignments across the week in a grid format. Features include:
- **Weekly Grid View**: Guard names in first column, 7 daily columns (Mon-Sun) with compact cell display
- **Multi-Source Aggregation**: GET /api/weekly-guards-schedule endpoint aggregates fixed shifts, patrol routes, and absences by location and week
- **Compact Cell Format**: Fixed posts show "Site Name HH:mm-HH:mm", mobile patrols show "Pattuglia HH:mm-HH:mm", absences show status (Ferie/Malattia/Permesso/Riposo)
- **Read-Only Dialogs**: Clicking cells opens appropriate dialog (fixed shift details or mobile patrol info) with navigation links to Planning Fissi/Mobile for edits
- **Location and Week Filters**: Dropdown for branch selection, week navigation with prev/next buttons displaying "Settimana dal DD MMM al DD MMM YYYY"
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences.
### User Roles
- **Admin**: Full access.
@ -72,7 +50,7 @@ The database supports managing users, guards, certifications, sites, shifts, shi
- **Client**: View assigned sites, service reporting, KPIs.
### Critical Date/Timezone Handling
The system handles timezone conversions for shift times, converting Italy local time from the frontend to UTC for database storage, and back to Italy local time for display, accounting for DST.
To prevent timezone-related bugs, especially when assigning shifts, dates should always be constructed from components (`new Date(year, month-1, day)`) and never parsed from ISO strings directly using `parseISO()` or `new Date(string ISO)`. Date validation should use regex instead of `parseISO()`.
## External Dependencies
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
@ -82,8 +60,4 @@ The system handles timezone conversions for shift times, converting Italy local
- **Zod**: For schema validation.
- **TanStack Query**: For data fetching and state management.
- **Wouter**: For client-side routing.
- **date-fns**: For date manipulation and formatting.
- **Leaflet**: Interactive map library with react-leaflet bindings and OpenStreetMap tiles.
- **Nominatim**: OpenStreetMap geocoding API for automatic address-to-coordinates conversion.
- **OSRM (Open Source Routing Machine)**: Public API (router.project-osrm.org) for distance matrix calculation and route optimization in Planning Mobile. No authentication required.
- **@dnd-kit**: Drag-and-drop library (@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities) for interactive patrol route reordering.
- **date-fns**: For date manipulation and formatting.

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -297,50 +297,6 @@ export const shiftAssignments = pgTable("shift_assignments", {
// Actual check-in/out times (recorded when guard clocks in/out)
checkInTime: timestamp("check_in_time"),
checkOutTime: timestamp("check_out_time"),
// Dotazioni operative per questo turno specifico
isArmedOnDuty: boolean("is_armed_on_duty").default(false), // Guardia armata per questo turno
assignedVehicleId: varchar("assigned_vehicle_id").references(() => vehicles.id, { onDelete: "set null" }), // Automezzo assegnato
});
// ============= PATROL ROUTES (TURNI PATTUGLIA) =============
export const patrolRoutes = pgTable("patrol_routes", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }),
// Data e orari del turno pattuglia
shiftDate: date("shift_date").notNull(), // Data del turno
startTime: varchar("start_time").notNull(), // Orario inizio (HH:MM)
endTime: varchar("end_time").notNull(), // Orario fine (HH:MM)
status: shiftStatusEnum("status").notNull().default("planned"),
location: locationEnum("location").notNull(), // Sede di riferimento
// Dotazioni
vehicleId: varchar("vehicle_id").references(() => vehicles.id, { onDelete: "set null" }),
isArmedRoute: boolean("is_armed_route").default(false), // Percorso con guardia armata
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const patrolRouteStops = pgTable("patrol_route_stops", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
patrolRouteId: varchar("patrol_route_id").notNull().references(() => patrolRoutes.id, { onDelete: "cascade" }),
siteId: varchar("site_id").notNull().references(() => sites.id, { onDelete: "cascade" }),
sequenceOrder: integer("sequence_order").notNull(), // Ordine nel percorso (1, 2, 3...)
estimatedArrivalTime: varchar("estimated_arrival_time"), // Orario stimato arrivo (HH:MM)
actualArrivalTime: timestamp("actual_arrival_time"), // Orario effettivo arrivo
// Check completamento tappa
isCompleted: boolean("is_completed").default(false),
completedAt: timestamp("completed_at"),
notes: text("notes"), // Note specifiche per questa tappa
createdAt: timestamp("created_at").defaultNow(),
});
// ============= CCNL SETTINGS =============
@ -562,7 +518,6 @@ export const guardsRelations = relations(guards, ({ one, many }) => ({
}),
certifications: many(certifications),
shiftAssignments: many(shiftAssignments),
patrolRoutes: many(patrolRoutes),
constraints: one(guardConstraints),
sitePreferences: many(sitePreferences),
trainingCourses: many(trainingCourses),
@ -594,7 +549,6 @@ export const sitesRelations = relations(sites, ({ one, many }) => ({
references: [customers.id],
}),
shifts: many(shifts),
patrolRouteStops: many(patrolRouteStops),
preferences: many(sitePreferences),
}));
@ -619,33 +573,6 @@ export const shiftAssignmentsRelations = relations(shiftAssignments, ({ one }) =
fields: [shiftAssignments.guardId],
references: [guards.id],
}),
assignedVehicle: one(vehicles, {
fields: [shiftAssignments.assignedVehicleId],
references: [vehicles.id],
}),
}));
export const patrolRoutesRelations = relations(patrolRoutes, ({ one, many }) => ({
guard: one(guards, {
fields: [patrolRoutes.guardId],
references: [guards.id],
}),
vehicle: one(vehicles, {
fields: [patrolRoutes.vehicleId],
references: [vehicles.id],
}),
stops: many(patrolRouteStops),
}));
export const patrolRouteStopsRelations = relations(patrolRouteStops, ({ one }) => ({
patrolRoute: one(patrolRoutes, {
fields: [patrolRouteStops.patrolRouteId],
references: [patrolRoutes.id],
}),
site: one(sites, {
fields: [patrolRouteStops.siteId],
references: [sites.id],
}),
}));
export const notificationsRelations = relations(notifications, ({ one }) => ({
@ -804,17 +731,6 @@ export const insertShiftSchema = createInsertSchema(shifts).omit({
updatedAt: true,
});
export const insertPatrolRouteSchema = createInsertSchema(patrolRoutes).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertPatrolRouteStopSchema = createInsertSchema(patrolRouteStops).omit({
id: true,
createdAt: true,
});
// Form schema that accepts datetime strings and transforms to Date
export const insertShiftFormSchema = z.object({
siteId: z.string().min(1, "Sito obbligatorio"),

View File

@ -1,145 +1,7 @@
{
"version": "1.1.1",
"lastUpdate": "2025-11-15T10:11:44.404Z",
"version": "1.0.37",
"lastUpdate": "2025-10-23T09:23:28.990Z",
"changelog": [
{
"version": "1.1.1",
"date": "2025-11-15",
"type": "patch",
"description": "Deployment automatico v1.1.1"
},
{
"version": "1.1.0",
"date": "2025-10-25",
"type": "minor",
"description": "Deployment automatico v1.1.0"
},
{
"version": "1.0.58",
"date": "2025-10-25",
"type": "patch",
"description": "Deployment automatico v1.0.58"
},
{
"version": "1.0.57",
"date": "2025-10-25",
"type": "patch",
"description": "Deployment automatico v1.0.57"
},
{
"version": "1.0.56",
"date": "2025-10-25",
"type": "patch",
"description": "Deployment automatico v1.0.56"
},
{
"version": "1.0.55",
"date": "2025-10-25",
"type": "patch",
"description": "Deployment automatico v1.0.55"
},
{
"version": "1.0.54",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.54"
},
{
"version": "1.0.53",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.53"
},
{
"version": "1.0.52",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.52"
},
{
"version": "1.0.51",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.51"
},
{
"version": "1.0.50",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.50"
},
{
"version": "1.0.49",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.49"
},
{
"version": "1.0.48",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.48"
},
{
"version": "1.0.47",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.47"
},
{
"version": "1.0.46",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.46"
},
{
"version": "1.0.45",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.45"
},
{
"version": "1.0.44",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.44"
},
{
"version": "1.0.43",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.43"
},
{
"version": "1.0.42",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.42"
},
{
"version": "1.0.41",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.41"
},
{
"version": "1.0.40",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.40"
},
{
"version": "1.0.39",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.39"
},
{
"version": "1.0.38",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.38"
},
{
"version": "1.0.37",
"date": "2025-10-23",
@ -301,6 +163,72 @@
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.11"
},
{
"version": "1.0.10",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.10"
},
{
"version": "1.0.9",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.9"
},
{
"version": "1.0.8",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.8"
},
{
"version": "1.0.7",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.7"
},
{
"version": "1.0.6",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.6"
},
{
"version": "1.0.5",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.5"
},
{
"version": "1.0.4",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.4"
},
{
"version": "1.0.3",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.3"
},
{
"version": "1.0.2",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.2"
},
{
"version": "1.0.1",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.1"
},
{
"version": "1.0.0",
"date": "2025-01-17",
"type": "initial",
"description": "Versione iniziale VigilanzaTurni - Sistema completo gestione turni vigilanza"
}
]
}