Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
591687b000 | ||
|
|
ee5d1aaa24 | ||
|
|
fc6f5a39f8 | ||
|
|
758a697447 | ||
|
|
40cde1634b | ||
|
|
7b05c8cbce | ||
|
|
d868c6ee31 | ||
|
|
a10b50e7e9 | ||
|
|
c8825a9b4c | ||
|
|
7961971ad0 | ||
|
|
3e3b9851a8 | ||
|
|
3fc0c55fd2 | ||
|
|
2eb53bb1b2 | ||
|
|
a9f3453755 | ||
|
|
dd84ddb35b | ||
|
|
5c22ec14f1 | ||
|
|
efa056dd98 | ||
|
|
b132082ffc | ||
|
|
e5ce415aeb | ||
|
|
34bdb99599 | ||
|
|
3cc1739015 | ||
|
|
20f24ba25e | ||
|
|
bd55070abc | ||
|
|
7a6fd3245b | ||
|
|
bafc34065e | ||
|
|
b4c6400360 | ||
|
|
753e01d612 | ||
|
|
3b3056f6b8 | ||
|
|
2b62d8ff4e | ||
|
|
1639244169 | ||
|
|
2cd6c32ad9 | ||
|
|
0a72b413fa | ||
|
|
1bad21cf9e | ||
|
|
6366382753 | ||
|
|
0b64fd2f08 | ||
|
|
36bfad3815 | ||
|
|
d8f22f81da | ||
|
|
b1ba5b91c0 | ||
|
|
5830d08c55 | ||
|
|
468d6477eb | ||
|
|
74bd542309 | ||
|
|
580fbfcaab | ||
|
|
565cd08f10 | ||
|
|
52baa7f6c3 | ||
|
|
c8fa396c8f | ||
|
|
5c8ebf7218 | ||
|
|
fbc4f96a46 | ||
|
|
bb50965eba | ||
|
|
cf0c905d0f | ||
|
|
00ac8c8415 | ||
|
|
ab85e8eb03 | ||
|
|
1c183a18ec | ||
|
|
e0504f0a13 | ||
|
|
4a2b5fab66 |
8
.replit
8
.replit
@ -20,8 +20,8 @@ localPort = 33035
|
|||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 39567
|
localPort = 40417
|
||||||
externalPort = 6000
|
externalPort = 8000
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41295
|
localPort = 41295
|
||||||
@ -39,6 +39,10 @@ externalPort = 4200
|
|||||||
localPort = 42175
|
localPort = 42175
|
||||||
externalPort = 3002
|
externalPort = 3002
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 42187
|
||||||
|
externalPort = 6800
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 43169
|
localPort = 43169
|
||||||
externalPort = 5000
|
externalPort = 5000
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import PlanningMobile from "@/pages/planning-mobile";
|
|||||||
import MyShiftsFixed from "@/pages/my-shifts-fixed";
|
import MyShiftsFixed from "@/pages/my-shifts-fixed";
|
||||||
import MyShiftsMobile from "@/pages/my-shifts-mobile";
|
import MyShiftsMobile from "@/pages/my-shifts-mobile";
|
||||||
import SitePlanningView from "@/pages/site-planning-view";
|
import SitePlanningView from "@/pages/site-planning-view";
|
||||||
|
import WeeklyGuards from "@/pages/weekly-guards";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
@ -57,6 +58,7 @@ function Router() {
|
|||||||
<Route path="/my-shifts-fixed" component={MyShiftsFixed} />
|
<Route path="/my-shifts-fixed" component={MyShiftsFixed} />
|
||||||
<Route path="/my-shifts-mobile" component={MyShiftsMobile} />
|
<Route path="/my-shifts-mobile" component={MyShiftsMobile} />
|
||||||
<Route path="/site-planning-view" component={SitePlanningView} />
|
<Route path="/site-planning-view" component={SitePlanningView} />
|
||||||
|
<Route path="/weekly-guards" component={WeeklyGuards} />
|
||||||
<Route path="/reports" component={Reports} />
|
<Route path="/reports" component={Reports} />
|
||||||
<Route path="/notifications" component={Notifications} />
|
<Route path="/notifications" component={Notifications} />
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={Users} />
|
||||||
|
|||||||
@ -12,6 +12,11 @@ import {
|
|||||||
Car,
|
Car,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Navigation,
|
Navigation,
|
||||||
|
ChevronDown,
|
||||||
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
|
Building2,
|
||||||
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import {
|
import {
|
||||||
@ -23,15 +28,31 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarMenuSubButton,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
const menuItems = [
|
interface MenuItem {
|
||||||
|
title: string;
|
||||||
|
url?: string;
|
||||||
|
icon: any;
|
||||||
|
roles: string[];
|
||||||
|
items?: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
url: "/",
|
url: "/",
|
||||||
@ -39,47 +60,47 @@ const menuItems = [
|
|||||||
roles: ["admin", "coordinator", "guard", "client"],
|
roles: ["admin", "coordinator", "guard", "client"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Turni",
|
title: "Planning",
|
||||||
url: "/shifts",
|
icon: FolderKanban,
|
||||||
icon: Calendar,
|
|
||||||
roles: ["admin", "coordinator", "guard"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Pianificazione",
|
|
||||||
url: "/planning",
|
|
||||||
icon: ClipboardList,
|
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
items: [
|
||||||
{
|
{
|
||||||
title: "Pianificazione Operativa",
|
title: "Fissi",
|
||||||
url: "/operational-planning",
|
|
||||||
icon: Calendar,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Planning Fissi",
|
|
||||||
url: "/general-planning",
|
url: "/general-planning",
|
||||||
icon: BarChart3,
|
icon: Calendar,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Planning Mobile",
|
title: "Mobili",
|
||||||
url: "/planning-mobile",
|
url: "/planning-mobile",
|
||||||
icon: Navigation,
|
icon: Navigation,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Planning di Servizio",
|
title: "Vista",
|
||||||
url: "/service-planning",
|
url: "/service-planning",
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Gestione Pianificazioni",
|
title: "Guardie Settimanale",
|
||||||
url: "/advanced-planning",
|
url: "/weekly-guards",
|
||||||
icon: ClipboardList,
|
icon: Users,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Scadenziario",
|
||||||
|
url: "/advanced-planning",
|
||||||
|
icon: Calendar,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Anagrafiche",
|
||||||
|
icon: Building2,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
title: "Guardie",
|
title: "Guardie",
|
||||||
url: "/guards",
|
url: "/guards",
|
||||||
@ -98,6 +119,19 @@ const menuItems = [
|
|||||||
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",
|
||||||
@ -105,23 +139,31 @@ const menuItems = [
|
|||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Parco Automezzi",
|
title: "Contratti",
|
||||||
url: "/vehicles",
|
url: "/parameters",
|
||||||
icon: Car,
|
icon: Settings,
|
||||||
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: "Notifiche",
|
title: "Utilità",
|
||||||
url: "/notifications",
|
icon: Settings,
|
||||||
icon: Bell,
|
|
||||||
roles: ["admin", "coordinator", "guard"],
|
roles: ["admin", "coordinator", "guard"],
|
||||||
},
|
items: [
|
||||||
{
|
{
|
||||||
title: "Utenti",
|
title: "Utenti",
|
||||||
url: "/users",
|
url: "/users",
|
||||||
@ -129,10 +171,12 @@ const menuItems = [
|
|||||||
roles: ["admin"],
|
roles: ["admin"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Parametri",
|
title: "Notifiche",
|
||||||
url: "/parameters",
|
url: "/notifications",
|
||||||
icon: Settings,
|
icon: Bell,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator", "guard"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -140,9 +184,78 @@ export function AppSidebar() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
|
|
||||||
const filteredItems = menuItems.filter(
|
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
|
||||||
(item) => user && item.roles.includes(user.role)
|
if (!user) return [];
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
const hasRole = item.roles.includes(user.role);
|
||||||
|
if (!hasRole) return false;
|
||||||
|
|
||||||
|
if (item.items) {
|
||||||
|
item.items = filterMenuItems(item.items);
|
||||||
|
return item.items.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredItems = filterMenuItems(menuItems);
|
||||||
|
|
||||||
|
const renderMenuItem = (item: MenuItem) => {
|
||||||
|
// Menu item con sottomenu
|
||||||
|
if (item.items && item.items.length > 0) {
|
||||||
|
const isAnySubItemActive = item.items.some((subItem) => location === subItem.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible key={item.title} defaultOpen={isAnySubItemActive} className="group/collapsible">
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton data-testid={`menu-${item.title.toLowerCase()}`}>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{item.items.map((subItem) => (
|
||||||
|
<SidebarMenuSubItem key={subItem.title}>
|
||||||
|
<SidebarMenuSubButton
|
||||||
|
asChild
|
||||||
|
isActive={location === subItem.url}
|
||||||
|
data-testid={`link-${subItem.title.toLowerCase().replace(/\s+/g, '-')}`}
|
||||||
|
>
|
||||||
|
<Link href={subItem.url!}>
|
||||||
|
<subItem.icon className="h-4 w-4" />
|
||||||
|
<span>{subItem.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu item semplice
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={location === item.url}
|
||||||
|
data-testid={`link-${item.title.toLowerCase().replace(/\s+/g, '-')}`}
|
||||||
|
>
|
||||||
|
<Link href={item.url!}>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@ -161,20 +274,7 @@ export function AppSidebar() {
|
|||||||
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
|
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{filteredItems.map((item) => (
|
{filteredItems.map(renderMenuItem)}
|
||||||
<SidebarMenuItem key={item.title}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
asChild
|
|
||||||
isActive={location === item.url}
|
|
||||||
data-testid={`link-${item.title.toLowerCase()}`}
|
|
||||||
>
|
|
||||||
<Link href={item.url}>
|
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock, Copy, Circle } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@ -85,14 +85,14 @@ interface GeneralPlanningResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper per formattare orario in formato italiano 24h (HH:MM)
|
// Helper per formattare orario in formato italiano 24h (HH:MM)
|
||||||
// IMPORTANTE: usa timeZone UTC per evitare shift di +2 ore
|
// IMPORTANTE: Gli orari nel DB sono UTC, visualizzali in timezone Europe/Rome
|
||||||
const formatTime = (dateString: string) => {
|
const formatTime = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleTimeString("it-IT", {
|
return date.toLocaleTimeString("it-IT", {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
timeZone: "UTC" // Evita conversione timezone locale (+2h in Italia)
|
timeZone: "Europe/Rome" // Converti da UTC a Italy time
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -117,6 +117,7 @@ export default function GeneralPlanning() {
|
|||||||
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
|
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
|
||||||
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
|
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
|
||||||
const [ccnlConfirmation, setCcnlConfirmation] = useState<{ message: string; data: any } | null>(null);
|
const [ccnlConfirmation, setCcnlConfirmation] = useState<{ message: string; data: any } | null>(null);
|
||||||
|
const [showCopyWeekConfirmation, setShowCopyWeekConfirmation] = useState<boolean>(false);
|
||||||
|
|
||||||
// Query per dati planning settimanale
|
// Query per dati planning settimanale
|
||||||
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
|
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
|
||||||
@ -275,6 +276,54 @@ export default function GeneralPlanning() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mutation per copiare turni settimanali
|
||||||
|
const copyWeekMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
return apiRequest("POST", "/api/shift-assignments/copy-week", {
|
||||||
|
weekStart: format(weekStart, "yyyy-MM-dd"),
|
||||||
|
location: selectedLocation,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async (response: any) => {
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Settimana copiata!",
|
||||||
|
description: `${data.copiedShifts} turni e ${data.copiedAssignments} assegnazioni copiate nella settimana successiva`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalida cache e naviga alla settimana successiva
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
|
||||||
|
setWeekStart(addWeeks(weekStart, 1)); // Naviga alla settimana copiata
|
||||||
|
setShowCopyWeekConfirmation(false);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
let errorMessage = "Impossibile copiare la settimana";
|
||||||
|
|
||||||
|
if (error.message) {
|
||||||
|
const match = error.message.match(/^(\d+):\s*(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match[2]);
|
||||||
|
errorMessage = parsed.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = match[2];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Errore Copia Settimana",
|
||||||
|
description: errorMessage,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowCopyWeekConfirmation(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Handler per submit form assegnazione guardia
|
// Handler per submit form assegnazione guardia
|
||||||
const handleAssignGuard = () => {
|
const handleAssignGuard = () => {
|
||||||
if (!selectedCell || !selectedGuardId) return;
|
if (!selectedCell || !selectedGuardId) return;
|
||||||
@ -358,7 +407,7 @@ export default function GeneralPlanning() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigazione settimana */}
|
{/* Navigazione settimana */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -385,6 +434,16 @@ export default function GeneralPlanning() {
|
|||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setShowCopyWeekConfirmation(true)}
|
||||||
|
disabled={isLoading || !planningData || copyWeekMutation.isPending}
|
||||||
|
data-testid="button-copy-week"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
{copyWeekMutation.isPending ? "Copia in corso..." : "Copia Turno Settimanale"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info settimana */}
|
{/* Info settimana */}
|
||||||
@ -658,19 +717,19 @@ export default function GeneralPlanning() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Select guardia disponibile */}
|
{/* Select guardia (tutte, evidenziate in rosso se impegnate) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
|
// Mostra TUTTE le guardie, ma filtra solo per ore ordinarie/straordinario
|
||||||
const filteredGuards = availableGuards?.filter(g =>
|
const filteredGuards = availableGuards?.filter(g =>
|
||||||
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
|
showOvertimeGuards || !g.requiresOvertime
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime && g.isAvailable) || false;
|
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime) || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="guard-select">Guardia Disponibile</Label>
|
<Label htmlFor="guard-select">Guardia</Label>
|
||||||
{!isLoadingGuards && hasOvertimeGuards && (
|
{!isLoadingGuards && hasOvertimeGuards && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -699,15 +758,20 @@ export default function GeneralPlanning() {
|
|||||||
{filteredGuards.length > 0 ? (
|
{filteredGuards.length > 0 ? (
|
||||||
filteredGuards.map((guard) => (
|
filteredGuards.map((guard) => (
|
||||||
<SelectItem key={guard.guardId} value={guard.guardId}>
|
<SelectItem key={guard.guardId} value={guard.guardId}>
|
||||||
|
<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 disponibile"
|
? "Nessuna guardia"
|
||||||
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
|
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
@ -715,7 +779,7 @@ export default function GeneralPlanning() {
|
|||||||
</Select>
|
</Select>
|
||||||
{filteredGuards.length === 0 && !showOvertimeGuards && hasOvertimeGuards && (
|
{filteredGuards.length === 0 && !showOvertimeGuards && hasOvertimeGuards && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
ℹ️ Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
|
ℹ️ Alcune guardie richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{filteredGuards.length > 0 && selectedGuardId && (
|
{filteredGuards.length > 0 && selectedGuardId && (
|
||||||
@ -988,6 +1052,60 @@ export default function GeneralPlanning() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Dialog conferma copia settimana */}
|
||||||
|
<AlertDialog open={showCopyWeekConfirmation} onOpenChange={setShowCopyWeekConfirmation}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<Copy className="h-5 w-5" />
|
||||||
|
Copia Turno Settimanale
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-foreground font-medium">
|
||||||
|
Vuoi copiare tutti i turni della settimana corrente nella settimana successiva?
|
||||||
|
</p>
|
||||||
|
{planningData && (
|
||||||
|
<div className="space-y-2 bg-muted/30 p-3 rounded-md">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Settimana corrente:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{format(new Date(planningData.weekStart), "dd MMM", { locale: it })} -{" "}
|
||||||
|
{format(new Date(planningData.weekEnd), "dd MMM yyyy", { locale: it })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Verrà copiata in:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{format(addWeeks(new Date(planningData.weekStart), 1), "dd MMM", { locale: it })} -{" "}
|
||||||
|
{format(addWeeks(new Date(planningData.weekEnd), 1), "dd MMM yyyy", { locale: it })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Sede:</span>
|
||||||
|
<span className="font-medium">{formatLocation(selectedLocation)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tutti i turni e le assegnazioni guardie verranno duplicati con le stesse caratteristiche (orari, dotazioni, veicoli).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel data-testid="button-cancel-copy-week">Annulla</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => copyWeekMutation.mutate()}
|
||||||
|
data-testid="button-confirm-copy-week"
|
||||||
|
disabled={copyWeekMutation.isPending}
|
||||||
|
>
|
||||||
|
{copyWeekMutation.isPending ? "Copia in corso..." : "Conferma Copia"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -159,21 +159,31 @@ export default function MyShiftsFixed() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
dayShifts.map((shift) => {
|
dayShifts.map((shift) => {
|
||||||
// Parsing sicuro orari
|
// Parsing sicuro orari (DB in UTC → visualizza in Europe/Rome)
|
||||||
let startTime = "N/A";
|
let startTime = "N/A";
|
||||||
let endTime = "N/A";
|
let endTime = "N/A";
|
||||||
|
|
||||||
if (shift.plannedStartTime) {
|
if (shift.plannedStartTime) {
|
||||||
const parsedStart = parseISO(shift.plannedStartTime);
|
const parsedStart = new Date(shift.plannedStartTime);
|
||||||
if (isValid(parsedStart)) {
|
if (isValid(parsedStart)) {
|
||||||
startTime = format(parsedStart, "HH:mm");
|
startTime = parsedStart.toLocaleTimeString("it-IT", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
timeZone: "Europe/Rome"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shift.plannedEndTime) {
|
if (shift.plannedEndTime) {
|
||||||
const parsedEnd = parseISO(shift.plannedEndTime);
|
const parsedEnd = new Date(shift.plannedEndTime);
|
||||||
if (isValid(parsedEnd)) {
|
if (isValid(parsedEnd)) {
|
||||||
endTime = format(parsedEnd, "HH:mm");
|
endTime = parsedEnd.toLocaleTimeString("it-IT", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
timeZone: "Europe/Rome"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { format, addWeeks, addDays, startOfWeek } from "date-fns";
|
import { format, addWeeks, addDays, startOfWeek } from "date-fns";
|
||||||
import { it } from "date-fns/locale";
|
import { it } from "date-fns/locale";
|
||||||
import { ChevronLeft, ChevronRight, Users, Building2 } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Users, Building2, Navigation, Shield, Car as CarIcon, MapPin } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@ -12,13 +12,15 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
|
|
||||||
type Location = "roccapiemonte" | "milano" | "roma";
|
type Location = "roccapiemonte" | "milano" | "roma";
|
||||||
|
|
||||||
interface ShiftDetail {
|
interface FixedShiftDetail {
|
||||||
shiftId: string;
|
shiftId: string;
|
||||||
date: string;
|
date: string;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
|
siteAddress: string;
|
||||||
siteId: string;
|
siteId: string;
|
||||||
|
isArmed: boolean;
|
||||||
vehicle?: {
|
vehicle?: {
|
||||||
licensePlate: string;
|
licensePlate: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
@ -27,14 +29,42 @@ interface ShiftDetail {
|
|||||||
hours: number;
|
hours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GuardSchedule {
|
interface FixedGuardSchedule {
|
||||||
guardId: string;
|
guardId: string;
|
||||||
guardName: string;
|
guardName: string;
|
||||||
badgeNumber: string;
|
badgeNumber: string;
|
||||||
shifts: ShiftDetail[];
|
shifts: FixedShiftDetail[];
|
||||||
totalHours: number;
|
totalHours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PatrolRoute {
|
||||||
|
routeId: string;
|
||||||
|
guardId: string;
|
||||||
|
shiftDate: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
isArmedRoute: boolean;
|
||||||
|
vehicle?: {
|
||||||
|
licensePlate: string;
|
||||||
|
brand: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
stops: {
|
||||||
|
siteId: string;
|
||||||
|
siteName: string;
|
||||||
|
siteAddress: string;
|
||||||
|
sequenceOrder: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobileGuardSchedule {
|
||||||
|
guardId: string;
|
||||||
|
guardName: string;
|
||||||
|
badgeNumber: string;
|
||||||
|
routes: PatrolRoute[];
|
||||||
|
totalRoutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SiteSchedule {
|
interface SiteSchedule {
|
||||||
siteId: string;
|
siteId: string;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
@ -48,6 +78,7 @@ interface SiteSchedule {
|
|||||||
guardName: string;
|
guardName: string;
|
||||||
badgeNumber: string;
|
badgeNumber: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
|
isArmed: boolean;
|
||||||
}[];
|
}[];
|
||||||
vehicle?: {
|
vehicle?: {
|
||||||
licensePlate: string;
|
licensePlate: string;
|
||||||
@ -64,20 +95,30 @@ interface SiteSchedule {
|
|||||||
export default function ServicePlanning() {
|
export default function ServicePlanning() {
|
||||||
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||||
const [weekStart, setWeekStart] = useState<Date>(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
const [weekStart, setWeekStart] = useState<Date>(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||||
const [viewMode, setViewMode] = useState<"guard" | "site">("guard");
|
const [viewMode, setViewMode] = useState<"guard-fixed" | "guard-mobile" | "site">("guard-fixed");
|
||||||
|
|
||||||
const weekStartStr = format(weekStart, "yyyy-MM-dd");
|
const weekStartStr = format(weekStart, "yyyy-MM-dd");
|
||||||
const weekEndStr = format(addDays(weekStart, 6), "yyyy-MM-dd");
|
|
||||||
|
|
||||||
// Query per vista Guardie
|
// Query per vista Agenti Fissi
|
||||||
const { data: guardSchedules, isLoading: isLoadingGuards } = useQuery<GuardSchedule[]>({
|
const { data: fixedGuardSchedules, isLoading: isLoadingFixedGuards } = useQuery<FixedGuardSchedule[]>({
|
||||||
queryKey: ["/api/service-planning/by-guard", weekStartStr, selectedLocation],
|
queryKey: ["/api/service-planning/guards-fixed", weekStartStr, selectedLocation],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`/api/service-planning/by-guard?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
const response = await fetch(`/api/service-planning/guards-fixed?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
||||||
if (!response.ok) throw new Error("Failed to fetch guard schedules");
|
if (!response.ok) throw new Error("Failed to fetch fixed guard schedules");
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
enabled: viewMode === "guard",
|
enabled: viewMode === "guard-fixed",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query per vista Agenti Mobili
|
||||||
|
const { data: mobileGuardSchedules, isLoading: isLoadingMobileGuards } = useQuery<MobileGuardSchedule[]>({
|
||||||
|
queryKey: ["/api/service-planning/guards-mobile", weekStartStr, selectedLocation],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`/api/service-planning/guards-mobile?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch mobile guard schedules");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: viewMode === "guard-mobile",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query per vista Siti
|
// Query per vista Siti
|
||||||
@ -101,7 +142,7 @@ export default function ServicePlanning() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Planning di Servizio</h1>
|
<h1 className="text-3xl font-bold">Planning di Servizio</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Visualizza orari e dotazioni per guardia o sito
|
Visualizza orari e dotazioni per agente fisso, agente mobile o per sito
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -145,11 +186,15 @@ export default function ServicePlanning() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tabs per vista */}
|
{/* Tabs per vista */}
|
||||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard" | "site")}>
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard-fixed" | "guard-mobile" | "site")}>
|
||||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
<TabsList className="grid w-full max-w-2xl grid-cols-3">
|
||||||
<TabsTrigger value="guard" data-testid="tab-guard-view">
|
<TabsTrigger value="guard-fixed" data-testid="tab-guard-fixed-view">
|
||||||
<Users className="h-4 w-4 mr-2" />
|
<Users className="h-4 w-4 mr-2" />
|
||||||
Vista Agente
|
Agenti Fissi
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="guard-mobile" data-testid="tab-guard-mobile-view">
|
||||||
|
<Navigation className="h-4 w-4 mr-2" />
|
||||||
|
Agenti Mobili
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="site" data-testid="tab-site-view">
|
<TabsTrigger value="site" data-testid="tab-site-view">
|
||||||
<Building2 className="h-4 w-4 mr-2" />
|
<Building2 className="h-4 w-4 mr-2" />
|
||||||
@ -157,18 +202,18 @@ export default function ServicePlanning() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Vista Agente */}
|
{/* Vista Agenti Fissi */}
|
||||||
<TabsContent value="guard" className="space-y-4 mt-6">
|
<TabsContent value="guard-fixed" className="space-y-4 mt-6">
|
||||||
{isLoadingGuards ? (
|
{isLoadingFixedGuards ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Skeleton key={i} className="h-32 w-full" />
|
<Skeleton key={i} className="h-32 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : guardSchedules && guardSchedules.length > 0 ? (
|
) : fixedGuardSchedules && fixedGuardSchedules.length > 0 ? (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{guardSchedules.map((guard) => (
|
{fixedGuardSchedules.map((guard) => (
|
||||||
<Card key={guard.guardId} data-testid={`card-guard-${guard.guardId}`}>
|
<Card key={guard.guardId} data-testid={`card-guard-fixed-${guard.guardId}`}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-lg">
|
<CardTitle className="text-lg">
|
||||||
@ -179,25 +224,40 @@ export default function ServicePlanning() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{guard.shifts.length === 0 ? (
|
{guard.shifts.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Nessun turno assegnato</p>
|
<p className="text-sm text-muted-foreground">Nessun turno fisso assegnato</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{guard.shifts.map((shift) => (
|
{guard.shifts.map((shift) => (
|
||||||
<div
|
<div
|
||||||
key={shift.shiftId}
|
key={shift.shiftId}
|
||||||
className="flex items-start justify-between p-3 rounded-md bg-muted/50"
|
className="p-3 rounded-md bg-muted/50 space-y-2"
|
||||||
data-testid={`shift-${shift.shiftId}`}
|
data-testid={`shift-${shift.shiftId}`}
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
<div className="font-medium">{shift.siteName}</div>
|
<div className="font-medium">{shift.siteName}</div>
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{shift.siteAddress}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to} ({shift.hours}h)
|
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to} ({shift.hours}h)
|
||||||
</div>
|
</div>
|
||||||
{shift.vehicle && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model})
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
{shift.isArmed && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
Armato
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{shift.vehicle && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<CarIcon className="h-3 w-3 mr-1" />
|
||||||
|
{shift.vehicle.licensePlate}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -210,7 +270,101 @@ export default function ServicePlanning() {
|
|||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-center text-muted-foreground">Nessuna guardia con turni assegnati</p>
|
<p className="text-center text-muted-foreground">Nessun agente con turni fissi assegnati</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Vista Agenti Mobili */}
|
||||||
|
<TabsContent value="guard-mobile" className="space-y-4 mt-6">
|
||||||
|
{isLoadingMobileGuards ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-32 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : mobileGuardSchedules && mobileGuardSchedules.length > 0 ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{mobileGuardSchedules.map((guard) => (
|
||||||
|
<Card key={guard.guardId} data-testid={`card-guard-mobile-${guard.guardId}`}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{guard.guardName} <Badge variant="outline">{guard.badgeNumber}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<Badge>{guard.totalRoutes} {guard.totalRoutes === 1 ? 'percorso' : 'percorsi'}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{guard.routes.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Nessun percorso pattuglia assegnato</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{guard.routes.map((route) => (
|
||||||
|
<div
|
||||||
|
key={route.routeId}
|
||||||
|
className="p-3 rounded-md bg-muted/50 space-y-3"
|
||||||
|
data-testid={`route-${route.routeId}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-medium">
|
||||||
|
{format(new Date(route.shiftDate), "EEEE d MMM yyyy", { locale: it })}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{route.startTime} - {route.endTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{route.isArmedRoute && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
Armato
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{route.vehicle && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<CarIcon className="h-3 w-3 mr-1" />
|
||||||
|
{route.vehicle.licensePlate}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<Navigation className="h-4 w-4" />
|
||||||
|
Percorso ({route.stops.length} {route.stops.length === 1 ? 'tappa' : 'tappe'}):
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 pl-5">
|
||||||
|
{route.stops.map((stop) => (
|
||||||
|
<div key={stop.siteId} className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{stop.sequenceOrder}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-foreground">{stop.siteName}</div>
|
||||||
|
<div className="text-xs flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{stop.siteAddress}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-center text-muted-foreground">Nessun agente con percorsi pattuglia assegnati</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@ -252,20 +406,29 @@ export default function ServicePlanning() {
|
|||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to}
|
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
<Badge variant="secondary">{shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"}</Badge>
|
<Badge variant="secondary">{shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"}</Badge>
|
||||||
|
{shift.vehicle && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<CarIcon className="h-3 w-3 mr-1" />
|
||||||
|
{shift.vehicle.licensePlate}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{shift.guards.map((guard, idx) => (
|
{shift.guards.map((guard, idx) => (
|
||||||
<div key={idx} className="text-sm text-muted-foreground">
|
<div key={idx} className="text-sm text-muted-foreground flex items-center justify-between">
|
||||||
👤 {guard.guardName} ({guard.badgeNumber}) - {guard.hours}h
|
<span>{guard.guardName} ({guard.badgeNumber}) - {guard.hours}h</span>
|
||||||
|
{guard.isArmed && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
Armato
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{shift.vehicle && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model})
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -201,21 +201,31 @@ export default function SitePlanningView() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
dayGuards.map((guard, index) => {
|
dayGuards.map((guard, index) => {
|
||||||
// Parsing sicuro orari
|
// Parsing sicuro orari (DB in UTC → visualizza in Europe/Rome)
|
||||||
let startTime = "N/A";
|
let startTime = "N/A";
|
||||||
let endTime = "N/A";
|
let endTime = "N/A";
|
||||||
|
|
||||||
if (guard.plannedStartTime) {
|
if (guard.plannedStartTime) {
|
||||||
const parsedStart = parseISO(guard.plannedStartTime);
|
const parsedStart = new Date(guard.plannedStartTime);
|
||||||
if (isValid(parsedStart)) {
|
if (isValid(parsedStart)) {
|
||||||
startTime = format(parsedStart, "HH:mm");
|
startTime = parsedStart.toLocaleTimeString("it-IT", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
timeZone: "Europe/Rome"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (guard.plannedEndTime) {
|
if (guard.plannedEndTime) {
|
||||||
const parsedEnd = parseISO(guard.plannedEndTime);
|
const parsedEnd = new Date(guard.plannedEndTime);
|
||||||
if (isValid(parsedEnd)) {
|
if (isValid(parsedEnd)) {
|
||||||
endTime = format(parsedEnd, "HH:mm");
|
endTime = parsedEnd.toLocaleTimeString("it-IT", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
timeZone: "Europe/Rome"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
448
client/src/pages/weekly-guards.tsx
Normal file
448
client/src/pages/weekly-guards.tsx
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Users, Clock, MapPin, Navigation, ExternalLink } from "lucide-react";
|
||||||
|
import { format, parseISO, addDays, startOfWeek, addWeeks } from "date-fns";
|
||||||
|
import { it } from "date-fns/locale";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
|
||||||
|
type AbsenceType = "sick_leave" | "vacation" | "personal_leave" | "injury";
|
||||||
|
|
||||||
|
interface GuardScheduleData {
|
||||||
|
guard: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
badgeNumber: string;
|
||||||
|
};
|
||||||
|
fixedShifts: Array<{
|
||||||
|
assignmentId: string;
|
||||||
|
shiftId: string;
|
||||||
|
plannedStartTime: Date;
|
||||||
|
plannedEndTime: Date;
|
||||||
|
siteName: string;
|
||||||
|
siteId: string;
|
||||||
|
}>;
|
||||||
|
mobileShifts: Array<{
|
||||||
|
routeId: string;
|
||||||
|
shiftDate: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
}>;
|
||||||
|
absences: Array<{
|
||||||
|
id: string;
|
||||||
|
type: AbsenceType;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeeklyScheduleResponse {
|
||||||
|
weekStart: string;
|
||||||
|
weekEnd: string;
|
||||||
|
location: string;
|
||||||
|
guards: GuardScheduleData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ABSENCE_LABELS: Record<AbsenceType, string> = {
|
||||||
|
sick_leave: "Malattia",
|
||||||
|
vacation: "Ferie",
|
||||||
|
personal_leave: "Permesso",
|
||||||
|
injury: "Infortunio",
|
||||||
|
};
|
||||||
|
|
||||||
|
type DialogData = {
|
||||||
|
type: "fixed" | "mobile";
|
||||||
|
guardName: string;
|
||||||
|
date: string;
|
||||||
|
data: any;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export default function WeeklyGuards() {
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
|
||||||
|
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
|
||||||
|
startOfWeek(new Date(), { weekStartsOn: 1 }) // Inizia lunedì
|
||||||
|
);
|
||||||
|
const [dialogData, setDialogData] = useState<DialogData>(null);
|
||||||
|
|
||||||
|
const { data: scheduleData, isLoading, error } = useQuery<WeeklyScheduleResponse>({
|
||||||
|
queryKey: ["/api/weekly-guards-schedule", selectedLocation, format(currentWeekStart, "yyyy-MM-dd")],
|
||||||
|
queryFn: async () => {
|
||||||
|
const startDate = format(currentWeekStart, "yyyy-MM-dd");
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/weekly-guards-schedule?location=${selectedLocation}&startDate=${startDate}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch weekly schedule");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedLocation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper per ottenere i giorni della settimana
|
||||||
|
const getWeekDays = () => {
|
||||||
|
const days = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
days.push(addDays(currentWeekStart, i));
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const weekDays = getWeekDays();
|
||||||
|
|
||||||
|
// Helper per trovare l'attività di una guardia in un giorno specifico
|
||||||
|
const getDayActivity = (guardData: GuardScheduleData, date: Date) => {
|
||||||
|
const dateStr = format(date, "yyyy-MM-dd");
|
||||||
|
|
||||||
|
// Controlla assenze
|
||||||
|
const absence = guardData.absences.find(abs => {
|
||||||
|
const startDate = abs.startDate;
|
||||||
|
const endDate = abs.endDate;
|
||||||
|
return dateStr >= startDate && dateStr <= endDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (absence) {
|
||||||
|
return {
|
||||||
|
type: "absence" as const,
|
||||||
|
label: ABSENCE_LABELS[absence.type],
|
||||||
|
data: absence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controlla turni fissi
|
||||||
|
const fixedShift = guardData.fixedShifts.find(shift => {
|
||||||
|
const shiftDate = format(new Date(shift.plannedStartTime), "yyyy-MM-dd");
|
||||||
|
return shiftDate === dateStr;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fixedShift) {
|
||||||
|
const startTime = format(new Date(fixedShift.plannedStartTime), "HH:mm");
|
||||||
|
const endTime = format(new Date(fixedShift.plannedEndTime), "HH:mm");
|
||||||
|
return {
|
||||||
|
type: "fixed" as const,
|
||||||
|
label: `${fixedShift.siteName} ${startTime}-${endTime}`,
|
||||||
|
data: fixedShift,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controlla turni mobili
|
||||||
|
const mobileShift = guardData.mobileShifts.find(shift => shift.shiftDate === dateStr);
|
||||||
|
|
||||||
|
if (mobileShift) {
|
||||||
|
return {
|
||||||
|
type: "mobile" as const,
|
||||||
|
label: `Pattuglia ${mobileShift.startTime}-${mobileShift.endTime}`,
|
||||||
|
data: mobileShift,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviousWeek = () => {
|
||||||
|
setCurrentWeekStart(prev => addWeeks(prev, -1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextWeek = () => {
|
||||||
|
setCurrentWeekStart(prev => addWeeks(prev, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCellClick = (guardData: GuardScheduleData, activity: ReturnType<typeof getDayActivity>, date: Date) => {
|
||||||
|
if (!activity || activity.type === "absence") return;
|
||||||
|
|
||||||
|
const guardName = `${guardData.guard.lastName} ${guardData.guard.firstName}`;
|
||||||
|
const dateStr = format(date, "EEEE dd MMMM yyyy", { locale: it });
|
||||||
|
|
||||||
|
setDialogData({
|
||||||
|
type: activity.type,
|
||||||
|
guardName,
|
||||||
|
date: dateStr,
|
||||||
|
data: activity.data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||||
|
<Users className="h-8 w-8 text-primary" />
|
||||||
|
Guardie Settimanale
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Vista riepilogativa delle assegnazioni settimanali per sede
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtri */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="h-5 w-5" />
|
||||||
|
Filtri Visualizzazione
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Seleziona sede e settimana per visualizzare le assegnazioni
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm font-medium mb-2 block">Sede</label>
|
||||||
|
<Select
|
||||||
|
value={selectedLocation}
|
||||||
|
onValueChange={setSelectedLocation}
|
||||||
|
data-testid="select-location"
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Seleziona sede" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
||||||
|
<SelectItem value="milano">Milano</SelectItem>
|
||||||
|
<SelectItem value="roma">Roma</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handlePreviousWeek}
|
||||||
|
data-testid="button-previous-week"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="px-4 py-2 border rounded-md bg-muted min-w-[280px] text-center">
|
||||||
|
<span className="font-medium">
|
||||||
|
{format(currentWeekStart, "d MMM", { locale: it })} - {format(addDays(currentWeekStart, 6), "d MMM yyyy", { locale: it })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleNextWeek}
|
||||||
|
data-testid="button-next-week"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Griglia Settimanale */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-center text-muted-foreground">Caricamento...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : error ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-center text-destructive">
|
||||||
|
Errore nel caricamento della pianificazione. Riprova più tardi.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : scheduleData && scheduleData.guards.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="sticky left-0 z-10 bg-muted/50 text-left p-3 font-medium min-w-[180px]">
|
||||||
|
Guardia
|
||||||
|
</th>
|
||||||
|
{weekDays.map((day, index) => (
|
||||||
|
<th key={index} className="text-center p-3 font-medium min-w-[200px]">
|
||||||
|
<div>{format(day, "EEE", { locale: it })}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-normal">
|
||||||
|
{format(day, "dd/MM")}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{scheduleData.guards.map((guardData) => (
|
||||||
|
<tr
|
||||||
|
key={guardData.guard.id}
|
||||||
|
className="border-b hover:bg-muted/30"
|
||||||
|
data-testid={`row-guard-${guardData.guard.id}`}
|
||||||
|
>
|
||||||
|
<td className="sticky left-0 z-10 bg-background p-3 font-medium border-r">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm">
|
||||||
|
{guardData.guard.lastName} {guardData.guard.firstName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
#{guardData.guard.badgeNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{weekDays.map((day, dayIndex) => {
|
||||||
|
const activity = getDayActivity(guardData, day);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={dayIndex}
|
||||||
|
className="p-2 text-center align-middle"
|
||||||
|
>
|
||||||
|
{activity ? (
|
||||||
|
activity.type === "absence" ? (
|
||||||
|
<div
|
||||||
|
className="text-xs px-2 py-1.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-300"
|
||||||
|
data-testid={`cell-absence-${guardData.guard.id}-${dayIndex}`}
|
||||||
|
>
|
||||||
|
{activity.label}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-auto text-xs px-2 py-1.5 whitespace-normal hover-elevate"
|
||||||
|
onClick={() => handleCellClick(guardData, activity, day)}
|
||||||
|
data-testid={`button-shift-${guardData.guard.id}-${dayIndex}`}
|
||||||
|
>
|
||||||
|
{activity.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
Nessuna guardia trovata per la sede selezionata
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialog Dettaglio Turno */}
|
||||||
|
<Dialog open={!!dialogData} onOpenChange={() => setDialogData(null)}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{dialogData?.type === "fixed" ? (
|
||||||
|
<>
|
||||||
|
<MapPin className="h-5 w-5" />
|
||||||
|
Turno Fisso - {dialogData?.guardName}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Navigation className="h-5 w-5" />
|
||||||
|
Turno Mobile - {dialogData?.guardName}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{dialogData?.date}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{dialogData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{dialogData.type === "fixed" ? (
|
||||||
|
// Dettagli turno fisso
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-muted/30 p-3 rounded-md space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Sito</span>
|
||||||
|
<span className="text-sm font-medium">{dialogData.data.siteName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Orario</span>
|
||||||
|
<div className="flex items-center gap-1 text-sm font-medium">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{format(new Date(dialogData.data.plannedStartTime), "HH:mm")} - {format(new Date(dialogData.data.plannedEndTime), "HH:mm")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Durata</span>
|
||||||
|
<span className="text-sm font-bold">
|
||||||
|
{Math.round((new Date(dialogData.data.plannedEndTime).getTime() - new Date(dialogData.data.plannedStartTime).getTime()) / (1000 * 60 * 60))}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Per modificare questo turno, vai alla pagina <Link href="/general-planning" className="text-primary font-medium hover:underline">Planning Fissi</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Dettagli turno mobile
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-muted/30 p-3 rounded-md space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Tipo</span>
|
||||||
|
<Badge variant="outline">Pattuglia</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Orario</span>
|
||||||
|
<div className="flex items-center gap-1 text-sm font-medium">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{dialogData.data.startTime} - {dialogData.data.endTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Per visualizzare il percorso completo e modificare il turno, vai alla pagina <Link href="/planning-mobile" className="text-primary font-medium hover:underline">Planning Mobile</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogData(null)}>
|
||||||
|
Chiudi
|
||||||
|
</Button>
|
||||||
|
{dialogData?.type === "fixed" ? (
|
||||||
|
<Link href="/general-planning">
|
||||||
|
<Button data-testid="button-goto-planning-fissi">
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Vai a Planning Fissi
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/planning-mobile">
|
||||||
|
<Button data-testid="button-goto-planning-mobile">
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Vai a Planning Mobile
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.51_20251024_132912.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.51_20251024_132912.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.52_20251024_145327.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.52_20251024_145327.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.53_20251024_163455.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.53_20251024_163455.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.54_20251024_172526.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.54_20251024_172526.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.55_20251025_072838.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.55_20251025_072838.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.56_20251025_074951.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.56_20251025_074951.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.57_20251025_080211.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.57_20251025_080211.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.58_20251025_090445.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.58_20251025_090445.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.1.0_20251025_090838.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.1.0_20251025_090838.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.1.1_20251115_101122.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.1.1_20251115_101122.sql.gz
Normal file
Binary file not shown.
56
package-lock.json
generated
56
package-lock.json
generated
@ -9,6 +9,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
"@neondatabase/serverless": "^0.10.4",
|
||||||
@ -417,6 +420,59 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||||
|
|||||||
@ -11,6 +11,9 @@
|
|||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
"@neondatabase/serverless": "^0.10.4",
|
||||||
|
|||||||
140
replit.md
140
replit.md
@ -1,7 +1,7 @@
|
|||||||
# VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza
|
# VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
VigilanzaTurni is a professional 24/7 shift management system for security companies, designed to streamline operations and enhance efficiency. It supports multi-role authentication (Admin, Coordinator, Guard, Client) and multi-location operations, managing over 250 security personnel across different branches (Roccapiemonte, Milano, Roma). Key capabilities include comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system.
|
VigilanzaTurni is a professional 24/7 shift management system for security companies, designed to streamline operations and enhance efficiency. It supports multi-role authentication (Admin, Coordinator, Guard, Client) and multi-location operations, managing over 250 security personnel across different branches. Key capabilities include comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system. The project aims to provide a robust, scalable solution for security companies, improving operational control and resource allocation.
|
||||||
|
|
||||||
## User Preferences
|
## User Preferences
|
||||||
- Interfaccia in italiano
|
- Interfaccia in italiano
|
||||||
@ -29,20 +29,41 @@ VigilanzaTurni is a professional 24/7 shift management system for security compa
|
|||||||
- **Componenti**: Shadcn UI with an operational design.
|
- **Componenti**: Shadcn UI with an operational design.
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
The database includes tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, `notifications`, `customers`, `service_types`, and various tables for advanced scheduling and constraints (`guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, `absence_affected_shifts`). Service types include specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, and `responseTimeMinutes`. Sites support multi-location (`location` field), contract management (`contractReference`, `contractStartDate`, `contractEndDate`), and service type association (`serviceTypeId` FK to `service_types.id`).
|
The database supports managing users, guards, certifications, sites, shifts, shift assignments, notifications, customers, and service types. It also includes tables for advanced scheduling constraints such as guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Service types include specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, and `responseTimeMinutes`.
|
||||||
|
|
||||||
### Core Features
|
### Core Features
|
||||||
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering sites, guards, and vehicles by selected branch.
|
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch.
|
||||||
- **Service Type Classification**: Service types are classified as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules (Planning Fissi, Planning Mobile).
|
- **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 showing all sites with active contracts, allowing direct shift creation for multiple days with guard availability checks.
|
- **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**: Dedicated guard-centric interface for mobile services, displaying guard availability and hours for mobile-classified sites. Includes interactive Leaflet map showing sites with GPS coordinates and automatic re-centering based on selected location.
|
- **Planning Mobile**: Guard-centric interface for mobile services, displaying guard availability and hours, with an interactive Leaflet map showing sites. Features include:
|
||||||
- **Customer Management**: Full CRUD operations for customers with comprehensive details.
|
- **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
|
||||||
- **Customer-Centric Reports**: New reports aggregating data by customer, replacing site-based billing, with specific counters for fixed posts (hours), patrols (passages), inspections, and interventions. CSV export is supported.
|
- **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.
|
||||||
- **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 unique badge numbers.
|
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
|
||||||
- **Gestione Siti/Commesse**: Sites are associated with service types from the `service_types` table via `serviceTypeId` (FK). Service types are managed in the "Tipologie Servizi" page and include specialized parameters. Sites include service schedule, contract management, location assignment, and customer assignment (`customerId` FK to `customers.id`).
|
- **Gestione Siti/Commesse**: Sites are associated with service types, including schedule, contract management, and location assignment. Automatic geocoding is supported.
|
||||||
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses.
|
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses.
|
||||||
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences.
|
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Includes patrol route persistence and exclusivity constraints between fixed and mobile shifts.
|
||||||
|
- **Guard Planning Views**: Dedicated views for guards to see their fixed post shifts and mobile patrol routes.
|
||||||
|
- **Site Planning View**: Coordinators can view all guards assigned to a specific site over a week.
|
||||||
|
- **Shift Duplication Features**:
|
||||||
|
- **Weekly Copy (Planning Fissi)**: POST /api/shift-assignments/copy-week endpoint duplicates all shifts and assignments from selected week to next week (+7 days) with atomic transaction. Frontend includes confirmation dialog with week details and success feedback.
|
||||||
|
- **Patrol Sequence Duplication (Planning Mobili)**: POST /api/patrol-routes/duplicate endpoint with dual behavior: UPDATE when target date = source date (modifies guard), CREATE when different date (duplicates route with all stops). Frontend shows daily sequence list with duplication dialog (date picker defaulting to next day, guard selector pre-filled but changeable).
|
||||||
|
- **Guardie Settimanale**: Compact weekly schedule view showing all guards' assignments across the week in a grid format. Features include:
|
||||||
|
- **Weekly Grid View**: Guard names in first column, 7 daily columns (Mon-Sun) with compact cell display
|
||||||
|
- **Multi-Source Aggregation**: GET /api/weekly-guards-schedule endpoint aggregates fixed shifts, patrol routes, and absences by location and week
|
||||||
|
- **Compact Cell Format**: Fixed posts show "Site Name HH:mm-HH:mm", mobile patrols show "Pattuglia HH:mm-HH:mm", absences show status (Ferie/Malattia/Permesso/Riposo)
|
||||||
|
- **Read-Only Dialogs**: Clicking cells opens appropriate dialog (fixed shift details or mobile patrol info) with navigation links to Planning Fissi/Mobile for edits
|
||||||
|
- **Location and Week Filters**: Dropdown for branch selection, week navigation with prev/next buttons displaying "Settimana dal DD MMM al DD MMM YYYY"
|
||||||
|
|
||||||
### User Roles
|
### User Roles
|
||||||
- **Admin**: Full access.
|
- **Admin**: Full access.
|
||||||
@ -51,96 +72,7 @@ The database includes tables for `users`, `guards`, `certifications`, `sites`, `
|
|||||||
- **Client**: View assigned sites, service reporting, KPIs.
|
- **Client**: View assigned sites, service reporting, KPIs.
|
||||||
|
|
||||||
### Critical Date/Timezone Handling
|
### Critical Date/Timezone Handling
|
||||||
To prevent timezone-related bugs, especially when assigning shifts, dates should always be constructed from components (`new Date(year, month-1, day)`) and never parsed from ISO strings directly using `parseISO()` or `new Date(string ISO)`. Date validation should use regex instead of `parseISO()`.
|
The system handles timezone conversions for shift times, converting Italy local time from the frontend to UTC for database storage, and back to Italy local time for display, accounting for DST.
|
||||||
|
|
||||||
## Recent Changes (October 2025)
|
|
||||||
### Automatic Geocoding Integration (October 23, 2025)
|
|
||||||
- **Issue**: Users had to manually find and enter GPS coordinates for sites, which was time-consuming and error-prone
|
|
||||||
- **Solution**:
|
|
||||||
- **Backend (`server/routes.ts`)**:
|
|
||||||
- Created POST `/api/geocode` endpoint integrating Nominatim API (OpenStreetMap)
|
|
||||||
- Implemented in-memory rate limiter enforcing 1 request/second to comply with Nominatim usage policy
|
|
||||||
- Added compliant User-Agent header: "VigilanzaTurni/1.0 (Security Shift Management System; contact: support@vigilanzaturni.it)"
|
|
||||||
- Returns latitude, longitude, displayName, and full address object
|
|
||||||
- **Frontend (`client/src/pages/sites.tsx`)**:
|
|
||||||
- Added "📍 Trova Coordinate" button in both create and edit site dialogs
|
|
||||||
- Button auto-populates latitude/longitude fields from address
|
|
||||||
- Disabled state when address missing or geocoding in progress
|
|
||||||
- Toast notifications for success (with found address) and error handling
|
|
||||||
- Dedicated section with bg-muted/50 highlighting for GPS coordinates
|
|
||||||
- **Impact**: Users can now automatically geocode site addresses with a single click, ensuring accurate GPS positioning for Planning Mobile map without manual coordinate lookup
|
|
||||||
|
|
||||||
### Planning Mobile - Leaflet Map Integration (October 23, 2025)
|
|
||||||
- **Issue**: Planning Mobile page had errors in backend endpoints and lacked interactive map functionality
|
|
||||||
- **Solution**:
|
|
||||||
- **Backend Fixes**:
|
|
||||||
- Removed non-existent fields (`city` from sites, `isActive` from guards) from queries
|
|
||||||
- Changed `innerJoin` to `leftJoin` for service types to handle sites without serviceTypeId
|
|
||||||
- Fixed `orderBy` syntax (single field instead of multiple)
|
|
||||||
- Added `hasDriverLicense` filter for guards (mobile services require driving)
|
|
||||||
- **Map Integration**:
|
|
||||||
- Implemented Leaflet + react-leaflet with OpenStreetMap tiles (100% free, no API key)
|
|
||||||
- Interactive markers for sites with GPS coordinates and classification="mobile"
|
|
||||||
- Popup details showing site name, address, and service type
|
|
||||||
- Automatic re-centering via `key={selectedLocation}` forcing MapContainer remount
|
|
||||||
- Graceful fallback for sites without coordinates
|
|
||||||
- **Impact**: Planning Mobile now fully functional with interactive map for patrol/inspection route planning
|
|
||||||
|
|
||||||
### Planning Mobile - Interactive Features (October 23, 2025)
|
|
||||||
- **Issue**: Planning Mobile needed interactive map controls and patrol route sequencing workflow
|
|
||||||
- **Critical Bug Fix - Geocoding**:
|
|
||||||
- **Problem**: Toast showed "Indirizzo: undefined" when geocoding sites
|
|
||||||
- **Root Cause**: `apiRequest()` returns Response object, not parsed JSON
|
|
||||||
- **Fix**: Added `const result = await response.json()` after apiRequest in both handleGeocode() and handleGeocodeEdit()
|
|
||||||
- **Impact**: Geocoding now correctly displays full address from Nominatim (e.g., "Via Tiburtina, Roma, Lazio, Italia")
|
|
||||||
- **New Features**:
|
|
||||||
- **Zoom-to-Site**:
|
|
||||||
- MapController component with useMap() hook for programmatic map control
|
|
||||||
- Navigation button on each site card triggers flyTo() animation (zoom level 16)
|
|
||||||
- Toast feedback: "Mappa centrata - Visualizzazione di [nome sito]"
|
|
||||||
- **Guard Assignment**:
|
|
||||||
- "Assegna Guardia" button on site cards
|
|
||||||
- Validates guard selection from dropdown
|
|
||||||
- Toast feedback: "[Sito] assegnato a [Nome Cognome Guardia]"
|
|
||||||
- **Patrol Route Sequencing**:
|
|
||||||
- Click markers on map to build patrol route sequence
|
|
||||||
- Visual feedback: Green "Tappa N" badges on markers and site cards
|
|
||||||
- Dedicated UI section showing route in construction with numbered sequence
|
|
||||||
- Remove/clear/save route controls
|
|
||||||
- Auto-reset route when changing guard or location
|
|
||||||
- **Note**: Backend persistence (save to database) not yet implemented - marked as TODO for future development
|
|
||||||
- **Impact**: Operators can now interact with the map, assign guards to sites, and visually plan patrol routes by clicking sites in sequence
|
|
||||||
|
|
||||||
### Sites Form Fix - ServiceTypeId Integration (October 2025)
|
|
||||||
- **Issue**: Sites form used hardcoded `shiftType` enum values instead of dynamic service types from the database
|
|
||||||
- **Solution**:
|
|
||||||
- Changed Sites form to use `serviceTypeId` (FK to `service_types.id`) instead of deprecated `shiftType` field
|
|
||||||
- Added dynamic service type dropdown loading from `/api/service-types` endpoint
|
|
||||||
- Updated both create and edit forms to properly handle service type selection
|
|
||||||
- Card display now shows service type label from database instead of hardcoded labels
|
|
||||||
- **Impact**: Sites now correctly reference service types configured in "Tipologie Servizi" page, ensuring consistency across the system
|
|
||||||
|
|
||||||
### Advanced Planning System Complete (October 23, 2025)
|
|
||||||
- **Implementation**: Full planning system with guard views, exclusivity constraints, and database persistence
|
|
||||||
- **Features**:
|
|
||||||
- **Patrol Route Database Persistence**:
|
|
||||||
- Backend endpoints: GET/POST/PUT/DELETE `/api/patrol-routes`
|
|
||||||
- Database schema: `patrol_routes` table with `patrol_route_stops` for sequence
|
|
||||||
- Planning Mobile loads existing routes when guard selected, saves to DB
|
|
||||||
- Green markers on map for sites in current patrol route
|
|
||||||
- **Exclusivity Constraint (fisso/mobile)**:
|
|
||||||
- Validation in 3 backend endpoints: POST `/api/patrol-routes`, POST `/api/shift-assignments`, POST `/api/shifts/:shiftId/assignments`
|
|
||||||
- Guards cannot be assigned to both fixed posts and mobile patrols on same date
|
|
||||||
- Clear error messages when constraint violated
|
|
||||||
- **Guard Planning Views**:
|
|
||||||
- `/my-shifts-fixed`: Guards view their fixed post shifts with orari, dotazioni (armato, automezzo), location, sito
|
|
||||||
- `/my-shifts-mobile`: Guards view patrol routes with sequenced site list, addresses, vehicle assignment
|
|
||||||
- Backend endpoints: GET `/api/my-shifts/fixed`, GET `/api/my-shifts/mobile` with date range filters
|
|
||||||
- **Site Planning View**:
|
|
||||||
- `/site-planning-view`: Coordinators view all guards assigned to a site across a week
|
|
||||||
- Shows guard name, badge, orari, dotazioni for each assignment
|
|
||||||
- Backend endpoint: GET `/api/site-planning/:siteId` with date range filters
|
|
||||||
- **Impact**: Complete end-to-end planning system supporting both coordinator and guard roles with database-backed route planning and operational equipment tracking
|
|
||||||
|
|
||||||
## External Dependencies
|
## External Dependencies
|
||||||
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
|
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
|
||||||
@ -151,5 +83,7 @@ To prevent timezone-related bugs, especially when assigning shifts, dates should
|
|||||||
- **TanStack Query**: For data fetching and state management.
|
- **TanStack Query**: For data fetching and state management.
|
||||||
- **Wouter**: For client-side routing.
|
- **Wouter**: For client-side routing.
|
||||||
- **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 (free).
|
- **Leaflet**: Interactive map library with react-leaflet bindings and OpenStreetMap tiles.
|
||||||
- **Nominatim**: OpenStreetMap geocoding API for automatic address-to-coordinates conversion (free, rate limited to 1 req/sec).
|
- **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.
|
||||||
970
server/routes.ts
970
server/routes.ts
File diff suppressed because it is too large
Load Diff
142
version.json
142
version.json
@ -1,7 +1,79 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.48",
|
"version": "1.1.1",
|
||||||
"lastUpdate": "2025-10-23T16:03:23.268Z",
|
"lastUpdate": "2025-11-15T10:11:44.404Z",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
{
|
||||||
|
"version": "1.1.1",
|
||||||
|
"date": "2025-11-15",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.1.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.1.0",
|
||||||
|
"date": "2025-10-25",
|
||||||
|
"type": "minor",
|
||||||
|
"description": "Deployment automatico v1.1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.58",
|
||||||
|
"date": "2025-10-25",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.58"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.57",
|
||||||
|
"date": "2025-10-25",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.57"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.56",
|
||||||
|
"date": "2025-10-25",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.56"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.55",
|
||||||
|
"date": "2025-10-25",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.55"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.54",
|
||||||
|
"date": "2025-10-24",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.54"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.53",
|
||||||
|
"date": "2025-10-24",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.52",
|
||||||
|
"date": "2025-10-24",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.52"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.51",
|
||||||
|
"date": "2025-10-24",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.51"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.50",
|
||||||
|
"date": "2025-10-24",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.49",
|
||||||
|
"date": "2025-10-23",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.49"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.48",
|
"version": "1.0.48",
|
||||||
"date": "2025-10-23",
|
"date": "2025-10-23",
|
||||||
@ -229,72 +301,6 @@
|
|||||||
"date": "2025-10-17",
|
"date": "2025-10-17",
|
||||||
"type": "patch",
|
"type": "patch",
|
||||||
"description": "Deployment automatico v1.0.11"
|
"description": "Deployment automatico v1.0.11"
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.10",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.10"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.9",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.9"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.8",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.8"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.7",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.7"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.6",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.6"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.5",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.4",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.3",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.2",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.1",
|
|
||||||
"date": "2025-10-17",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.0",
|
|
||||||
"date": "2025-01-17",
|
|
||||||
"type": "initial",
|
|
||||||
"description": "Versione iniziale VigilanzaTurni - Sistema completo gestione turni vigilanza"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user