Compare commits

..

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

13 changed files with 137 additions and 1329 deletions

View File

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

View File

@ -30,7 +30,6 @@ 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();
@ -58,7 +57,6 @@ function Router() {
<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,47 +39,47 @@ const menuItems: MenuItem[] = [
roles: ["admin", "coordinator", "guard", "client"],
},
{
title: "Planning",
icon: FolderKanban,
roles: ["admin", "coordinator"],
items: [
title: "Turni",
url: "/shifts",
icon: Calendar,
roles: ["admin", "coordinator", "guard"],
},
{
title: "Fissi",
url: "/general-planning",
title: "Pianificazione",
url: "/planning",
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Pianificazione Operativa",
url: "/operational-planning",
icon: Calendar,
roles: ["admin", "coordinator"],
},
{
title: "Mobili",
title: "Planning Fissi",
url: "/general-planning",
icon: BarChart3,
roles: ["admin", "coordinator"],
},
{
title: "Planning Mobile",
url: "/planning-mobile",
icon: Navigation,
roles: ["admin", "coordinator"],
},
{
title: "Vista",
title: "Planning di Servizio",
url: "/service-planning",
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Guardie Settimanale",
url: "/weekly-guards",
icon: Users,
roles: ["admin", "coordinator"],
},
],
},
{
title: "Scadenziario",
title: "Gestione Pianificazioni",
url: "/advanced-planning",
icon: Calendar,
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Anagrafiche",
icon: Building2,
roles: ["admin", "coordinator"],
items: [
{
title: "Guardie",
url: "/guards",
@ -119,19 +98,6 @@ const menuItems: MenuItem[] = [
icon: Briefcase,
roles: ["admin", "coordinator"],
},
{
title: "Automezzi",
url: "/vehicles",
icon: Car,
roles: ["admin", "coordinator"],
},
],
},
{
title: "Tipologia",
icon: Wrench,
roles: ["admin", "coordinator"],
items: [
{
title: "Servizi",
url: "/services",
@ -139,36 +105,16 @@ const menuItems: MenuItem[] = [
roles: ["admin", "coordinator"],
},
{
title: "Contratti",
url: "/parameters",
icon: Settings,
title: "Parco Automezzi",
url: "/vehicles",
icon: Car,
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,
roles: ["admin", "coordinator", "guard"],
items: [
{
title: "Utenti",
url: "/users",
icon: UserCog,
roles: ["admin"],
},
{
title: "Notifiche",
@ -176,7 +122,17 @@ const menuItems: MenuItem[] = [
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>
const filteredItems = menuItems.filter(
(item) => user && item.roles.includes(user.role)
);
}
// 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>
);
};
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, Copy } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
@ -717,19 +717,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 +758,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>
</SelectItem>
))
) : (
<SelectItem value="no-guards" disabled>
{showOvertimeGuards
? "Nessuna guardia"
? "Nessuna guardia disponibile"
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
</SelectItem>
)}
@ -779,7 +774,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 && (

View File

@ -155,8 +155,6 @@ export default function PlanningMobile() {
const [mapCenter, setMapCenter] = useState<[number, number] | null>(null);
const [mapZoom, setMapZoom] = useState(12);
const [patrolRoute, setPatrolRoute] = useState<MobileSite[]>([]);
const [shiftStartTime, setShiftStartTime] = useState("08:00");
const [shiftDuration, setShiftDuration] = useState("8");
// State per dialog duplicazione sequenza
const [duplicateDialog, setDuplicateDialog] = useState<{
@ -164,13 +162,11 @@ export default function PlanningMobile() {
sourceRoute: any | null;
targetDate: string;
selectedDuplicateGuardId: string;
numDays: string;
}>({
isOpen: false,
sourceRoute: null,
targetDate: "",
selectedDuplicateGuardId: "",
numDays: "1",
});
// State per dialog risultati ottimizzazione
@ -184,19 +180,6 @@ export default function PlanningMobile() {
estimatedTime: "",
});
// State per dialog conferma forzatura turno
const [forceDialog, setForceDialog] = useState<{
isOpen: boolean;
conflicts: any[];
weeklyHours: any;
patrolRouteData: any;
}>({
isOpen: false,
conflicts: [],
weeklyHours: null,
patrolRouteData: null,
});
// Ref per scroll alla sezione sequenze pattuglia
const patrolSequencesRef = useRef<HTMLDivElement>(null);
@ -287,7 +270,6 @@ export default function PlanningMobile() {
sourceRoute: null,
targetDate: "",
selectedDuplicateGuardId: "",
numDays: "1",
});
},
onError: (error: any) => {
@ -433,7 +415,6 @@ export default function PlanningMobile() {
sourceRoute: route,
targetDate: nextDay, // Default = giorno successivo
selectedDuplicateGuardId: route.guardId || "", // Pre-compilato con guardia attuale
numDays: "1",
});
} catch (error) {
toast({
@ -445,8 +426,8 @@ export default function PlanningMobile() {
}
};
// Handler submit dialog duplicazione con supporto giorni multipli
const handleSubmitDuplicate = async () => {
// Handler submit dialog duplicazione
const handleSubmitDuplicate = () => {
if (!duplicateDialog.sourceRoute || !duplicateDialog.targetDate || !duplicateDialog.selectedDuplicateGuardId) {
toast({
title: "Campi mancanti",
@ -456,116 +437,11 @@ export default function PlanningMobile() {
return;
}
const numDays = parseInt(duplicateDialog.numDays) || 1;
// Se numDays === 1, comportamento standard
if (numDays === 1) {
duplicatePatrolRouteMutation.mutate({
sourceRouteId: duplicateDialog.sourceRoute.id,
targetDate: duplicateDialog.targetDate,
guardId: duplicateDialog.selectedDuplicateGuardId,
});
return;
}
// Duplicazione multipla: verifica sovrapposizioni per tutti i giorni
try {
const startDate = parseISO(duplicateDialog.targetDate);
// Verifica sovrapposizioni per ogni giorno usando timing reale della route sorgente
let hasAnyConflict = false;
const sourceStartTime = duplicateDialog.sourceRoute.startTime || "08:00";
// Usa endTime se presente, altrimenti calcola da duration (se presente), altrimenti fallback a 8h
let sourceEndTime: string;
if (duplicateDialog.sourceRoute.endTime) {
sourceEndTime = duplicateDialog.sourceRoute.endTime;
} else {
const durationStr = duplicateDialog.sourceRoute.duration?.toString() || "8";
sourceEndTime = calculateEndTime(sourceStartTime, durationStr);
}
for (let i = 0; i < numDays; i++) {
const targetDate = format(addDays(startDate, i), "yyyy-MM-dd");
const checkResponse = await apiRequest("POST", "/api/patrol-routes/check-overlaps", {
guardId: duplicateDialog.selectedDuplicateGuardId,
shiftDate: targetDate,
startTime: sourceStartTime,
endTime: sourceEndTime,
excludeRouteId: null,
});
const checkData = await checkResponse.json();
if (checkData.hasConflicts || checkData.weeklyHours.exceedsLimit) {
hasAnyConflict = true;
break; // Ferma al primo conflitto
}
}
// Se ci sono conflitti, avvisa l'utente e blocca
if (hasAnyConflict) {
toast({
title: "Conflitto rilevato",
description: "Almeno un giorno ha conflitti di sovrapposizione turni. La duplicazione multipla è stata bloccata per sicurezza. Duplica singolarmente i giorni per gestire i conflitti.",
variant: "destructive",
});
return;
}
// Nessun conflitto, procedi con duplicazione
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < numDays; i++) {
const targetDate = format(addDays(startDate, i), "yyyy-MM-dd");
try {
const response = await apiRequest("POST", "/api/patrol-routes/duplicate", {
sourceRouteId: duplicateDialog.sourceRoute.id,
targetDate: targetDate,
guardId: duplicateDialog.selectedDuplicateGuardId,
});
await response.json();
successCount++;
} catch (error) {
errorCount++;
console.error(`Error duplicating for ${targetDate}:`, error);
}
}
// Mostra risultato
if (successCount > 0) {
toast({
title: "Duplicazione completata!",
description: `${successCount} sequenze create con successo${errorCount > 0 ? ` (${errorCount} errori)` : ''}`,
});
} else {
toast({
title: "Errore",
description: "Nessuna sequenza è stata duplicata",
variant: "destructive",
});
}
// Chiudi dialog e invalida cache
await queryClient.invalidateQueries({ queryKey: ["/api/patrol-routes"] });
setDuplicateDialog({
isOpen: false,
sourceRoute: null,
targetDate: "",
selectedDuplicateGuardId: "",
numDays: "1",
});
} catch (error) {
toast({
title: "Errore duplicazione multipla",
description: "Errore durante la duplicazione delle sequenze",
variant: "destructive",
});
}
};
// Funzione per aggiungere sito alla patrol route
@ -705,20 +581,8 @@ export default function PlanningMobile() {
optimizeRouteMutation.mutate(coordinates);
};
// Helper per calcolare endTime da startTime + durata
const calculateEndTime = (startTime: string, durationHours: string): string => {
const [hours, minutes] = startTime.split(':').map(Number);
const duration = parseFloat(durationHours);
const totalMinutes = hours * 60 + minutes + duration * 60;
const endHours = Math.floor(totalMinutes / 60) % 24;
const endMinutes = totalMinutes % 60;
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
};
// Funzione per salvare il turno pattuglia con controllo sovrapposizioni
const handleSavePatrolRoute = async () => {
// Funzione per salvare il turno pattuglia
const handleSavePatrolRoute = () => {
if (!selectedGuard) {
toast({
title: "Guardia non selezionata",
@ -737,15 +601,12 @@ export default function PlanningMobile() {
return;
}
// Calcola endTime
const endTime = calculateEndTime(shiftStartTime, shiftDuration);
// Prepara i dati per il salvataggio
const patrolRouteData = {
guardId: selectedGuard.id,
shiftDate: selectedDate,
startTime: shiftStartTime,
endTime: endTime,
startTime: "08:00", // TODO: permettere all'utente di configurare
endTime: "20:00",
location: selectedLocation,
status: "planned",
stops: patrolRoute.map((site) => ({
@ -758,70 +619,10 @@ export default function PlanningMobile() {
(route: any) => route.guardId === selectedGuard.id
);
// Verifica sovrapposizioni con endpoint
try {
const checkResponse = await apiRequest("POST", "/api/patrol-routes/check-overlaps", {
guardId: selectedGuard.id,
shiftDate: selectedDate,
startTime: shiftStartTime,
endTime: endTime,
excludeRouteId: existingRoute?.id || null,
});
const checkData = await checkResponse.json();
// Se ci sono conflitti o si superano ore contrattuali, mostra dialog
if (checkData.hasConflicts || checkData.weeklyHours.exceedsLimit) {
setForceDialog({
isOpen: true,
conflicts: checkData.conflicts || [],
weeklyHours: checkData.weeklyHours,
patrolRouteData: {
...patrolRouteData,
existingRouteId: existingRoute?.id,
},
});
return;
}
// Nessun problema, salva direttamente
savePatrolRouteMutation.mutate({
data: patrolRouteData,
existingRouteId: existingRoute?.id,
});
} catch (error) {
toast({
title: "Errore verifica sovrapposizioni",
description: "Impossibile verificare conflitti turni. Riprova.",
variant: "destructive",
});
}
};
// Funzione per forzare il salvataggio ignorando i conflitti
const handleForceSave = () => {
const { patrolRouteData } = forceDialog;
if (!patrolRouteData) return;
savePatrolRouteMutation.mutate({
data: {
guardId: patrolRouteData.guardId,
shiftDate: patrolRouteData.shiftDate,
startTime: patrolRouteData.startTime,
endTime: patrolRouteData.endTime,
location: patrolRouteData.location,
status: patrolRouteData.status,
stops: patrolRouteData.stops,
},
existingRouteId: patrolRouteData.existingRouteId,
});
setForceDialog({
isOpen: false,
conflicts: [],
weeklyHours: null,
patrolRouteData: null,
});
};
// Carica patrol route esistente quando si seleziona una guardia
@ -928,34 +729,6 @@ export default function PlanningMobile() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Campi Orario Turno */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted/30 rounded-md">
<div className="space-y-2">
<Label htmlFor="shift-start-time">Ora Inizio</Label>
<Input
id="shift-start-time"
type="time"
value={shiftStartTime}
onChange={(e) => setShiftStartTime(e.target.value)}
data-testid="input-shift-start-time"
/>
</div>
<div className="space-y-2">
<Label htmlFor="shift-duration">Durata (ore)</Label>
<Input
id="shift-duration"
type="number"
min="1"
max="24"
step="0.5"
value={shiftDuration}
onChange={(e) => setShiftDuration(e.target.value)}
data-testid="input-shift-duration"
/>
</div>
</div>
{/* Lista Tappe Draggable */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@ -1306,95 +1079,6 @@ export default function PlanningMobile() {
</CardContent>
</Card>
{/* Dialog Conferma Forzatura Turno */}
<Dialog open={forceDialog.isOpen} onOpenChange={(open) => {
if (!open) {
setForceDialog({
isOpen: false,
conflicts: [],
weeklyHours: null,
patrolRouteData: null,
});
}
}}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-orange-600">
Attenzione: Sovrapposizione Turni
</DialogTitle>
<DialogDescription>
La guardia ha già turni assegnati nelle ore indicate o supererebbe i limiti contrattuali.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Conflitti */}
{forceDialog.conflicts && forceDialog.conflicts.length > 0 && (
<div className="space-y-2">
<h4 className="font-semibold text-sm">Turni in Conflitto:</h4>
<div className="space-y-2">
{forceDialog.conflicts.map((conflict: any, idx: number) => (
<div key={idx} className="p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm">
<div className="font-medium">
{conflict.type === 'fisso' ? `Turno Fisso - ${conflict.siteName}` : `Turno Mobile`}
</div>
<div className="text-xs text-muted-foreground">
{conflict.type === 'fisso' ? (
<>
{format(new Date(conflict.startTime), "dd/MM/yyyy HH:mm")} - {format(new Date(conflict.endTime), "HH:mm")}
</>
) : (
<>
{conflict.shiftDate} {conflict.startTime} - {conflict.endTime}
</>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Ore Settimanali */}
{forceDialog.weeklyHours && (
<div className="space-y-2">
<h4 className="font-semibold text-sm">Ore Settimanali:</h4>
<div className="p-3 bg-muted/30 rounded-md space-y-1 text-sm">
<div className="flex justify-between">
<span>Ore attuali:</span>
<span className="font-medium">{forceDialog.weeklyHours.current}h</span>
</div>
<div className="flex justify-between">
<span>Ore nuovo turno:</span>
<span className="font-medium text-blue-600">{forceDialog.weeklyHours.newShiftHours}h</span>
</div>
<div className="flex justify-between border-t pt-1">
<span>Totale con nuovo turno:</span>
<span className={`font-bold ${forceDialog.weeklyHours.exceedsLimit ? 'text-destructive' : ''}`}>
{forceDialog.weeklyHours.withNewShift}h / {forceDialog.weeklyHours.maxTotal}h
</span>
</div>
{forceDialog.weeklyHours.exceedsLimit && (
<p className="text-xs text-destructive pt-2">
Superamento limite contrattuale di {Math.round((forceDialog.weeklyHours.withNewShift - forceDialog.weeklyHours.maxTotal) * 100) / 100}h
</p>
)}
</div>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setForceDialog({ isOpen: false, conflicts: [], weeklyHours: null, patrolRouteData: null })}>
Annulla
</Button>
<Button variant="destructive" onClick={handleForceSave} data-testid="button-force-save">
Forza Salvataggio
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog Duplica Sequenza */}
<Dialog open={duplicateDialog.isOpen} onOpenChange={(open) => {
if (!open) {
@ -1403,7 +1087,6 @@ export default function PlanningMobile() {
sourceRoute: null,
targetDate: "",
selectedDuplicateGuardId: "",
numDays: "1",
});
}
}}>
@ -1442,7 +1125,7 @@ export default function PlanningMobile() {
{/* Data Target */}
<div className="space-y-2">
<Label htmlFor="target-date">Data di Partenza *</Label>
<Label htmlFor="target-date">Data di Destinazione *</Label>
<Input
id="target-date"
type="date"
@ -1454,28 +1137,11 @@ export default function PlanningMobile() {
{duplicateDialog.sourceRoute && duplicateDialog.targetDate && duplicateDialog.sourceRoute.shiftDate && isValid(parseISO(duplicateDialog.sourceRoute.shiftDate)) &&
format(parseISO(duplicateDialog.sourceRoute.shiftDate), "yyyy-MM-dd") === duplicateDialog.targetDate
? "⚠️ Stessa data: verrà modificata la guardia della sequenza esistente"
: "✓ Data diversa: verranno create nuove sequenze consecutive"
: "✓ Data diversa: verrà creata una nuova sequenza con tutte le tappe"
}
</p>
</div>
{/* Numero Giorni */}
<div className="space-y-2">
<Label htmlFor="num-days">Numero Giorni Consecutivi</Label>
<Input
id="num-days"
type="number"
min="1"
max="30"
value={duplicateDialog.numDays}
onChange={(e) => setDuplicateDialog({ ...duplicateDialog, numDays: e.target.value })}
data-testid="input-num-days"
/>
<p className="text-xs text-muted-foreground">
Duplica la sequenza per {duplicateDialog.numDays} {parseInt(duplicateDialog.numDays) === 1 ? 'giorno' : 'giorni'} consecutivi a partire dalla data indicata
</p>
</div>
{/* Selezione Guardia */}
<div className="space-y-2">
<Label htmlFor="guard-select">Guardia Assegnata *</Label>
@ -1505,7 +1171,6 @@ export default function PlanningMobile() {
sourceRoute: null,
targetDate: "",
selectedDuplicateGuardId: "",
numDays: "1",
})}
data-testid="button-cancel-duplicate"
>

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

View File

@ -40,13 +40,7 @@ The database supports managing users, guards, certifications, sites, shifts, shi
- **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)
- **Duplication/Modification Dialog**: Copy routes to different dates or modify assigned guard
- **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export.
- **Dashboard Operativa**: Live KPIs and real-time shift status.
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
@ -58,12 +52,6 @@ The database supports managing users, guards, certifications, sites, shifts, shi
- **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"
### User Roles
- **Admin**: Full access.

View File

@ -4,8 +4,8 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
import { db } from "./db";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema, customers, patrolRoutes, patrolRouteStops, insertPatrolRouteSchema, absences } from "@shared/schema";
import { eq, and, gte, lte, lt, desc, asc, ne, sql } from "drizzle-orm";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema, customers, patrolRoutes, patrolRouteStops, insertPatrolRouteSchema } from "@shared/schema";
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
@ -4348,190 +4348,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// POST - Verifica sovrapposizioni turni e calcola ore settimanali
app.post("/api/patrol-routes/check-overlaps", isAuthenticated, async (req: any, res) => {
try {
const { guardId, shiftDate, startTime, endTime, excludeRouteId } = req.body;
if (!guardId || !shiftDate || !startTime || !endTime) {
return res.status(400).json({
message: "guardId, shiftDate, startTime e endTime sono obbligatori"
});
}
// Converte orari in timestamp per confronto
const shiftDateObj = new Date(shiftDate);
const [startHour, startMin] = startTime.split(':').map(Number);
const [endHour, endMin] = endTime.split(':').map(Number);
const startTimestamp = new Date(shiftDateObj);
startTimestamp.setHours(startHour, startMin, 0, 0);
const endTimestamp = new Date(shiftDateObj);
endTimestamp.setHours(endHour, endMin, 0, 0);
// Se endTime è minore di startTime, il turno attraversa la mezzanotte
if (endTimestamp <= startTimestamp) {
endTimestamp.setDate(endTimestamp.getDate() + 1);
}
const conflicts = [];
// 1. Controlla sovrapposizioni con shift_assignments (turni fissi)
const fixedShifts = await db
.select({
id: shiftAssignments.id,
siteName: sites.name,
plannedStartTime: shiftAssignments.plannedStartTime,
plannedEndTime: shiftAssignments.plannedEndTime,
})
.from(shiftAssignments)
.leftJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
.leftJoin(sites, eq(shifts.siteId, sites.id))
.where(eq(shiftAssignments.guardId, guardId));
for (const shift of fixedShifts) {
const shiftStart = new Date(shift.plannedStartTime);
const shiftEnd = new Date(shift.plannedEndTime);
// Controlla sovrapposizione
if (startTimestamp < shiftEnd && endTimestamp > shiftStart) {
conflicts.push({
type: 'fisso',
siteName: shift.siteName,
startTime: shift.plannedStartTime,
endTime: shift.plannedEndTime,
});
}
}
// 2. Controlla sovrapposizioni con patrol_routes (turni mobili)
const mobileShifts = await db
.select()
.from(patrolRoutes)
.where(
and(
eq(patrolRoutes.guardId, guardId),
excludeRouteId ? ne(patrolRoutes.id, excludeRouteId) : undefined
)
);
for (const route of mobileShifts) {
const routeDateObj = new Date(route.shiftDate);
const [rStartHour, rStartMin] = route.startTime.split(':').map(Number);
const [rEndHour, rEndMin] = route.endTime.split(':').map(Number);
const routeStart = new Date(routeDateObj);
routeStart.setHours(rStartHour, rStartMin, 0, 0);
const routeEnd = new Date(routeDateObj);
routeEnd.setHours(rEndHour, rEndMin, 0, 0);
if (routeEnd <= routeStart) {
routeEnd.setDate(routeEnd.getDate() + 1);
}
// Controlla sovrapposizione
if (startTimestamp < routeEnd && endTimestamp > routeStart) {
conflicts.push({
type: 'mobile',
shiftDate: route.shiftDate,
startTime: route.startTime,
endTime: route.endTime,
});
}
}
// 3. Calcola ore settimanali
const weekStart = new Date(shiftDateObj);
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + (weekStart.getDay() === 0 ? -6 : 1));
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7);
let totalWeeklyHours = 0;
// Ore da turni fissi
const weeklyFixedShifts = await db
.select({
plannedStartTime: shiftAssignments.plannedStartTime,
plannedEndTime: shiftAssignments.plannedEndTime,
})
.from(shiftAssignments)
.where(
and(
eq(shiftAssignments.guardId, guardId),
gte(shiftAssignments.plannedStartTime, weekStart),
lt(shiftAssignments.plannedStartTime, weekEnd)
)
);
for (const shift of weeklyFixedShifts) {
const start = new Date(shift.plannedStartTime);
const end = new Date(shift.plannedEndTime);
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
totalWeeklyHours += hours;
}
// Ore da turni mobili
const weeklyMobileShifts = await db
.select()
.from(patrolRoutes)
.where(
and(
eq(patrolRoutes.guardId, guardId),
gte(patrolRoutes.shiftDate, weekStart.toISOString().split('T')[0]),
lt(patrolRoutes.shiftDate, weekEnd.toISOString().split('T')[0]),
excludeRouteId ? ne(patrolRoutes.id, excludeRouteId) : undefined
)
);
for (const route of weeklyMobileShifts) {
const [rStartHour, rStartMin] = route.startTime.split(':').map(Number);
const [rEndHour, rEndMin] = route.endTime.split(':').map(Number);
let hours = rEndHour - rStartHour + (rEndMin - rStartMin) / 60;
if (hours < 0) hours += 24; // Turno attraversa mezzanotte
totalWeeklyHours += hours;
}
// Aggiungi ore del nuovo turno
const newShiftHours = (endTimestamp.getTime() - startTimestamp.getTime()) / (1000 * 60 * 60);
const totalHoursWithNew = totalWeeklyHours + newShiftHours;
// 4. Recupera limiti contrattuali
const contractParams = await db
.select()
.from(contractParameters)
.limit(1);
const maxHours = contractParams[0]?.maxHoursPerWeek || 40;
const maxOvertime = contractParams[0]?.maxOvertimePerWeek || 8;
const totalMaxHours = maxHours + maxOvertime;
const exceedsContractLimit = totalHoursWithNew > totalMaxHours;
res.json({
hasConflicts: conflicts.length > 0,
conflicts,
weeklyHours: {
current: Math.round(totalWeeklyHours * 100) / 100,
withNewShift: Math.round(totalHoursWithNew * 100) / 100,
newShiftHours: Math.round(newShiftHours * 100) / 100,
maxRegular: maxHours,
maxOvertime: maxOvertime,
maxTotal: totalMaxHours,
exceedsLimit: exceedsContractLimit,
},
});
} catch (error) {
console.error("Error checking overlaps:", error);
res.status(500).json({ message: "Errore verifica sovrapposizioni" });
}
});
// ============= GEOCODING API (Nominatim/OSM) =============
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
@ -4705,108 +4521,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
return `${minutes}m`;
}
// ============= WEEKLY GUARDS SCHEDULE =============
app.get("/api/weekly-guards-schedule", isAuthenticated, async (req: any, res) => {
try {
const location = req.query.location as string;
const startDate = req.query.startDate as string;
if (!location || !startDate) {
return res.status(400).json({ message: "Location e startDate richiesti" });
}
// Calcola l'intervallo della settimana (7 giorni da startDate)
const weekStart = parseISO(startDate);
const weekEnd = addDays(weekStart, 6);
// Recupera tutte le guardie della sede
const guardsInLocation = await db
.select()
.from(guards)
.where(eq(guards.location, location))
.orderBy(guards.lastName, guards.firstName);
// Prepara la struttura dati
const guardsSchedule = [];
for (const guard of guardsInLocation) {
// Recupera turni fissi della settimana (shift_assignments)
const fixedShifts = await db
.select({
assignmentId: shiftAssignments.id,
shiftId: shifts.id,
plannedStartTime: shiftAssignments.plannedStartTime,
plannedEndTime: shiftAssignments.plannedEndTime,
siteName: sites.name,
siteId: sites.id,
})
.from(shiftAssignments)
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
.innerJoin(sites, eq(shifts.siteId, sites.id))
.where(
and(
eq(shiftAssignments.guardId, guard.id),
gte(shiftAssignments.plannedStartTime, weekStart),
lte(shiftAssignments.plannedStartTime, weekEnd)
)
);
// Recupera turni mobili della settimana (patrol_routes)
const mobileShifts = await db
.select({
routeId: patrolRoutes.id,
shiftDate: patrolRoutes.shiftDate,
startTime: patrolRoutes.startTime,
endTime: patrolRoutes.endTime,
})
.from(patrolRoutes)
.where(
and(
eq(patrolRoutes.guardId, guard.id),
gte(sql`${patrolRoutes.shiftDate}`, startDate),
lte(sql`${patrolRoutes.shiftDate}`, format(weekEnd, 'yyyy-MM-dd'))
)
);
// Recupera assenze della settimana
const absencesData = await db
.select()
.from(absences)
.where(
and(
eq(absences.guardId, guard.id),
lte(sql`${absences.startDate}`, format(weekEnd, 'yyyy-MM-dd')),
gte(sql`${absences.endDate}`, startDate)
)
);
guardsSchedule.push({
guard: {
id: guard.id,
firstName: guard.firstName,
lastName: guard.lastName,
badgeNumber: guard.badgeNumber,
},
fixedShifts,
mobileShifts,
absences: absencesData,
});
}
res.json({
weekStart: format(weekStart, 'yyyy-MM-dd'),
weekEnd: format(weekEnd, 'yyyy-MM-dd'),
location,
guards: guardsSchedule,
});
} catch (error) {
console.error("Error fetching weekly guards schedule:", error);
res.status(500).json({ message: "Errore nel recupero della pianificazione settimanale" });
}
});
const httpServer = createServer(app);
return httpServer;
}

View File

@ -1,19 +1,7 @@
{
"version": "1.1.1",
"lastUpdate": "2025-11-15T10:11:44.404Z",
"version": "1.0.58",
"lastUpdate": "2025-10-25T09:05:02.565Z",
"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",
@ -301,6 +289,18 @@
"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"
}
]
}