Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
591687b000 | ||
|
|
ee5d1aaa24 | ||
|
|
fc6f5a39f8 | ||
|
|
758a697447 | ||
|
|
40cde1634b | ||
|
|
7b05c8cbce | ||
|
|
d868c6ee31 | ||
|
|
a10b50e7e9 | ||
|
|
c8825a9b4c | ||
|
|
7961971ad0 | ||
|
|
3e3b9851a8 | ||
|
|
3fc0c55fd2 | ||
|
|
2eb53bb1b2 | ||
|
|
a9f3453755 | ||
|
|
dd84ddb35b | ||
|
|
5c22ec14f1 | ||
|
|
efa056dd98 | ||
|
|
b132082ffc | ||
|
|
e5ce415aeb | ||
|
|
34bdb99599 | ||
|
|
3cc1739015 | ||
|
|
20f24ba25e | ||
|
|
bd55070abc | ||
|
|
7a6fd3245b | ||
|
|
bafc34065e | ||
|
|
b4c6400360 |
4
.replit
4
.replit
@ -19,6 +19,10 @@ externalPort = 80
|
|||||||
localPort = 33035
|
localPort = 33035
|
||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 40417
|
||||||
|
externalPort = 8000
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41295
|
localPort = 41295
|
||||||
externalPort = 5173
|
externalPort = 5173
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import PlanningMobile from "@/pages/planning-mobile";
|
|||||||
import MyShiftsFixed from "@/pages/my-shifts-fixed";
|
import MyShiftsFixed from "@/pages/my-shifts-fixed";
|
||||||
import MyShiftsMobile from "@/pages/my-shifts-mobile";
|
import MyShiftsMobile from "@/pages/my-shifts-mobile";
|
||||||
import SitePlanningView from "@/pages/site-planning-view";
|
import SitePlanningView from "@/pages/site-planning-view";
|
||||||
|
import WeeklyGuards from "@/pages/weekly-guards";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
@ -57,6 +58,7 @@ function Router() {
|
|||||||
<Route path="/my-shifts-fixed" component={MyShiftsFixed} />
|
<Route path="/my-shifts-fixed" component={MyShiftsFixed} />
|
||||||
<Route path="/my-shifts-mobile" component={MyShiftsMobile} />
|
<Route path="/my-shifts-mobile" component={MyShiftsMobile} />
|
||||||
<Route path="/site-planning-view" component={SitePlanningView} />
|
<Route path="/site-planning-view" component={SitePlanningView} />
|
||||||
|
<Route path="/weekly-guards" component={WeeklyGuards} />
|
||||||
<Route path="/reports" component={Reports} />
|
<Route path="/reports" component={Reports} />
|
||||||
<Route path="/notifications" component={Notifications} />
|
<Route path="/notifications" component={Notifications} />
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={Users} />
|
||||||
|
|||||||
@ -12,6 +12,11 @@ import {
|
|||||||
Car,
|
Car,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Navigation,
|
Navigation,
|
||||||
|
ChevronDown,
|
||||||
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
|
Building2,
|
||||||
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import {
|
import {
|
||||||
@ -23,15 +28,31 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarMenuSubButton,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
const menuItems = [
|
interface MenuItem {
|
||||||
|
title: string;
|
||||||
|
url?: string;
|
||||||
|
icon: any;
|
||||||
|
roles: string[];
|
||||||
|
items?: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
url: "/",
|
url: "/",
|
||||||
@ -39,100 +60,123 @@ const menuItems = [
|
|||||||
roles: ["admin", "coordinator", "guard", "client"],
|
roles: ["admin", "coordinator", "guard", "client"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Turni",
|
title: "Planning",
|
||||||
url: "/shifts",
|
icon: FolderKanban,
|
||||||
icon: Calendar,
|
|
||||||
roles: ["admin", "coordinator", "guard"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Pianificazione",
|
|
||||||
url: "/planning",
|
|
||||||
icon: ClipboardList,
|
|
||||||
roles: ["admin", "coordinator"],
|
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: "Pianificazione Operativa",
|
title: "Scadenziario",
|
||||||
url: "/operational-planning",
|
|
||||||
icon: Calendar,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Planning Fissi",
|
|
||||||
url: "/general-planning",
|
|
||||||
icon: BarChart3,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
url: "/advanced-planning",
|
||||||
icon: ClipboardList,
|
icon: Calendar,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Guardie",
|
title: "Anagrafiche",
|
||||||
url: "/guards",
|
icon: Building2,
|
||||||
icon: Users,
|
|
||||||
roles: ["admin", "coordinator"],
|
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: "Siti",
|
title: "Tipologia",
|
||||||
url: "/sites",
|
icon: Wrench,
|
||||||
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"],
|
roles: ["admin", "coordinator"],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Servizi",
|
||||||
|
url: "/services",
|
||||||
|
icon: Briefcase,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Contratti",
|
||||||
|
url: "/parameters",
|
||||||
|
icon: Settings,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Report",
|
title: "Report",
|
||||||
url: "/reports",
|
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
roles: ["admin", "coordinator", "client"],
|
roles: ["admin", "coordinator", "client"],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Report Amministrativo",
|
||||||
|
url: "/reports",
|
||||||
|
icon: FileText,
|
||||||
|
roles: ["admin", "coordinator", "client"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Notifiche",
|
title: "Utilità",
|
||||||
url: "/notifications",
|
|
||||||
icon: Bell,
|
|
||||||
roles: ["admin", "coordinator", "guard"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Utenti",
|
|
||||||
url: "/users",
|
|
||||||
icon: UserCog,
|
|
||||||
roles: ["admin"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Parametri",
|
|
||||||
url: "/parameters",
|
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator", "guard"],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Utenti",
|
||||||
|
url: "/users",
|
||||||
|
icon: UserCog,
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Notifiche",
|
||||||
|
url: "/notifications",
|
||||||
|
icon: Bell,
|
||||||
|
roles: ["admin", "coordinator", "guard"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -140,9 +184,78 @@ export function AppSidebar() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
|
|
||||||
const filteredItems = menuItems.filter(
|
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
|
||||||
(item) => user && item.roles.includes(user.role)
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@ -161,20 +274,7 @@ export function AppSidebar() {
|
|||||||
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
|
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{filteredItems.map((item) => (
|
{filteredItems.map(renderMenuItem)}
|
||||||
<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>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock, Copy } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock, Copy, Circle } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@ -717,19 +717,19 @@ export default function GeneralPlanning() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Select guardia disponibile */}
|
{/* Select guardia (tutte, evidenziate in rosso se impegnate) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
|
// Mostra TUTTE le guardie, ma filtra solo per ore ordinarie/straordinario
|
||||||
const filteredGuards = availableGuards?.filter(g =>
|
const filteredGuards = availableGuards?.filter(g =>
|
||||||
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
|
showOvertimeGuards || !g.requiresOvertime
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime && g.isAvailable) || false;
|
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime) || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="guard-select">Guardia Disponibile</Label>
|
<Label htmlFor="guard-select">Guardia</Label>
|
||||||
{!isLoadingGuards && hasOvertimeGuards && (
|
{!isLoadingGuards && hasOvertimeGuards && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -758,15 +758,20 @@ export default function GeneralPlanning() {
|
|||||||
{filteredGuards.length > 0 ? (
|
{filteredGuards.length > 0 ? (
|
||||||
filteredGuards.map((guard) => (
|
filteredGuards.map((guard) => (
|
||||||
<SelectItem key={guard.guardId} value={guard.guardId}>
|
<SelectItem key={guard.guardId} value={guard.guardId}>
|
||||||
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
|
<div className={`flex items-center gap-1.5 ${guard.isAvailable ? "" : "text-destructive font-medium"}`}>
|
||||||
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
|
{!guard.isAvailable && <Circle className="h-3 w-3 fill-current" />}
|
||||||
{guard.requiresOvertime && " 🔸"}
|
<span>
|
||||||
|
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
|
||||||
|
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
|
||||||
|
{guard.requiresOvertime && " 🔸"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<SelectItem value="no-guards" disabled>
|
<SelectItem value="no-guards" disabled>
|
||||||
{showOvertimeGuards
|
{showOvertimeGuards
|
||||||
? "Nessuna guardia disponibile"
|
? "Nessuna guardia"
|
||||||
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
|
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
@ -774,7 +779,7 @@ export default function GeneralPlanning() {
|
|||||||
</Select>
|
</Select>
|
||||||
{filteredGuards.length === 0 && !showOvertimeGuards && hasOvertimeGuards && (
|
{filteredGuards.length === 0 && !showOvertimeGuards && hasOvertimeGuards && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
ℹ️ Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
|
ℹ️ Alcune guardie richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{filteredGuards.length > 0 && selectedGuardId && (
|
{filteredGuards.length > 0 && selectedGuardId && (
|
||||||
|
|||||||
@ -6,8 +6,25 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered, Copy } from "lucide-react";
|
import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered, Copy, GripVertical, Sparkles } from "lucide-react";
|
||||||
import { format, parseISO, isValid, addDays } from "date-fns";
|
import { format, parseISO, isValid, addDays } from "date-fns";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -61,6 +78,50 @@ type MobileSite = {
|
|||||||
longitude: string | null;
|
longitude: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Componente per tappa draggable
|
||||||
|
function SortableStop({ site, index, onRemove }: { site: MobileSite; index: number; onRemove: () => void }) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: site.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20 cursor-move"
|
||||||
|
data-testid={`route-stop-${index}`}
|
||||||
|
>
|
||||||
|
<div {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-green-600">
|
||||||
|
{index + 1}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium flex-1">{site.name}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
data-testid={`button-remove-stop-${index}`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type AvailableGuard = {
|
type AvailableGuard = {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@ -94,6 +155,8 @@ export default function PlanningMobile() {
|
|||||||
const [mapCenter, setMapCenter] = useState<[number, number] | null>(null);
|
const [mapCenter, setMapCenter] = useState<[number, number] | null>(null);
|
||||||
const [mapZoom, setMapZoom] = useState(12);
|
const [mapZoom, setMapZoom] = useState(12);
|
||||||
const [patrolRoute, setPatrolRoute] = useState<MobileSite[]>([]);
|
const [patrolRoute, setPatrolRoute] = useState<MobileSite[]>([]);
|
||||||
|
const [shiftStartTime, setShiftStartTime] = useState("08:00");
|
||||||
|
const [shiftDuration, setShiftDuration] = useState("8");
|
||||||
|
|
||||||
// State per dialog duplicazione sequenza
|
// State per dialog duplicazione sequenza
|
||||||
const [duplicateDialog, setDuplicateDialog] = useState<{
|
const [duplicateDialog, setDuplicateDialog] = useState<{
|
||||||
@ -101,13 +164,63 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: any | null;
|
sourceRoute: any | null;
|
||||||
targetDate: string;
|
targetDate: string;
|
||||||
selectedDuplicateGuardId: string;
|
selectedDuplicateGuardId: string;
|
||||||
|
numDays: string;
|
||||||
}>({
|
}>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
|
numDays: "1",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// State per dialog risultati ottimizzazione
|
||||||
|
const [optimizationResults, setOptimizationResults] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
totalDistanceKm: string;
|
||||||
|
estimatedTime: string;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
totalDistanceKm: "",
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Sensors per drag-and-drop
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler per riordinare le tappe con drag-and-drop
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setPatrolRoute((items) => {
|
||||||
|
const oldIndex = items.findIndex(item => item.id === active.id);
|
||||||
|
const newIndex = items.findIndex(item => item.id === over.id);
|
||||||
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Query siti mobile per location
|
// Query siti mobile per location
|
||||||
const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({
|
const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({
|
||||||
queryKey: ["/api/planning-mobile/sites", selectedLocation],
|
queryKey: ["/api/planning-mobile/sites", selectedLocation],
|
||||||
@ -174,6 +287,7 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
|
numDays: "1",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@ -254,6 +368,34 @@ export default function PlanningMobile() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Funzione per trovare la guardia assegnata a un sito
|
||||||
|
const findAssignedGuard = (siteId: string) => {
|
||||||
|
if (!existingPatrolRoutes) return null;
|
||||||
|
|
||||||
|
for (const route of existingPatrolRoutes) {
|
||||||
|
const hasStop = route.stops?.some((stop: any) => stop.siteId === siteId);
|
||||||
|
if (hasStop) {
|
||||||
|
const guard = availableGuards?.find(g => g.id === route.guardId);
|
||||||
|
return guard || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Funzione per fare scroll alla sezione sequenze pattuglia
|
||||||
|
const handleScrollToPatrolSequences = () => {
|
||||||
|
if (patrolSequencesRef.current) {
|
||||||
|
patrolSequencesRef.current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Sequenza visualizzata",
|
||||||
|
description: "Scorri la lista delle sequenze pattuglia",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Funzione per assegnare guardia a un sito
|
// Funzione per assegnare guardia a un sito
|
||||||
const handleAssignGuard = (site: MobileSite) => {
|
const handleAssignGuard = (site: MobileSite) => {
|
||||||
if (!selectedGuard) {
|
if (!selectedGuard) {
|
||||||
@ -291,6 +433,7 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: route,
|
sourceRoute: route,
|
||||||
targetDate: nextDay, // Default = giorno successivo
|
targetDate: nextDay, // Default = giorno successivo
|
||||||
selectedDuplicateGuardId: route.guardId || "", // Pre-compilato con guardia attuale
|
selectedDuplicateGuardId: route.guardId || "", // Pre-compilato con guardia attuale
|
||||||
|
numDays: "1",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@ -302,8 +445,8 @@ export default function PlanningMobile() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handler submit dialog duplicazione
|
// Handler submit dialog duplicazione con supporto giorni multipli
|
||||||
const handleSubmitDuplicate = () => {
|
const handleSubmitDuplicate = async () => {
|
||||||
if (!duplicateDialog.sourceRoute || !duplicateDialog.targetDate || !duplicateDialog.selectedDuplicateGuardId) {
|
if (!duplicateDialog.sourceRoute || !duplicateDialog.targetDate || !duplicateDialog.selectedDuplicateGuardId) {
|
||||||
toast({
|
toast({
|
||||||
title: "Campi mancanti",
|
title: "Campi mancanti",
|
||||||
@ -313,11 +456,116 @@ export default function PlanningMobile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicatePatrolRouteMutation.mutate({
|
const numDays = parseInt(duplicateDialog.numDays) || 1;
|
||||||
sourceRouteId: duplicateDialog.sourceRoute.id,
|
|
||||||
targetDate: duplicateDialog.targetDate,
|
// Se numDays === 1, comportamento standard
|
||||||
guardId: duplicateDialog.selectedDuplicateGuardId,
|
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
|
// Funzione per aggiungere sito alla patrol route
|
||||||
@ -358,6 +606,36 @@ export default function PlanningMobile() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mutation per ottimizzare percorso
|
||||||
|
const optimizeRouteMutation = useMutation({
|
||||||
|
mutationFn: async (coordinates: { lat: string; lon: string; id: string; name: string }[]) => {
|
||||||
|
const response = await apiRequest("POST", "/api/optimize-route", { coordinates });
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
// Riordina le tappe secondo l'ordine ottimizzato
|
||||||
|
const optimizedStops = data.optimizedRoute.map((coord: any) =>
|
||||||
|
patrolRoute.find(site => site.id === coord.id)
|
||||||
|
).filter((site: any) => site !== undefined) as MobileSite[];
|
||||||
|
|
||||||
|
setPatrolRoute(optimizedStops);
|
||||||
|
|
||||||
|
// Mostra dialog con risultati
|
||||||
|
setOptimizationResults({
|
||||||
|
isOpen: true,
|
||||||
|
totalDistanceKm: data.totalDistanceKm,
|
||||||
|
estimatedTime: data.estimatedTimeFormatted,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Errore ottimizzazione",
|
||||||
|
description: error.message || "Impossibile ottimizzare il percorso",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Mutation per salvare patrol route
|
// Mutation per salvare patrol route
|
||||||
const savePatrolRouteMutation = useMutation({
|
const savePatrolRouteMutation = useMutation({
|
||||||
mutationFn: async ({ data, existingRouteId }: { data: any; existingRouteId?: string }) => {
|
mutationFn: async ({ data, existingRouteId }: { data: any; existingRouteId?: string }) => {
|
||||||
@ -388,8 +666,59 @@ export default function PlanningMobile() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Funzione per salvare il turno pattuglia
|
// Funzione per ottimizzare il percorso
|
||||||
const handleSavePatrolRoute = () => {
|
const handleOptimizeRoute = () => {
|
||||||
|
// Verifica che ci siano almeno 2 tappe
|
||||||
|
if (patrolRoute.length < 2) {
|
||||||
|
toast({
|
||||||
|
title: "Tappe insufficienti",
|
||||||
|
description: "Servono almeno 2 tappe per ottimizzare il percorso",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica che tutte le tappe abbiano coordinate GPS
|
||||||
|
const sitesWithCoords = patrolRoute.filter(site =>
|
||||||
|
site.latitude && site.longitude &&
|
||||||
|
!isNaN(parseFloat(site.latitude)) &&
|
||||||
|
!isNaN(parseFloat(site.longitude))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sitesWithCoords.length !== patrolRoute.length) {
|
||||||
|
toast({
|
||||||
|
title: "Coordinate GPS mancanti",
|
||||||
|
description: `${patrolRoute.length - sitesWithCoords.length} tappe non hanno coordinate GPS valide`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepara dati per ottimizzazione
|
||||||
|
const coordinates = sitesWithCoords.map(site => ({
|
||||||
|
lat: site.latitude!,
|
||||||
|
lon: site.longitude!,
|
||||||
|
id: site.id,
|
||||||
|
name: site.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 () => {
|
||||||
if (!selectedGuard) {
|
if (!selectedGuard) {
|
||||||
toast({
|
toast({
|
||||||
title: "Guardia non selezionata",
|
title: "Guardia non selezionata",
|
||||||
@ -408,12 +737,15 @@ export default function PlanningMobile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calcola endTime
|
||||||
|
const endTime = calculateEndTime(shiftStartTime, shiftDuration);
|
||||||
|
|
||||||
// Prepara i dati per il salvataggio
|
// Prepara i dati per il salvataggio
|
||||||
const patrolRouteData = {
|
const patrolRouteData = {
|
||||||
guardId: selectedGuard.id,
|
guardId: selectedGuard.id,
|
||||||
shiftDate: selectedDate,
|
shiftDate: selectedDate,
|
||||||
startTime: "08:00", // TODO: permettere all'utente di configurare
|
startTime: shiftStartTime,
|
||||||
endTime: "20:00",
|
endTime: endTime,
|
||||||
location: selectedLocation,
|
location: selectedLocation,
|
||||||
status: "planned",
|
status: "planned",
|
||||||
stops: patrolRoute.map((site) => ({
|
stops: patrolRoute.map((site) => ({
|
||||||
@ -426,9 +758,69 @@ export default function PlanningMobile() {
|
|||||||
(route: any) => route.guardId === selectedGuard.id
|
(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({
|
savePatrolRouteMutation.mutate({
|
||||||
data: patrolRouteData,
|
data: {
|
||||||
existingRouteId: existingRoute?.id,
|
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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -531,38 +923,76 @@ export default function PlanningMobile() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{patrolRoute.length} tappe programmate per il turno del {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
|
{patrolRoute.length} tappe programmate per il turno del {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
|
||||||
|
<br />
|
||||||
|
<span className="text-xs">Trascina le tappe per riordinarle</span>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex gap-2 flex-wrap">
|
{/* Campi Orario Turno */}
|
||||||
{patrolRoute.map((site, index) => (
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted/30 rounded-md">
|
||||||
<div
|
<div className="space-y-2">
|
||||||
key={site.id}
|
<Label htmlFor="shift-start-time">Ora Inizio</Label>
|
||||||
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20"
|
<Input
|
||||||
data-testid={`route-stop-${index}`}
|
id="shift-start-time"
|
||||||
>
|
type="time"
|
||||||
<Badge className="bg-green-600">
|
value={shiftStartTime}
|
||||||
{index + 1}
|
onChange={(e) => setShiftStartTime(e.target.value)}
|
||||||
</Badge>
|
data-testid="input-shift-start-time"
|
||||||
<span className="text-sm font-medium">{site.name}</span>
|
/>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
<div className="space-y-2">
|
||||||
variant="ghost"
|
<Label htmlFor="shift-duration">Durata (ore)</Label>
|
||||||
onClick={() => handleRemoveFromRoute(site.id)}
|
<Input
|
||||||
className="h-6 w-6 p-0"
|
id="shift-duration"
|
||||||
data-testid={`button-remove-stop-${index}`}
|
type="number"
|
||||||
>
|
min="1"
|
||||||
✕
|
max="24"
|
||||||
</Button>
|
step="0.5"
|
||||||
</div>
|
value={shiftDuration}
|
||||||
))}
|
onChange={(e) => setShiftDuration(e.target.value)}
|
||||||
|
data-testid="input-shift-duration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
|
{/* Lista Tappe Draggable */}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={patrolRoute.map(site => site.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{patrolRoute.map((site, index) => (
|
||||||
|
<SortableStop
|
||||||
|
key={site.id}
|
||||||
|
site={site}
|
||||||
|
index={index}
|
||||||
|
onRemove={() => handleRemoveFromRoute(site.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
<div className="flex gap-2 pt-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSavePatrolRoute}
|
onClick={handleSavePatrolRoute}
|
||||||
|
disabled={savePatrolRouteMutation.isPending}
|
||||||
data-testid="button-save-patrol-route"
|
data-testid="button-save-patrol-route"
|
||||||
>
|
>
|
||||||
Salva Turno Pattuglia
|
{savePatrolRouteMutation.isPending ? "Salvataggio..." : "Salva Turno Pattuglia"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleOptimizeRoute}
|
||||||
|
disabled={optimizeRouteMutation.isPending || patrolRoute.length < 2}
|
||||||
|
data-testid="button-optimize-route"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
{optimizeRouteMutation.isPending ? "Ottimizzazione..." : "Ottimizza Sequenza"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -669,6 +1099,7 @@ export default function PlanningMobile() {
|
|||||||
mobileSites.map((site) => {
|
mobileSites.map((site) => {
|
||||||
const isInRoute = patrolRoute.some(s => s.id === site.id);
|
const isInRoute = patrolRoute.some(s => s.id === site.id);
|
||||||
const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
|
const routeIndex = patrolRoute.findIndex(s => s.id === site.id);
|
||||||
|
const assignedGuard = findAssignedGuard(site.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -700,15 +1131,22 @@ export default function PlanningMobile() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2 items-center">
|
||||||
<Button
|
{assignedGuard ? (
|
||||||
size="sm"
|
<Button
|
||||||
variant="default"
|
size="sm"
|
||||||
onClick={() => handleAssignGuard(site)}
|
variant="default"
|
||||||
data-testid={`button-assign-${site.id}`}
|
onClick={handleScrollToPatrolSequences}
|
||||||
>
|
data-testid={`button-assigned-${site.id}`}
|
||||||
Assegna Guardia
|
>
|
||||||
</Button>
|
<User className="h-4 w-4 mr-2" />
|
||||||
|
Assegnato a {assignedGuard.firstName} {assignedGuard.lastName}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground" data-testid={`text-not-assigned-${site.id}`}>
|
||||||
|
Non assegnato
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -788,7 +1226,7 @@ export default function PlanningMobile() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Sequenze Pattuglia del Giorno */}
|
{/* Sequenze Pattuglia del Giorno */}
|
||||||
<Card>
|
<Card ref={patrolSequencesRef}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<ListOrdered className="h-5 w-5" />
|
<ListOrdered className="h-5 w-5" />
|
||||||
@ -868,6 +1306,95 @@ export default function PlanningMobile() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 Duplica Sequenza */}
|
||||||
<Dialog open={duplicateDialog.isOpen} onOpenChange={(open) => {
|
<Dialog open={duplicateDialog.isOpen} onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -876,6 +1403,7 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
|
numDays: "1",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
@ -899,8 +1427,8 @@ export default function PlanningMobile() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground">Data:</span>
|
<span className="text-muted-foreground">Data:</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{duplicateDialog.sourceRoute.scheduledDate && isValid(parseISO(duplicateDialog.sourceRoute.scheduledDate))
|
{duplicateDialog.sourceRoute.shiftDate && isValid(parseISO(duplicateDialog.sourceRoute.shiftDate))
|
||||||
? format(parseISO(duplicateDialog.sourceRoute.scheduledDate), "dd/MM/yyyy", { locale: it })
|
? format(parseISO(duplicateDialog.sourceRoute.shiftDate), "dd/MM/yyyy", { locale: it })
|
||||||
: "Data non valida"}
|
: "Data non valida"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -914,7 +1442,7 @@ export default function PlanningMobile() {
|
|||||||
|
|
||||||
{/* Data Target */}
|
{/* Data Target */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="target-date">Data di Destinazione *</Label>
|
<Label htmlFor="target-date">Data di Partenza *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="target-date"
|
id="target-date"
|
||||||
type="date"
|
type="date"
|
||||||
@ -923,14 +1451,31 @@ export default function PlanningMobile() {
|
|||||||
data-testid="input-target-date"
|
data-testid="input-target-date"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{duplicateDialog.sourceRoute && duplicateDialog.targetDate && duplicateDialog.sourceRoute.scheduledDate && isValid(parseISO(duplicateDialog.sourceRoute.scheduledDate)) &&
|
{duplicateDialog.sourceRoute && duplicateDialog.targetDate && duplicateDialog.sourceRoute.shiftDate && isValid(parseISO(duplicateDialog.sourceRoute.shiftDate)) &&
|
||||||
format(parseISO(duplicateDialog.sourceRoute.scheduledDate), "yyyy-MM-dd") === duplicateDialog.targetDate
|
format(parseISO(duplicateDialog.sourceRoute.shiftDate), "yyyy-MM-dd") === duplicateDialog.targetDate
|
||||||
? "⚠️ Stessa data: verrà modificata la guardia della sequenza esistente"
|
? "⚠️ Stessa data: verrà modificata la guardia della sequenza esistente"
|
||||||
: "✓ Data diversa: verrà creata una nuova sequenza con tutte le tappe"
|
: "✓ Data diversa: verranno create nuove sequenze consecutive"
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Selezione Guardia */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="guard-select">Guardia Assegnata *</Label>
|
<Label htmlFor="guard-select">Guardia Assegnata *</Label>
|
||||||
@ -960,6 +1505,7 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
|
numDays: "1",
|
||||||
})}
|
})}
|
||||||
data-testid="button-cancel-duplicate"
|
data-testid="button-cancel-duplicate"
|
||||||
>
|
>
|
||||||
@ -975,6 +1521,67 @@ export default function PlanningMobile() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Dialog Risultati Ottimizzazione */}
|
||||||
|
<Dialog open={optimizationResults.isOpen} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setOptimizationResults({
|
||||||
|
isOpen: false,
|
||||||
|
totalDistanceKm: "",
|
||||||
|
estimatedTime: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
Percorso Ottimizzato
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Il percorso è stato ottimizzato per ridurre i chilometri percorsi
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-muted-foreground">Distanza Totale</Label>
|
||||||
|
<div className="text-2xl font-bold">{optimizationResults.totalDistanceKm} km</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Partenza e ritorno dalla prima tappa
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-muted-foreground">Tempo Stimato</Label>
|
||||||
|
<div className="text-2xl font-bold">{optimizationResults.estimatedTime}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tempo di percorrenza stimato
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-muted/30 rounded-md">
|
||||||
|
<p className="text-sm">
|
||||||
|
Le tappe sono state riordinate per minimizzare la distanza percorsa.
|
||||||
|
Salva il turno pattuglia per confermare le modifiche.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setOptimizationResults({
|
||||||
|
isOpen: false,
|
||||||
|
totalDistanceKm: "",
|
||||||
|
estimatedTime: "",
|
||||||
|
})}
|
||||||
|
data-testid="button-close-optimization-results"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
448
client/src/pages/weekly-guards.tsx
Normal file
448
client/src/pages/weekly-guards.tsx
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.55_20251025_072838.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.55_20251025_072838.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.56_20251025_074951.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.56_20251025_074951.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.57_20251025_080211.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.57_20251025_080211.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.58_20251025_090445.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.58_20251025_090445.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.1.0_20251025_090838.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.1.0_20251025_090838.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.1.1_20251115_101122.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.1.1_20251115_101122.sql.gz
Normal file
Binary file not shown.
56
package-lock.json
generated
56
package-lock.json
generated
@ -9,6 +9,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
"@neondatabase/serverless": "^0.10.4",
|
||||||
@ -417,6 +420,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||||
|
|||||||
@ -11,6 +11,9 @@
|
|||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
"@neondatabase/serverless": "^0.10.4",
|
||||||
|
|||||||
21
replit.md
21
replit.md
@ -35,7 +35,18 @@ The database supports managing users, guards, certifications, sites, shifts, shi
|
|||||||
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch.
|
- **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.
|
- **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 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. Includes patrol sequence list view and duplication/modification dialog.
|
- **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.
|
- **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export.
|
||||||
- **Dashboard Operativa**: Live KPIs and real-time shift status.
|
- **Dashboard Operativa**: Live KPIs and real-time shift status.
|
||||||
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
|
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
|
||||||
@ -47,6 +58,12 @@ The database supports managing users, guards, certifications, sites, shifts, shi
|
|||||||
- **Shift Duplication Features**:
|
- **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.
|
- **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).
|
- **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
|
### User Roles
|
||||||
- **Admin**: Full access.
|
- **Admin**: Full access.
|
||||||
@ -68,3 +85,5 @@ The system handles timezone conversions for shift times, converting Italy local
|
|||||||
- **date-fns**: For date manipulation and formatting.
|
- **date-fns**: For date manipulation and formatting.
|
||||||
- **Leaflet**: Interactive map library with react-leaflet bindings and OpenStreetMap tiles.
|
- **Leaflet**: Interactive map library with react-leaflet bindings and OpenStreetMap tiles.
|
||||||
- **Nominatim**: OpenStreetMap geocoding API for automatic address-to-coordinates conversion.
|
- **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.
|
||||||
407
server/routes.ts
407
server/routes.ts
@ -4,8 +4,8 @@ import { storage } from "./storage";
|
|||||||
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
||||||
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema, customers, patrolRoutes, patrolRouteStops, insertPatrolRouteSchema } from "@shared/schema";
|
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, desc, asc, ne, sql } from "drizzle-orm";
|
import { eq, and, gte, lte, lt, desc, asc, ne, sql } from "drizzle-orm";
|
||||||
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
@ -4281,8 +4281,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
return res.status(404).json({ message: "Sequenza pattuglia sorgente non trovata" });
|
return res.status(404).json({ message: "Sequenza pattuglia sorgente non trovata" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Controlla se targetDate è uguale a sourceRoute.scheduledDate
|
// Controlla se targetDate è uguale a sourceRoute.shiftDate
|
||||||
const sourceDate = new Date(sourceRoute.scheduledDate).toISOString().split('T')[0];
|
const sourceDate = new Date(sourceRoute.shiftDate).toISOString().split('T')[0];
|
||||||
const targetDateNormalized = new Date(targetDate).toISOString().split('T')[0];
|
const targetDateNormalized = new Date(targetDate).toISOString().split('T')[0];
|
||||||
|
|
||||||
if (sourceDate === targetDateNormalized) {
|
if (sourceDate === targetDateNormalized) {
|
||||||
@ -4312,10 +4312,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
.insert(patrolRoutes)
|
.insert(patrolRoutes)
|
||||||
.values({
|
.values({
|
||||||
guardId: guardId || sourceRoute.guardId, // Usa nuova guardia o mantieni originale
|
guardId: guardId || sourceRoute.guardId, // Usa nuova guardia o mantieni originale
|
||||||
scheduledDate: new Date(targetDate),
|
shiftDate: targetDate,
|
||||||
startTime: sourceRoute.startTime,
|
startTime: sourceRoute.startTime,
|
||||||
endTime: sourceRoute.endTime,
|
endTime: sourceRoute.endTime,
|
||||||
status: "scheduled", // Nuova sequenza sempre in stato scheduled
|
status: "planned", // Nuova sequenza sempre in stato planned
|
||||||
location: sourceRoute.location,
|
location: sourceRoute.location,
|
||||||
notes: sourceRoute.notes,
|
notes: sourceRoute.notes,
|
||||||
})
|
})
|
||||||
@ -4348,6 +4348,190 @@ 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) =============
|
// ============= GEOCODING API (Nominatim/OSM) =============
|
||||||
|
|
||||||
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
||||||
@ -4412,6 +4596,217 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============= ROUTE OPTIMIZATION API (OSRM + TSP) =============
|
||||||
|
|
||||||
|
app.post("/api/optimize-route", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { coordinates } = req.body;
|
||||||
|
|
||||||
|
// Validazione: array di coordinate [{lat, lon, id}]
|
||||||
|
if (!Array.isArray(coordinates) || coordinates.length < 2) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Almeno 2 coordinate richieste per l'ottimizzazione"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica formato coordinate
|
||||||
|
for (const coord of coordinates) {
|
||||||
|
if (!coord.lat || !coord.lon || !coord.id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Ogni coordinata deve avere lat, lon e id"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 1: Calcola matrice distanze usando OSRM Table API
|
||||||
|
const coordsString = coordinates.map(c => `${c.lon},${c.lat}`).join(';');
|
||||||
|
const osrmTableUrl = `https://router.project-osrm.org/table/v1/driving/${coordsString}?annotations=distance,duration`;
|
||||||
|
|
||||||
|
const osrmResponse = await fetch(osrmTableUrl);
|
||||||
|
if (!osrmResponse.ok) {
|
||||||
|
throw new Error(`OSRM API error: ${osrmResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const osrmData = await osrmResponse.json();
|
||||||
|
|
||||||
|
if (osrmData.code !== 'Ok' || !osrmData.distances || !osrmData.durations) {
|
||||||
|
throw new Error("OSRM non ha restituito dati validi");
|
||||||
|
}
|
||||||
|
|
||||||
|
const distances = osrmData.distances; // Matrice NxN in metri
|
||||||
|
const durations = osrmData.durations; // Matrice NxN in secondi
|
||||||
|
|
||||||
|
// STEP 2: Applica algoritmo TSP Nearest Neighbor
|
||||||
|
// Inizia dalla prima tappa (indice 0)
|
||||||
|
const n = coordinates.length;
|
||||||
|
const visited = new Set<number>();
|
||||||
|
const route: number[] = [];
|
||||||
|
let current = 0;
|
||||||
|
let totalDistance = 0;
|
||||||
|
let totalDuration = 0;
|
||||||
|
|
||||||
|
visited.add(current);
|
||||||
|
route.push(current);
|
||||||
|
|
||||||
|
// Trova sempre il vicino più vicino non visitato
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
let nearest = -1;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
if (!visited.has(j) && distances[current][j] < minDistance) {
|
||||||
|
minDistance = distances[current][j];
|
||||||
|
nearest = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearest !== -1) {
|
||||||
|
totalDistance += distances[current][nearest];
|
||||||
|
totalDuration += durations[current][nearest];
|
||||||
|
visited.add(nearest);
|
||||||
|
route.push(nearest);
|
||||||
|
current = nearest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ritorna al punto di partenza (circuito chiuso)
|
||||||
|
totalDistance += distances[current][0];
|
||||||
|
totalDuration += durations[current][0];
|
||||||
|
|
||||||
|
// STEP 3: Prepara risposta
|
||||||
|
const optimizedRoute = route.map(index => ({
|
||||||
|
...coordinates[index],
|
||||||
|
order: route.indexOf(index) + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
optimizedRoute,
|
||||||
|
totalDistanceMeters: Math.round(totalDistance),
|
||||||
|
totalDistanceKm: (totalDistance / 1000).toFixed(2),
|
||||||
|
totalDurationSeconds: Math.round(totalDuration),
|
||||||
|
totalDurationMinutes: Math.round(totalDuration / 60),
|
||||||
|
estimatedTimeFormatted: formatDuration(totalDuration),
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error optimizing route:", error);
|
||||||
|
res.status(500).json({ message: "Errore durante l'ottimizzazione del percorso" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper per formattare durata in ore e minuti
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
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);
|
const httpServer = createServer(app);
|
||||||
return httpServer;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|||||||
76
version.json
76
version.json
@ -1,7 +1,43 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.54",
|
"version": "1.1.1",
|
||||||
"lastUpdate": "2025-10-24T17:25:43.941Z",
|
"lastUpdate": "2025-11-15T10:11:44.404Z",
|
||||||
"changelog": [
|
"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",
|
"version": "1.0.54",
|
||||||
"date": "2025-10-24",
|
"date": "2025-10-24",
|
||||||
@ -265,42 +301,6 @@
|
|||||||
"date": "2025-10-17",
|
"date": "2025-10-17",
|
||||||
"type": "patch",
|
"type": "patch",
|
||||||
"description": "Deployment automatico v1.0.11"
|
"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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user