Compare commits
No commits in common. "main" and "v1.0.54" have entirely different histories.
4
.replit
4
.replit
@ -19,10 +19,6 @@ 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,7 +30,6 @@ 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();
|
||||||
@ -58,7 +57,6 @@ 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,11 +12,6 @@ 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 {
|
||||||
@ -28,31 +23,15 @@ 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";
|
||||||
|
|
||||||
interface MenuItem {
|
const menuItems = [
|
||||||
title: string;
|
|
||||||
url?: string;
|
|
||||||
icon: any;
|
|
||||||
roles: string[];
|
|
||||||
items?: MenuItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
url: "/",
|
url: "/",
|
||||||
@ -60,47 +39,47 @@ const menuItems: MenuItem[] = [
|
|||||||
roles: ["admin", "coordinator", "guard", "client"],
|
roles: ["admin", "coordinator", "guard", "client"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Planning",
|
title: "Turni",
|
||||||
icon: FolderKanban,
|
url: "/shifts",
|
||||||
roles: ["admin", "coordinator"],
|
icon: Calendar,
|
||||||
items: [
|
roles: ["admin", "coordinator", "guard"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Fissi",
|
title: "Pianificazione",
|
||||||
url: "/general-planning",
|
url: "/planning",
|
||||||
|
icon: ClipboardList,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Pianificazione Operativa",
|
||||||
|
url: "/operational-planning",
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Mobili",
|
title: "Planning Fissi",
|
||||||
|
url: "/general-planning",
|
||||||
|
icon: BarChart3,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Planning Mobile",
|
||||||
url: "/planning-mobile",
|
url: "/planning-mobile",
|
||||||
icon: Navigation,
|
icon: Navigation,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Vista",
|
title: "Planning di Servizio",
|
||||||
url: "/service-planning",
|
url: "/service-planning",
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Guardie Settimanale",
|
title: "Gestione Pianificazioni",
|
||||||
url: "/weekly-guards",
|
|
||||||
icon: Users,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Scadenziario",
|
|
||||||
url: "/advanced-planning",
|
url: "/advanced-planning",
|
||||||
icon: Calendar,
|
icon: ClipboardList,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Anagrafiche",
|
|
||||||
icon: Building2,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
title: "Guardie",
|
title: "Guardie",
|
||||||
url: "/guards",
|
url: "/guards",
|
||||||
@ -119,19 +98,6 @@ const menuItems: MenuItem[] = [
|
|||||||
icon: Briefcase,
|
icon: Briefcase,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Automezzi",
|
|
||||||
url: "/vehicles",
|
|
||||||
icon: Car,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Tipologia",
|
|
||||||
icon: Wrench,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
title: "Servizi",
|
title: "Servizi",
|
||||||
url: "/services",
|
url: "/services",
|
||||||
@ -139,36 +105,16 @@ const menuItems: MenuItem[] = [
|
|||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Contratti",
|
title: "Parco Automezzi",
|
||||||
url: "/parameters",
|
url: "/vehicles",
|
||||||
icon: Settings,
|
icon: Car,
|
||||||
roles: ["admin", "coordinator"],
|
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: "Utilità",
|
|
||||||
icon: Settings,
|
|
||||||
roles: ["admin", "coordinator", "guard"],
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Utenti",
|
|
||||||
url: "/users",
|
|
||||||
icon: UserCog,
|
|
||||||
roles: ["admin"],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Notifiche",
|
title: "Notifiche",
|
||||||
@ -176,7 +122,17 @@ const menuItems: MenuItem[] = [
|
|||||||
icon: Bell,
|
icon: Bell,
|
||||||
roles: ["admin", "coordinator", "guard"],
|
roles: ["admin", "coordinator", "guard"],
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
|
title: "Utenti",
|
||||||
|
url: "/users",
|
||||||
|
icon: UserCog,
|
||||||
|
roles: ["admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Parametri",
|
||||||
|
url: "/parameters",
|
||||||
|
icon: Settings,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -184,78 +140,9 @@ export function AppSidebar() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
|
|
||||||
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
|
const filteredItems = menuItems.filter(
|
||||||
if (!user) return [];
|
(item) => user && item.roles.includes(user.role)
|
||||||
|
|
||||||
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>
|
||||||
@ -274,7 +161,20 @@ export function AppSidebar() {
|
|||||||
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
|
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{filteredItems.map(renderMenuItem)}
|
{filteredItems.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={location === item.url}
|
||||||
|
data-testid={`link-${item.title.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
<Link href={item.url}>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
</SidebarMenu>
|
</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, Circle } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock, Copy } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { 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 (tutte, evidenziate in rosso se impegnate) */}
|
{/* Select guardia disponibile */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Mostra TUTTE le guardie, ma filtra solo per ore ordinarie/straordinario
|
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
|
||||||
const filteredGuards = availableGuards?.filter(g =>
|
const filteredGuards = availableGuards?.filter(g =>
|
||||||
showOvertimeGuards || !g.requiresOvertime
|
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime) || false;
|
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime && g.isAvailable) || false;
|
||||||
|
|
||||||
return (
|
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</Label>
|
<Label htmlFor="guard-select">Guardia Disponibile</Label>
|
||||||
{!isLoadingGuards && hasOvertimeGuards && (
|
{!isLoadingGuards && hasOvertimeGuards && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -758,20 +758,15 @@ 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}>
|
||||||
<div className={`flex items-center gap-1.5 ${guard.isAvailable ? "" : "text-destructive font-medium"}`}>
|
|
||||||
{!guard.isAvailable && <Circle className="h-3 w-3 fill-current" />}
|
|
||||||
<span>
|
|
||||||
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
|
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
|
||||||
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
|
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
|
||||||
{guard.requiresOvertime && " 🔸"}
|
{guard.requiresOvertime && " 🔸"}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<SelectItem value="no-guards" disabled>
|
<SelectItem value="no-guards" disabled>
|
||||||
{showOvertimeGuards
|
{showOvertimeGuards
|
||||||
? "Nessuna guardia"
|
? "Nessuna guardia disponibile"
|
||||||
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
|
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
@ -779,7 +774,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 richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
|
ℹ️ Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{filteredGuards.length > 0 && selectedGuardId && (
|
{filteredGuards.length > 0 && selectedGuardId && (
|
||||||
|
|||||||
@ -6,25 +6,8 @@ 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, GripVertical, Sparkles } from "lucide-react";
|
import { Calendar, MapPin, User, Car, Clock, Navigation, ListOrdered, Copy } 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,
|
||||||
@ -78,50 +61,6 @@ 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;
|
||||||
@ -155,8 +94,6 @@ 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<{
|
||||||
@ -164,63 +101,13 @@ 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],
|
||||||
@ -287,7 +174,6 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
numDays: "1",
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@ -368,34 +254,6 @@ 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) {
|
||||||
@ -433,7 +291,6 @@ 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({
|
||||||
@ -445,8 +302,8 @@ export default function PlanningMobile() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handler submit dialog duplicazione con supporto giorni multipli
|
// Handler submit dialog duplicazione
|
||||||
const handleSubmitDuplicate = async () => {
|
const handleSubmitDuplicate = () => {
|
||||||
if (!duplicateDialog.sourceRoute || !duplicateDialog.targetDate || !duplicateDialog.selectedDuplicateGuardId) {
|
if (!duplicateDialog.sourceRoute || !duplicateDialog.targetDate || !duplicateDialog.selectedDuplicateGuardId) {
|
||||||
toast({
|
toast({
|
||||||
title: "Campi mancanti",
|
title: "Campi mancanti",
|
||||||
@ -456,116 +313,11 @@ export default function PlanningMobile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const numDays = parseInt(duplicateDialog.numDays) || 1;
|
|
||||||
|
|
||||||
// Se numDays === 1, comportamento standard
|
|
||||||
if (numDays === 1) {
|
|
||||||
duplicatePatrolRouteMutation.mutate({
|
duplicatePatrolRouteMutation.mutate({
|
||||||
sourceRouteId: duplicateDialog.sourceRoute.id,
|
sourceRouteId: duplicateDialog.sourceRoute.id,
|
||||||
targetDate: duplicateDialog.targetDate,
|
targetDate: duplicateDialog.targetDate,
|
||||||
guardId: duplicateDialog.selectedDuplicateGuardId,
|
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
|
||||||
@ -606,36 +358,6 @@ 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 }) => {
|
||||||
@ -666,59 +388,8 @@ export default function PlanningMobile() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Funzione per ottimizzare il percorso
|
// Funzione per salvare il turno pattuglia
|
||||||
const handleOptimizeRoute = () => {
|
const handleSavePatrolRoute = () => {
|
||||||
// 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",
|
||||||
@ -737,15 +408,12 @@ 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: shiftStartTime,
|
startTime: "08:00", // TODO: permettere all'utente di configurare
|
||||||
endTime: endTime,
|
endTime: "20:00",
|
||||||
location: selectedLocation,
|
location: selectedLocation,
|
||||||
status: "planned",
|
status: "planned",
|
||||||
stops: patrolRoute.map((site) => ({
|
stops: patrolRoute.map((site) => ({
|
||||||
@ -758,70 +426,10 @@ 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({
|
savePatrolRouteMutation.mutate({
|
||||||
data: patrolRouteData,
|
data: patrolRouteData,
|
||||||
existingRouteId: existingRoute?.id,
|
existingRouteId: existingRoute?.id,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: "Errore verifica sovrapposizioni",
|
|
||||||
description: "Impossibile verificare conflitti turni. Riprova.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Funzione per forzare il salvataggio ignorando i conflitti
|
|
||||||
const handleForceSave = () => {
|
|
||||||
const { patrolRouteData } = forceDialog;
|
|
||||||
if (!patrolRouteData) return;
|
|
||||||
|
|
||||||
savePatrolRouteMutation.mutate({
|
|
||||||
data: {
|
|
||||||
guardId: patrolRouteData.guardId,
|
|
||||||
shiftDate: patrolRouteData.shiftDate,
|
|
||||||
startTime: patrolRouteData.startTime,
|
|
||||||
endTime: patrolRouteData.endTime,
|
|
||||||
location: patrolRouteData.location,
|
|
||||||
status: patrolRouteData.status,
|
|
||||||
stops: patrolRouteData.stops,
|
|
||||||
},
|
|
||||||
existingRouteId: patrolRouteData.existingRouteId,
|
|
||||||
});
|
|
||||||
|
|
||||||
setForceDialog({
|
|
||||||
isOpen: false,
|
|
||||||
conflicts: [],
|
|
||||||
weeklyHours: null,
|
|
||||||
patrolRouteData: null,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Carica patrol route esistente quando si seleziona una guardia
|
// Carica patrol route esistente quando si seleziona una guardia
|
||||||
@ -923,76 +531,38 @@ 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">
|
||||||
{/* Campi Orario Turno */}
|
<div className="flex gap-2 flex-wrap">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted/30 rounded-md">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="shift-start-time">Ora Inizio</Label>
|
|
||||||
<Input
|
|
||||||
id="shift-start-time"
|
|
||||||
type="time"
|
|
||||||
value={shiftStartTime}
|
|
||||||
onChange={(e) => setShiftStartTime(e.target.value)}
|
|
||||||
data-testid="input-shift-start-time"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="shift-duration">Durata (ore)</Label>
|
|
||||||
<Input
|
|
||||||
id="shift-duration"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="24"
|
|
||||||
step="0.5"
|
|
||||||
value={shiftDuration}
|
|
||||||
onChange={(e) => setShiftDuration(e.target.value)}
|
|
||||||
data-testid="input-shift-duration"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lista Tappe Draggable */}
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={patrolRoute.map(site => site.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{patrolRoute.map((site, index) => (
|
{patrolRoute.map((site, index) => (
|
||||||
<SortableStop
|
<div
|
||||||
key={site.id}
|
key={site.id}
|
||||||
site={site}
|
className="flex items-center gap-2 p-2 border rounded-lg bg-muted/20"
|
||||||
index={index}
|
data-testid={`route-stop-${index}`}
|
||||||
onRemove={() => handleRemoveFromRoute(site.id)}
|
>
|
||||||
/>
|
<Badge className="bg-green-600">
|
||||||
|
{index + 1}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium">{site.name}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRemoveFromRoute(site.id)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
data-testid={`button-remove-stop-${index}`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
<div className="flex gap-2 pt-2">
|
||||||
</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"
|
||||||
>
|
>
|
||||||
{savePatrolRouteMutation.isPending ? "Salvataggio..." : "Salva Turno Pattuglia"}
|
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"
|
||||||
@ -1099,7 +669,6 @@ 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
|
||||||
@ -1131,22 +700,15 @@ export default function PlanningMobile() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 pt-2 items-center">
|
<div className="flex gap-2 pt-2">
|
||||||
{assignedGuard ? (
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={handleScrollToPatrolSequences}
|
onClick={() => handleAssignGuard(site)}
|
||||||
data-testid={`button-assigned-${site.id}`}
|
data-testid={`button-assign-${site.id}`}
|
||||||
>
|
>
|
||||||
<User className="h-4 w-4 mr-2" />
|
Assegna Guardia
|
||||||
Assegnato a {assignedGuard.firstName} {assignedGuard.lastName}
|
|
||||||
</Button>
|
</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"
|
||||||
@ -1226,7 +788,7 @@ export default function PlanningMobile() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Sequenze Pattuglia del Giorno */}
|
{/* Sequenze Pattuglia del Giorno */}
|
||||||
<Card ref={patrolSequencesRef}>
|
<Card>
|
||||||
<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" />
|
||||||
@ -1306,95 +868,6 @@ 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) {
|
||||||
@ -1403,7 +876,6 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
numDays: "1",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
@ -1427,8 +899,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.shiftDate && isValid(parseISO(duplicateDialog.sourceRoute.shiftDate))
|
{duplicateDialog.sourceRoute.scheduledDate && isValid(parseISO(duplicateDialog.sourceRoute.scheduledDate))
|
||||||
? format(parseISO(duplicateDialog.sourceRoute.shiftDate), "dd/MM/yyyy", { locale: it })
|
? format(parseISO(duplicateDialog.sourceRoute.scheduledDate), "dd/MM/yyyy", { locale: it })
|
||||||
: "Data non valida"}
|
: "Data non valida"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1442,7 +914,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 Partenza *</Label>
|
<Label htmlFor="target-date">Data di Destinazione *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="target-date"
|
id="target-date"
|
||||||
type="date"
|
type="date"
|
||||||
@ -1451,31 +923,14 @@ 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.shiftDate && isValid(parseISO(duplicateDialog.sourceRoute.shiftDate)) &&
|
{duplicateDialog.sourceRoute && duplicateDialog.targetDate && duplicateDialog.sourceRoute.scheduledDate && isValid(parseISO(duplicateDialog.sourceRoute.scheduledDate)) &&
|
||||||
format(parseISO(duplicateDialog.sourceRoute.shiftDate), "yyyy-MM-dd") === duplicateDialog.targetDate
|
format(parseISO(duplicateDialog.sourceRoute.scheduledDate), "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: verranno create nuove sequenze consecutive"
|
: "✓ Data diversa: verrà creata una nuova sequenza con tutte le tappe"
|
||||||
}
|
}
|
||||||
</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>
|
||||||
@ -1505,7 +960,6 @@ export default function PlanningMobile() {
|
|||||||
sourceRoute: null,
|
sourceRoute: null,
|
||||||
targetDate: "",
|
targetDate: "",
|
||||||
selectedDuplicateGuardId: "",
|
selectedDuplicateGuardId: "",
|
||||||
numDays: "1",
|
|
||||||
})}
|
})}
|
||||||
data-testid="button-cancel-duplicate"
|
data-testid="button-cancel-duplicate"
|
||||||
>
|
>
|
||||||
@ -1521,67 +975,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,448 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Users, Clock, MapPin, Navigation, ExternalLink } from "lucide-react";
|
|
||||||
import { format, parseISO, addDays, startOfWeek, addWeeks } from "date-fns";
|
|
||||||
import { it } from "date-fns/locale";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
|
|
||||||
type AbsenceType = "sick_leave" | "vacation" | "personal_leave" | "injury";
|
|
||||||
|
|
||||||
interface GuardScheduleData {
|
|
||||||
guard: {
|
|
||||||
id: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
badgeNumber: string;
|
|
||||||
};
|
|
||||||
fixedShifts: Array<{
|
|
||||||
assignmentId: string;
|
|
||||||
shiftId: string;
|
|
||||||
plannedStartTime: Date;
|
|
||||||
plannedEndTime: Date;
|
|
||||||
siteName: string;
|
|
||||||
siteId: string;
|
|
||||||
}>;
|
|
||||||
mobileShifts: Array<{
|
|
||||||
routeId: string;
|
|
||||||
shiftDate: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
}>;
|
|
||||||
absences: Array<{
|
|
||||||
id: string;
|
|
||||||
type: AbsenceType;
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WeeklyScheduleResponse {
|
|
||||||
weekStart: string;
|
|
||||||
weekEnd: string;
|
|
||||||
location: string;
|
|
||||||
guards: GuardScheduleData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ABSENCE_LABELS: Record<AbsenceType, string> = {
|
|
||||||
sick_leave: "Malattia",
|
|
||||||
vacation: "Ferie",
|
|
||||||
personal_leave: "Permesso",
|
|
||||||
injury: "Infortunio",
|
|
||||||
};
|
|
||||||
|
|
||||||
type DialogData = {
|
|
||||||
type: "fixed" | "mobile";
|
|
||||||
guardName: string;
|
|
||||||
date: string;
|
|
||||||
data: any;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
export default function WeeklyGuards() {
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
|
|
||||||
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
|
|
||||||
startOfWeek(new Date(), { weekStartsOn: 1 }) // Inizia lunedì
|
|
||||||
);
|
|
||||||
const [dialogData, setDialogData] = useState<DialogData>(null);
|
|
||||||
|
|
||||||
const { data: scheduleData, isLoading, error } = useQuery<WeeklyScheduleResponse>({
|
|
||||||
queryKey: ["/api/weekly-guards-schedule", selectedLocation, format(currentWeekStart, "yyyy-MM-dd")],
|
|
||||||
queryFn: async () => {
|
|
||||||
const startDate = format(currentWeekStart, "yyyy-MM-dd");
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/weekly-guards-schedule?location=${selectedLocation}&startDate=${startDate}`
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch weekly schedule");
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
enabled: !!selectedLocation,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper per ottenere i giorni della settimana
|
|
||||||
const getWeekDays = () => {
|
|
||||||
const days = [];
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
days.push(addDays(currentWeekStart, i));
|
|
||||||
}
|
|
||||||
return days;
|
|
||||||
};
|
|
||||||
|
|
||||||
const weekDays = getWeekDays();
|
|
||||||
|
|
||||||
// Helper per trovare l'attività di una guardia in un giorno specifico
|
|
||||||
const getDayActivity = (guardData: GuardScheduleData, date: Date) => {
|
|
||||||
const dateStr = format(date, "yyyy-MM-dd");
|
|
||||||
|
|
||||||
// Controlla assenze
|
|
||||||
const absence = guardData.absences.find(abs => {
|
|
||||||
const startDate = abs.startDate;
|
|
||||||
const endDate = abs.endDate;
|
|
||||||
return dateStr >= startDate && dateStr <= endDate;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (absence) {
|
|
||||||
return {
|
|
||||||
type: "absence" as const,
|
|
||||||
label: ABSENCE_LABELS[absence.type],
|
|
||||||
data: absence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Controlla turni fissi
|
|
||||||
const fixedShift = guardData.fixedShifts.find(shift => {
|
|
||||||
const shiftDate = format(new Date(shift.plannedStartTime), "yyyy-MM-dd");
|
|
||||||
return shiftDate === dateStr;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fixedShift) {
|
|
||||||
const startTime = format(new Date(fixedShift.plannedStartTime), "HH:mm");
|
|
||||||
const endTime = format(new Date(fixedShift.plannedEndTime), "HH:mm");
|
|
||||||
return {
|
|
||||||
type: "fixed" as const,
|
|
||||||
label: `${fixedShift.siteName} ${startTime}-${endTime}`,
|
|
||||||
data: fixedShift,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Controlla turni mobili
|
|
||||||
const mobileShift = guardData.mobileShifts.find(shift => shift.shiftDate === dateStr);
|
|
||||||
|
|
||||||
if (mobileShift) {
|
|
||||||
return {
|
|
||||||
type: "mobile" as const,
|
|
||||||
label: `Pattuglia ${mobileShift.startTime}-${mobileShift.endTime}`,
|
|
||||||
data: mobileShift,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreviousWeek = () => {
|
|
||||||
setCurrentWeekStart(prev => addWeeks(prev, -1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextWeek = () => {
|
|
||||||
setCurrentWeekStart(prev => addWeeks(prev, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCellClick = (guardData: GuardScheduleData, activity: ReturnType<typeof getDayActivity>, date: Date) => {
|
|
||||||
if (!activity || activity.type === "absence") return;
|
|
||||||
|
|
||||||
const guardName = `${guardData.guard.lastName} ${guardData.guard.firstName}`;
|
|
||||||
const dateStr = format(date, "EEEE dd MMMM yyyy", { locale: it });
|
|
||||||
|
|
||||||
setDialogData({
|
|
||||||
type: activity.type,
|
|
||||||
guardName,
|
|
||||||
date: dateStr,
|
|
||||||
data: activity.data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
|
||||||
<Users className="h-8 w-8 text-primary" />
|
|
||||||
Guardie Settimanale
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Vista riepilogativa delle assegnazioni settimanali per sede
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtri */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="h-5 w-5" />
|
|
||||||
Filtri Visualizzazione
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Seleziona sede e settimana per visualizzare le assegnazioni
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-end gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="text-sm font-medium mb-2 block">Sede</label>
|
|
||||||
<Select
|
|
||||||
value={selectedLocation}
|
|
||||||
onValueChange={setSelectedLocation}
|
|
||||||
data-testid="select-location"
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Seleziona sede" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
|
||||||
<SelectItem value="milano">Milano</SelectItem>
|
|
||||||
<SelectItem value="roma">Roma</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handlePreviousWeek}
|
|
||||||
data-testid="button-previous-week"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="px-4 py-2 border rounded-md bg-muted min-w-[280px] text-center">
|
|
||||||
<span className="font-medium">
|
|
||||||
{format(currentWeekStart, "d MMM", { locale: it })} - {format(addDays(currentWeekStart, 6), "d MMM yyyy", { locale: it })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleNextWeek}
|
|
||||||
data-testid="button-next-week"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Griglia Settimanale */}
|
|
||||||
{isLoading ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<p className="text-center text-muted-foreground">Caricamento...</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : error ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<p className="text-center text-destructive">
|
|
||||||
Errore nel caricamento della pianificazione. Riprova più tardi.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : scheduleData && scheduleData.guards.length > 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b bg-muted/50">
|
|
||||||
<th className="sticky left-0 z-10 bg-muted/50 text-left p-3 font-medium min-w-[180px]">
|
|
||||||
Guardia
|
|
||||||
</th>
|
|
||||||
{weekDays.map((day, index) => (
|
|
||||||
<th key={index} className="text-center p-3 font-medium min-w-[200px]">
|
|
||||||
<div>{format(day, "EEE", { locale: it })}</div>
|
|
||||||
<div className="text-xs text-muted-foreground font-normal">
|
|
||||||
{format(day, "dd/MM")}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{scheduleData.guards.map((guardData) => (
|
|
||||||
<tr
|
|
||||||
key={guardData.guard.id}
|
|
||||||
className="border-b hover:bg-muted/30"
|
|
||||||
data-testid={`row-guard-${guardData.guard.id}`}
|
|
||||||
>
|
|
||||||
<td className="sticky left-0 z-10 bg-background p-3 font-medium border-r">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm">
|
|
||||||
{guardData.guard.lastName} {guardData.guard.firstName}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
#{guardData.guard.badgeNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{weekDays.map((day, dayIndex) => {
|
|
||||||
const activity = getDayActivity(guardData, day);
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
key={dayIndex}
|
|
||||||
className="p-2 text-center align-middle"
|
|
||||||
>
|
|
||||||
{activity ? (
|
|
||||||
activity.type === "absence" ? (
|
|
||||||
<div
|
|
||||||
className="text-xs px-2 py-1.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-300"
|
|
||||||
data-testid={`cell-absence-${guardData.guard.id}-${dayIndex}`}
|
|
||||||
>
|
|
||||||
{activity.label}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full h-auto text-xs px-2 py-1.5 whitespace-normal hover-elevate"
|
|
||||||
onClick={() => handleCellClick(guardData, activity, day)}
|
|
||||||
data-testid={`button-shift-${guardData.guard.id}-${dayIndex}`}
|
|
||||||
>
|
|
||||||
{activity.label}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<p className="text-center text-muted-foreground">
|
|
||||||
Nessuna guardia trovata per la sede selezionata
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dialog Dettaglio Turno */}
|
|
||||||
<Dialog open={!!dialogData} onOpenChange={() => setDialogData(null)}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
{dialogData?.type === "fixed" ? (
|
|
||||||
<>
|
|
||||||
<MapPin className="h-5 w-5" />
|
|
||||||
Turno Fisso - {dialogData?.guardName}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Navigation className="h-5 w-5" />
|
|
||||||
Turno Mobile - {dialogData?.guardName}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{dialogData?.date}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{dialogData && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{dialogData.type === "fixed" ? (
|
|
||||||
// Dettagli turno fisso
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="bg-muted/30 p-3 rounded-md space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Sito</span>
|
|
||||||
<span className="text-sm font-medium">{dialogData.data.siteName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Orario</span>
|
|
||||||
<div className="flex items-center gap-1 text-sm font-medium">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{format(new Date(dialogData.data.plannedStartTime), "HH:mm")} - {format(new Date(dialogData.data.plannedEndTime), "HH:mm")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Durata</span>
|
|
||||||
<span className="text-sm font-bold">
|
|
||||||
{Math.round((new Date(dialogData.data.plannedEndTime).getTime() - new Date(dialogData.data.plannedStartTime).getTime()) / (1000 * 60 * 60))}h
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Per modificare questo turno, vai alla pagina <Link href="/general-planning" className="text-primary font-medium hover:underline">Planning Fissi</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Dettagli turno mobile
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="bg-muted/30 p-3 rounded-md space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Tipo</span>
|
|
||||||
<Badge variant="outline">Pattuglia</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Orario</span>
|
|
||||||
<div className="flex items-center gap-1 text-sm font-medium">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{dialogData.data.startTime} - {dialogData.data.endTime}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Per visualizzare il percorso completo e modificare il turno, vai alla pagina <Link href="/planning-mobile" className="text-primary font-medium hover:underline">Planning Mobile</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDialogData(null)}>
|
|
||||||
Chiudi
|
|
||||||
</Button>
|
|
||||||
{dialogData?.type === "fixed" ? (
|
|
||||||
<Link href="/general-planning">
|
|
||||||
<Button data-testid="button-goto-planning-fissi">
|
|
||||||
<ExternalLink className="h-4 w-4 mr-2" />
|
|
||||||
Vai a Planning Fissi
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Link href="/planning-mobile">
|
|
||||||
<Button data-testid="button-goto-planning-mobile">
|
|
||||||
<ExternalLink className="h-4 w-4 mr-2" />
|
|
||||||
Vai a Planning Mobile
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
BIN
database-backups/vigilanzaturni_v1.0.45_20251023_145254.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.45_20251023_145254.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.46_20251023_152240.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.46_20251023_152240.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.47_20251023_155134.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.47_20251023_155134.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.48_20251023_160305.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.48_20251023_160305.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.49_20251023_170434.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.49_20251023_170434.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.50_20251024_102610.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.50_20251024_102610.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
56
package-lock.json
generated
56
package-lock.json
generated
@ -9,9 +9,6 @@
|
|||||||
"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",
|
||||||
@ -420,59 +417,6 @@
|
|||||||
"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,9 +11,6 @@
|
|||||||
"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,18 +35,7 @@ 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. Features include:
|
- **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.
|
||||||
- **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.
|
||||||
@ -58,12 +47,6 @@ 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.
|
||||||
@ -85,5 +68,3 @@ 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, absences } from "@shared/schema";
|
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema, customers, patrolRoutes, patrolRouteStops, insertPatrolRouteSchema } from "@shared/schema";
|
||||||
import { eq, and, gte, lte, lt, desc, asc, ne, sql } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
||||||
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
import { 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.shiftDate
|
// Controlla se targetDate è uguale a sourceRoute.scheduledDate
|
||||||
const sourceDate = new Date(sourceRoute.shiftDate).toISOString().split('T')[0];
|
const sourceDate = new Date(sourceRoute.scheduledDate).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
|
||||||
shiftDate: targetDate,
|
scheduledDate: new Date(targetDate),
|
||||||
startTime: sourceRoute.startTime,
|
startTime: sourceRoute.startTime,
|
||||||
endTime: sourceRoute.endTime,
|
endTime: sourceRoute.endTime,
|
||||||
status: "planned", // Nuova sequenza sempre in stato planned
|
status: "scheduled", // Nuova sequenza sempre in stato scheduled
|
||||||
location: sourceRoute.location,
|
location: sourceRoute.location,
|
||||||
notes: sourceRoute.notes,
|
notes: sourceRoute.notes,
|
||||||
})
|
})
|
||||||
@ -4348,190 +4348,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST - Verifica sovrapposizioni turni e calcola ore settimanali
|
|
||||||
app.post("/api/patrol-routes/check-overlaps", isAuthenticated, async (req: any, res) => {
|
|
||||||
try {
|
|
||||||
const { guardId, shiftDate, startTime, endTime, excludeRouteId } = req.body;
|
|
||||||
|
|
||||||
if (!guardId || !shiftDate || !startTime || !endTime) {
|
|
||||||
return res.status(400).json({
|
|
||||||
message: "guardId, shiftDate, startTime e endTime sono obbligatori"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converte orari in timestamp per confronto
|
|
||||||
const shiftDateObj = new Date(shiftDate);
|
|
||||||
const [startHour, startMin] = startTime.split(':').map(Number);
|
|
||||||
const [endHour, endMin] = endTime.split(':').map(Number);
|
|
||||||
|
|
||||||
const startTimestamp = new Date(shiftDateObj);
|
|
||||||
startTimestamp.setHours(startHour, startMin, 0, 0);
|
|
||||||
|
|
||||||
const endTimestamp = new Date(shiftDateObj);
|
|
||||||
endTimestamp.setHours(endHour, endMin, 0, 0);
|
|
||||||
|
|
||||||
// Se endTime è minore di startTime, il turno attraversa la mezzanotte
|
|
||||||
if (endTimestamp <= startTimestamp) {
|
|
||||||
endTimestamp.setDate(endTimestamp.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const conflicts = [];
|
|
||||||
|
|
||||||
// 1. Controlla sovrapposizioni con shift_assignments (turni fissi)
|
|
||||||
const fixedShifts = await db
|
|
||||||
.select({
|
|
||||||
id: shiftAssignments.id,
|
|
||||||
siteName: sites.name,
|
|
||||||
plannedStartTime: shiftAssignments.plannedStartTime,
|
|
||||||
plannedEndTime: shiftAssignments.plannedEndTime,
|
|
||||||
})
|
|
||||||
.from(shiftAssignments)
|
|
||||||
.leftJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
|
||||||
.leftJoin(sites, eq(shifts.siteId, sites.id))
|
|
||||||
.where(eq(shiftAssignments.guardId, guardId));
|
|
||||||
|
|
||||||
for (const shift of fixedShifts) {
|
|
||||||
const shiftStart = new Date(shift.plannedStartTime);
|
|
||||||
const shiftEnd = new Date(shift.plannedEndTime);
|
|
||||||
|
|
||||||
// Controlla sovrapposizione
|
|
||||||
if (startTimestamp < shiftEnd && endTimestamp > shiftStart) {
|
|
||||||
conflicts.push({
|
|
||||||
type: 'fisso',
|
|
||||||
siteName: shift.siteName,
|
|
||||||
startTime: shift.plannedStartTime,
|
|
||||||
endTime: shift.plannedEndTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Controlla sovrapposizioni con patrol_routes (turni mobili)
|
|
||||||
const mobileShifts = await db
|
|
||||||
.select()
|
|
||||||
.from(patrolRoutes)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(patrolRoutes.guardId, guardId),
|
|
||||||
excludeRouteId ? ne(patrolRoutes.id, excludeRouteId) : undefined
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const route of mobileShifts) {
|
|
||||||
const routeDateObj = new Date(route.shiftDate);
|
|
||||||
const [rStartHour, rStartMin] = route.startTime.split(':').map(Number);
|
|
||||||
const [rEndHour, rEndMin] = route.endTime.split(':').map(Number);
|
|
||||||
|
|
||||||
const routeStart = new Date(routeDateObj);
|
|
||||||
routeStart.setHours(rStartHour, rStartMin, 0, 0);
|
|
||||||
|
|
||||||
const routeEnd = new Date(routeDateObj);
|
|
||||||
routeEnd.setHours(rEndHour, rEndMin, 0, 0);
|
|
||||||
|
|
||||||
if (routeEnd <= routeStart) {
|
|
||||||
routeEnd.setDate(routeEnd.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Controlla sovrapposizione
|
|
||||||
if (startTimestamp < routeEnd && endTimestamp > routeStart) {
|
|
||||||
conflicts.push({
|
|
||||||
type: 'mobile',
|
|
||||||
shiftDate: route.shiftDate,
|
|
||||||
startTime: route.startTime,
|
|
||||||
endTime: route.endTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Calcola ore settimanali
|
|
||||||
const weekStart = new Date(shiftDateObj);
|
|
||||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + (weekStart.getDay() === 0 ? -6 : 1));
|
|
||||||
weekStart.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const weekEnd = new Date(weekStart);
|
|
||||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
|
||||||
|
|
||||||
let totalWeeklyHours = 0;
|
|
||||||
|
|
||||||
// Ore da turni fissi
|
|
||||||
const weeklyFixedShifts = await db
|
|
||||||
.select({
|
|
||||||
plannedStartTime: shiftAssignments.plannedStartTime,
|
|
||||||
plannedEndTime: shiftAssignments.plannedEndTime,
|
|
||||||
})
|
|
||||||
.from(shiftAssignments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(shiftAssignments.guardId, guardId),
|
|
||||||
gte(shiftAssignments.plannedStartTime, weekStart),
|
|
||||||
lt(shiftAssignments.plannedStartTime, weekEnd)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const shift of weeklyFixedShifts) {
|
|
||||||
const start = new Date(shift.plannedStartTime);
|
|
||||||
const end = new Date(shift.plannedEndTime);
|
|
||||||
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
|
||||||
totalWeeklyHours += hours;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ore da turni mobili
|
|
||||||
const weeklyMobileShifts = await db
|
|
||||||
.select()
|
|
||||||
.from(patrolRoutes)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(patrolRoutes.guardId, guardId),
|
|
||||||
gte(patrolRoutes.shiftDate, weekStart.toISOString().split('T')[0]),
|
|
||||||
lt(patrolRoutes.shiftDate, weekEnd.toISOString().split('T')[0]),
|
|
||||||
excludeRouteId ? ne(patrolRoutes.id, excludeRouteId) : undefined
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const route of weeklyMobileShifts) {
|
|
||||||
const [rStartHour, rStartMin] = route.startTime.split(':').map(Number);
|
|
||||||
const [rEndHour, rEndMin] = route.endTime.split(':').map(Number);
|
|
||||||
|
|
||||||
let hours = rEndHour - rStartHour + (rEndMin - rStartMin) / 60;
|
|
||||||
if (hours < 0) hours += 24; // Turno attraversa mezzanotte
|
|
||||||
|
|
||||||
totalWeeklyHours += hours;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggiungi ore del nuovo turno
|
|
||||||
const newShiftHours = (endTimestamp.getTime() - startTimestamp.getTime()) / (1000 * 60 * 60);
|
|
||||||
const totalHoursWithNew = totalWeeklyHours + newShiftHours;
|
|
||||||
|
|
||||||
// 4. Recupera limiti contrattuali
|
|
||||||
const contractParams = await db
|
|
||||||
.select()
|
|
||||||
.from(contractParameters)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const maxHours = contractParams[0]?.maxHoursPerWeek || 40;
|
|
||||||
const maxOvertime = contractParams[0]?.maxOvertimePerWeek || 8;
|
|
||||||
const totalMaxHours = maxHours + maxOvertime;
|
|
||||||
|
|
||||||
const exceedsContractLimit = totalHoursWithNew > totalMaxHours;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
hasConflicts: conflicts.length > 0,
|
|
||||||
conflicts,
|
|
||||||
weeklyHours: {
|
|
||||||
current: Math.round(totalWeeklyHours * 100) / 100,
|
|
||||||
withNewShift: Math.round(totalHoursWithNew * 100) / 100,
|
|
||||||
newShiftHours: Math.round(newShiftHours * 100) / 100,
|
|
||||||
maxRegular: maxHours,
|
|
||||||
maxOvertime: maxOvertime,
|
|
||||||
maxTotal: totalMaxHours,
|
|
||||||
exceedsLimit: exceedsContractLimit,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking overlaps:", error);
|
|
||||||
res.status(500).json({ message: "Errore verifica sovrapposizioni" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============= GEOCODING API (Nominatim/OSM) =============
|
// ============= GEOCODING API (Nominatim/OSM) =============
|
||||||
|
|
||||||
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
||||||
@ -4596,217 +4412,6 @@ 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,43 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.1",
|
"version": "1.0.54",
|
||||||
"lastUpdate": "2025-11-15T10:11:44.404Z",
|
"lastUpdate": "2025-10-24T17:25:43.941Z",
|
||||||
"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",
|
||||||
@ -301,6 +265,42 @@
|
|||||||
"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