Compare commits
No commits in common. "main" and "v1.0.33" have entirely different histories.
12
.replit
12
.replit
@ -19,14 +19,6 @@ externalPort = 80
|
|||||||
localPort = 33035
|
localPort = 33035
|
||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 40417
|
|
||||||
externalPort = 8000
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 41295
|
|
||||||
externalPort = 5173
|
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41343
|
localPort = 41343
|
||||||
externalPort = 3000
|
externalPort = 3000
|
||||||
@ -39,10 +31,6 @@ 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
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
@ -18,11 +18,6 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<!-- Leaflet CSS -->
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
crossorigin=""/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -25,12 +25,6 @@ import Planning from "@/pages/planning";
|
|||||||
import OperationalPlanning from "@/pages/operational-planning";
|
import OperationalPlanning from "@/pages/operational-planning";
|
||||||
import GeneralPlanning from "@/pages/general-planning";
|
import GeneralPlanning from "@/pages/general-planning";
|
||||||
import ServicePlanning from "@/pages/service-planning";
|
import ServicePlanning from "@/pages/service-planning";
|
||||||
import Customers from "@/pages/customers";
|
|
||||||
import PlanningMobile from "@/pages/planning-mobile";
|
|
||||||
import MyShiftsFixed from "@/pages/my-shifts-fixed";
|
|
||||||
import MyShiftsMobile from "@/pages/my-shifts-mobile";
|
|
||||||
import SitePlanningView from "@/pages/site-planning-view";
|
|
||||||
import WeeklyGuards from "@/pages/weekly-guards";
|
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
@ -45,7 +39,6 @@ function Router() {
|
|||||||
<Route path="/" component={Dashboard} />
|
<Route path="/" component={Dashboard} />
|
||||||
<Route path="/guards" component={Guards} />
|
<Route path="/guards" component={Guards} />
|
||||||
<Route path="/sites" component={Sites} />
|
<Route path="/sites" component={Sites} />
|
||||||
<Route path="/customers" component={Customers} />
|
|
||||||
<Route path="/services" component={Services} />
|
<Route path="/services" component={Services} />
|
||||||
<Route path="/vehicles" component={Vehicles} />
|
<Route path="/vehicles" component={Vehicles} />
|
||||||
<Route path="/shifts" component={Shifts} />
|
<Route path="/shifts" component={Shifts} />
|
||||||
@ -53,12 +46,7 @@ function Router() {
|
|||||||
<Route path="/operational-planning" component={OperationalPlanning} />
|
<Route path="/operational-planning" component={OperationalPlanning} />
|
||||||
<Route path="/general-planning" component={GeneralPlanning} />
|
<Route path="/general-planning" component={GeneralPlanning} />
|
||||||
<Route path="/service-planning" component={ServicePlanning} />
|
<Route path="/service-planning" component={ServicePlanning} />
|
||||||
<Route path="/planning-mobile" component={PlanningMobile} />
|
|
||||||
<Route path="/advanced-planning" component={AdvancedPlanning} />
|
<Route path="/advanced-planning" component={AdvancedPlanning} />
|
||||||
<Route path="/my-shifts-fixed" component={MyShiftsFixed} />
|
|
||||||
<Route path="/my-shifts-mobile" component={MyShiftsMobile} />
|
|
||||||
<Route path="/site-planning-view" component={SitePlanningView} />
|
|
||||||
<Route path="/weekly-guards" component={WeeklyGuards} />
|
|
||||||
<Route path="/reports" component={Reports} />
|
<Route path="/reports" component={Reports} />
|
||||||
<Route path="/notifications" component={Notifications} />
|
<Route path="/notifications" component={Notifications} />
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={Users} />
|
||||||
|
|||||||
@ -11,12 +11,6 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
Car,
|
Car,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Navigation,
|
|
||||||
ChevronDown,
|
|
||||||
FileText,
|
|
||||||
FolderKanban,
|
|
||||||
Building2,
|
|
||||||
Wrench,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import {
|
import {
|
||||||
@ -28,31 +22,15 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
interface MenuItem {
|
const menuItems = [
|
||||||
title: string;
|
|
||||||
url?: string;
|
|
||||||
icon: any;
|
|
||||||
roles: string[];
|
|
||||||
items?: MenuItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: "Dashboard",
|
||||||
url: "/",
|
url: "/",
|
||||||
@ -60,123 +38,88 @@ const menuItems: MenuItem[] = [
|
|||||||
roles: ["admin", "coordinator", "guard", "client"],
|
roles: ["admin", "coordinator", "guard", "client"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Planning",
|
title: "Turni",
|
||||||
icon: FolderKanban,
|
url: "/shifts",
|
||||||
roles: ["admin", "coordinator"],
|
icon: Calendar,
|
||||||
items: [
|
roles: ["admin", "coordinator", "guard"],
|
||||||
{
|
|
||||||
title: "Fissi",
|
|
||||||
url: "/general-planning",
|
|
||||||
icon: Calendar,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Mobili",
|
|
||||||
url: "/planning-mobile",
|
|
||||||
icon: Navigation,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Vista",
|
|
||||||
url: "/service-planning",
|
|
||||||
icon: ClipboardList,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Guardie Settimanale",
|
|
||||||
url: "/weekly-guards",
|
|
||||||
icon: Users,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Scadenziario",
|
title: "Pianificazione",
|
||||||
url: "/advanced-planning",
|
url: "/planning",
|
||||||
|
icon: ClipboardList,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Pianificazione Operativa",
|
||||||
|
url: "/operational-planning",
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Anagrafiche",
|
title: "Planning Generale",
|
||||||
icon: Building2,
|
url: "/general-planning",
|
||||||
|
icon: BarChart3,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Guardie",
|
|
||||||
url: "/guards",
|
|
||||||
icon: Users,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Siti",
|
|
||||||
url: "/sites",
|
|
||||||
icon: MapPin,
|
|
||||||
roles: ["admin", "coordinator", "client"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Clienti",
|
|
||||||
url: "/customers",
|
|
||||||
icon: Briefcase,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Automezzi",
|
|
||||||
url: "/vehicles",
|
|
||||||
icon: Car,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Tipologia",
|
title: "Planning di Servizio",
|
||||||
icon: Wrench,
|
url: "/service-planning",
|
||||||
|
icon: ClipboardList,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Gestione Pianificazioni",
|
||||||
|
url: "/advanced-planning",
|
||||||
|
icon: ClipboardList,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Guardie",
|
||||||
|
url: "/guards",
|
||||||
|
icon: Users,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Siti",
|
||||||
|
url: "/sites",
|
||||||
|
icon: MapPin,
|
||||||
|
roles: ["admin", "coordinator", "client"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Servizi",
|
||||||
|
url: "/services",
|
||||||
|
icon: Briefcase,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Parco Automezzi",
|
||||||
|
url: "/vehicles",
|
||||||
|
icon: Car,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Servizi",
|
|
||||||
url: "/services",
|
|
||||||
icon: Briefcase,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Contratti",
|
|
||||||
url: "/parameters",
|
|
||||||
icon: Settings,
|
|
||||||
roles: ["admin", "coordinator"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Report",
|
title: "Report",
|
||||||
|
url: "/reports",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
roles: ["admin", "coordinator", "client"],
|
roles: ["admin", "coordinator", "client"],
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Report Amministrativo",
|
|
||||||
url: "/reports",
|
|
||||||
icon: FileText,
|
|
||||||
roles: ["admin", "coordinator", "client"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Utilità",
|
title: "Notifiche",
|
||||||
icon: Settings,
|
url: "/notifications",
|
||||||
|
icon: Bell,
|
||||||
roles: ["admin", "coordinator", "guard"],
|
roles: ["admin", "coordinator", "guard"],
|
||||||
items: [
|
},
|
||||||
{
|
{
|
||||||
title: "Utenti",
|
title: "Utenti",
|
||||||
url: "/users",
|
url: "/users",
|
||||||
icon: UserCog,
|
icon: UserCog,
|
||||||
roles: ["admin"],
|
roles: ["admin"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Notifiche",
|
title: "Parametri",
|
||||||
url: "/notifications",
|
url: "/parameters",
|
||||||
icon: Bell,
|
icon: Settings,
|
||||||
roles: ["admin", "coordinator", "guard"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -184,78 +127,9 @@ export function AppSidebar() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
|
|
||||||
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
|
const filteredItems = menuItems.filter(
|
||||||
if (!user) return [];
|
(item) => user && item.roles.includes(user.role)
|
||||||
|
);
|
||||||
return items.filter((item) => {
|
|
||||||
const hasRole = item.roles.includes(user.role);
|
|
||||||
if (!hasRole) return false;
|
|
||||||
|
|
||||||
if (item.items) {
|
|
||||||
item.items = filterMenuItems(item.items);
|
|
||||||
return item.items.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredItems = filterMenuItems(menuItems);
|
|
||||||
|
|
||||||
const renderMenuItem = (item: MenuItem) => {
|
|
||||||
// Menu item con sottomenu
|
|
||||||
if (item.items && item.items.length > 0) {
|
|
||||||
const isAnySubItemActive = item.items.some((subItem) => location === subItem.url);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible key={item.title} defaultOpen={isAnySubItemActive} className="group/collapsible">
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<SidebarMenuButton data-testid={`menu-${item.title.toLowerCase()}`}>
|
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
<span>{item.title}</span>
|
|
||||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<SidebarMenuSub>
|
|
||||||
{item.items.map((subItem) => (
|
|
||||||
<SidebarMenuSubItem key={subItem.title}>
|
|
||||||
<SidebarMenuSubButton
|
|
||||||
asChild
|
|
||||||
isActive={location === subItem.url}
|
|
||||||
data-testid={`link-${subItem.title.toLowerCase().replace(/\s+/g, '-')}`}
|
|
||||||
>
|
|
||||||
<Link href={subItem.url!}>
|
|
||||||
<subItem.icon className="h-4 w-4" />
|
|
||||||
<span>{subItem.title}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenuSub>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu item semplice
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={item.title}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
asChild
|
|
||||||
isActive={location === item.url}
|
|
||||||
data-testid={`link-${item.title.toLowerCase().replace(/\s+/g, '-')}`}
|
|
||||||
>
|
|
||||||
<Link href={item.url!}>
|
|
||||||
<item.icon className="h-4 w-4" />
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@ -274,7 +148,20 @@ export function AppSidebar() {
|
|||||||
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
|
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{filteredItems.map(renderMenuItem)}
|
{filteredItems.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={location === item.url}
|
||||||
|
data-testid={`link-${item.title.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
<Link href={item.url}>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@ -1,616 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
||||||
import { Customer, InsertCustomer, insertCustomerSchema } from "@shared/schema";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Plus, Building2, Pencil, Trash2, Phone, Mail } from "lucide-react";
|
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
|
|
||||||
export default function Customers() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
||||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
|
||||||
const [deletingCustomerId, setDeletingCustomerId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: customers, isLoading } = useQuery<Customer[]>({
|
|
||||||
queryKey: ["/api/customers"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<InsertCustomer>({
|
|
||||||
resolver: zodResolver(insertCustomerSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
businessName: "",
|
|
||||||
vatNumber: "",
|
|
||||||
fiscalCode: "",
|
|
||||||
address: "",
|
|
||||||
city: "",
|
|
||||||
province: "",
|
|
||||||
zipCode: "",
|
|
||||||
phone: "",
|
|
||||||
email: "",
|
|
||||||
pec: "",
|
|
||||||
contactPerson: "",
|
|
||||||
notes: "",
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: async (data: InsertCustomer) => {
|
|
||||||
return await apiRequest("POST", "/api/customers", data);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/customers"] });
|
|
||||||
toast({
|
|
||||||
title: "Cliente creato",
|
|
||||||
description: "Il cliente è stato aggiunto con successo",
|
|
||||||
});
|
|
||||||
setIsCreateDialogOpen(false);
|
|
||||||
form.reset();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
title: "Errore",
|
|
||||||
description: error.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: async ({ id, data }: { id: string; data: InsertCustomer }) => {
|
|
||||||
return await apiRequest("PATCH", `/api/customers/${id}`, data);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/customers"] });
|
|
||||||
toast({
|
|
||||||
title: "Cliente aggiornato",
|
|
||||||
description: "I dati del cliente sono stati aggiornati",
|
|
||||||
});
|
|
||||||
setEditingCustomer(null);
|
|
||||||
form.reset();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
title: "Errore",
|
|
||||||
description: error.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
return await apiRequest("DELETE", `/api/customers/${id}`, undefined);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/customers"] });
|
|
||||||
toast({
|
|
||||||
title: "Cliente eliminato",
|
|
||||||
description: "Il cliente è stato eliminato con successo",
|
|
||||||
});
|
|
||||||
setDeletingCustomerId(null);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast({
|
|
||||||
title: "Errore",
|
|
||||||
description: error.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
setDeletingCustomerId(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (data: InsertCustomer) => {
|
|
||||||
if (editingCustomer) {
|
|
||||||
updateMutation.mutate({ id: editingCustomer.id, data });
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditDialog = (customer: Customer) => {
|
|
||||||
setEditingCustomer(customer);
|
|
||||||
form.reset({
|
|
||||||
name: customer.name || "",
|
|
||||||
businessName: customer.businessName || "",
|
|
||||||
vatNumber: customer.vatNumber || "",
|
|
||||||
fiscalCode: customer.fiscalCode || "",
|
|
||||||
address: customer.address || "",
|
|
||||||
city: customer.city || "",
|
|
||||||
province: customer.province || "",
|
|
||||||
zipCode: customer.zipCode || "",
|
|
||||||
phone: customer.phone || "",
|
|
||||||
email: customer.email || "",
|
|
||||||
pec: customer.pec || "",
|
|
||||||
contactPerson: customer.contactPerson || "",
|
|
||||||
notes: customer.notes || "",
|
|
||||||
isActive: customer.isActive ?? true,
|
|
||||||
});
|
|
||||||
setIsCreateDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogOpenChange = (open: boolean) => {
|
|
||||||
setIsCreateDialogOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
// Reset only on close
|
|
||||||
setEditingCustomer(null);
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Skeleton className="h-12 w-full" />
|
|
||||||
<Skeleton className="h-96 w-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
|
|
||||||
Anagrafica Clienti
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Gestione anagrafica clienti e contratti
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Dialog open={isCreateDialogOpen} onOpenChange={handleDialogOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button data-testid="button-create-customer">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Nuovo Cliente
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{editingCustomer ? "Modifica Cliente" : "Nuovo Cliente"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Inserisci i dati anagrafici del cliente
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Nome Cliente *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="es. Banca Centrale Roma"
|
|
||||||
data-testid="input-name"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="businessName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Ragione Sociale</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="es. Banca Centrale S.p.A."
|
|
||||||
data-testid="input-business-name"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="vatNumber"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Partita IVA</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="12345678901"
|
|
||||||
data-testid="input-vat-number"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="fiscalCode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Codice Fiscale</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="CF cliente"
|
|
||||||
data-testid="input-fiscal-code"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="address"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Indirizzo</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Via, numero civico"
|
|
||||||
data-testid="input-address"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="city"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Città</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Roma"
|
|
||||||
data-testid="input-city"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="province"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Provincia</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="RM"
|
|
||||||
maxLength={2}
|
|
||||||
data-testid="input-province"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="zipCode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>CAP</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="00100"
|
|
||||||
data-testid="input-zip-code"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="phone"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Telefono</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="+39 06 1234567"
|
|
||||||
data-testid="input-phone"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="info@cliente.it"
|
|
||||||
data-testid="input-email"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="pec"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>PEC</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="pec@cliente.it"
|
|
||||||
data-testid="input-pec"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="contactPerson"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Referente</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Nome e cognome referente"
|
|
||||||
data-testid="input-contact-person"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="notes"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Note</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Note aggiuntive sul cliente"
|
|
||||||
data-testid="input-notes"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="isActive"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center gap-2 space-y-0 col-span-2">
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value ?? true}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
data-testid="switch-is-active"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="!mt-0">Cliente Attivo</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleDialogOpenChange(false)}
|
|
||||||
data-testid="button-cancel"
|
|
||||||
>
|
|
||||||
Annulla
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={createMutation.isPending || updateMutation.isPending}
|
|
||||||
data-testid="button-submit"
|
|
||||||
>
|
|
||||||
{editingCustomer ? "Aggiorna" : "Crea"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customers Table */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Lista Clienti</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{customers?.length || 0} clienti registrati
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Nome</TableHead>
|
|
||||||
<TableHead>Ragione Sociale</TableHead>
|
|
||||||
<TableHead>Città</TableHead>
|
|
||||||
<TableHead>Referente</TableHead>
|
|
||||||
<TableHead>Contatti</TableHead>
|
|
||||||
<TableHead>Stato</TableHead>
|
|
||||||
<TableHead className="text-right">Azioni</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{customers?.map((customer) => (
|
|
||||||
<TableRow key={customer.id} data-testid={`row-customer-${customer.id}`}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
|
||||||
{customer.name}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
|
||||||
{customer.businessName || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{customer.city ? `${customer.city} (${customer.province})` : "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{customer.contactPerson || "-"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-col gap-1 text-sm">
|
|
||||||
{customer.phone && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Phone className="h-3 w-3" />
|
|
||||||
{customer.phone}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{customer.email && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Mail className="h-3 w-3" />
|
|
||||||
{customer.email}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={customer.isActive ? "default" : "secondary"}>
|
|
||||||
{customer.isActive ? "Attivo" : "Inattivo"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => openEditDialog(customer)}
|
|
||||||
data-testid={`button-edit-${customer.id}`}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setDeletingCustomerId(customer.id)}
|
|
||||||
data-testid={`button-delete-${customer.id}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{(!customers || customers.length === 0) && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
|
||||||
Nessun cliente registrato
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<AlertDialog open={!!deletingCustomerId} onOpenChange={() => setDeletingCustomerId(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Conferma eliminazione</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Sei sicuro di voler eliminare questo cliente? L'operazione non può essere annullata.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel data-testid="button-cancel-delete">Annulla</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => deletingCustomerId && deleteMutation.mutate(deletingCustomerId)}
|
|
||||||
data-testid="button-confirm-delete"
|
|
||||||
className="bg-destructive hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Elimina
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { format, startOfWeek, addWeeks } from "date-fns";
|
import { format, startOfWeek, addWeeks } from "date-fns";
|
||||||
import { it } from "date-fns/locale";
|
import { it } from "date-fns/locale";
|
||||||
@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock, Copy, Circle } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock } 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 {
|
||||||
@ -19,16 +19,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { GuardAvailability, Vehicle as VehicleDb } from "@shared/schema";
|
import type { GuardAvailability, Vehicle as VehicleDb } from "@shared/schema";
|
||||||
@ -54,9 +44,6 @@ interface SiteData {
|
|||||||
siteId: string;
|
siteId: string;
|
||||||
siteName: string;
|
siteName: string;
|
||||||
serviceType: string;
|
serviceType: string;
|
||||||
serviceStartTime: string;
|
|
||||||
serviceEndTime: string;
|
|
||||||
serviceHours: number;
|
|
||||||
minGuards: number;
|
minGuards: number;
|
||||||
guards: GuardWithHours[];
|
guards: GuardWithHours[];
|
||||||
vehicles: Vehicle[];
|
vehicles: Vehicle[];
|
||||||
@ -85,14 +72,14 @@ interface GeneralPlanningResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper per formattare orario in formato italiano 24h (HH:MM)
|
// Helper per formattare orario in formato italiano 24h (HH:MM)
|
||||||
// IMPORTANTE: Gli orari nel DB sono UTC, visualizzali in timezone Europe/Rome
|
// IMPORTANTE: usa timeZone UTC per evitare shift di +2 ore
|
||||||
const formatTime = (dateString: string) => {
|
const 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: "Europe/Rome" // Converti da UTC a Italy time
|
timeZone: "UTC" // Evita conversione timezone locale (+2h in Italia)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -116,8 +103,6 @@ export default function GeneralPlanning() {
|
|||||||
const [durationHours, setDurationHours] = useState<number>(8);
|
const [durationHours, setDurationHours] = useState<number>(8);
|
||||||
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 [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>({
|
||||||
@ -176,28 +161,14 @@ export default function GeneralPlanning() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calcola dati aggiornati della cella selezionata (per auto-refresh dialog)
|
|
||||||
const currentCellData = (() => {
|
|
||||||
if (!selectedCell || !planningData) return selectedCell?.data;
|
|
||||||
|
|
||||||
// Trova i dati freschi da planningData
|
|
||||||
const day = planningData.days.find(d => d.date === selectedCell.date);
|
|
||||||
if (!day) return selectedCell.data;
|
|
||||||
|
|
||||||
const updatedSite = day.sites.find(s => s.siteId === selectedCell.siteId);
|
|
||||||
return updatedSite || selectedCell.data;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Mutation per eliminare assegnazione guardia
|
// Mutation per eliminare assegnazione guardia
|
||||||
const deleteAssignmentMutation = useMutation({
|
const deleteAssignmentMutation = useMutation({
|
||||||
mutationFn: async (assignmentId: string) => {
|
mutationFn: async (assignmentId: string) => {
|
||||||
return apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`, undefined);
|
return apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`, undefined);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: () => {
|
||||||
// Invalida e refetch planning generale per aggiornare dialog
|
queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["/api/general-planning"] });
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Guardia rimossa",
|
title: "Guardia rimossa",
|
||||||
@ -215,7 +186,7 @@ export default function GeneralPlanning() {
|
|||||||
|
|
||||||
// Mutation per assegnare guardia con orari (anche multi-giorno)
|
// Mutation per assegnare guardia con orari (anche multi-giorno)
|
||||||
const assignGuardMutation = useMutation({
|
const assignGuardMutation = useMutation({
|
||||||
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number; vehicleId?: string; force?: boolean }) => {
|
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number; vehicleId?: string }) => {
|
||||||
return apiRequest("POST", "/api/general-planning/assign-guard", data);
|
return apiRequest("POST", "/api/general-planning/assign-guard", data);
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@ -235,33 +206,19 @@ export default function GeneralPlanning() {
|
|||||||
// Reset form (NON chiudere dialog per vedere lista aggiornata)
|
// Reset form (NON chiudere dialog per vedere lista aggiornata)
|
||||||
setSelectedGuardId("");
|
setSelectedGuardId("");
|
||||||
setSelectedVehicleId("");
|
setSelectedVehicleId("");
|
||||||
setCcnlConfirmation(null); // Reset dialog conferma se aperto
|
|
||||||
},
|
},
|
||||||
onError: (error: any, variables) => {
|
onError: (error: any) => {
|
||||||
// Parse error message from API response
|
// Parse error message from API response
|
||||||
let errorMessage = "Impossibile assegnare la guardia";
|
let errorMessage = "Impossibile assegnare la guardia";
|
||||||
let errorType = "";
|
|
||||||
|
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
// Error format from apiRequest: "STATUS_CODE: {json_body}"
|
// Error format from apiRequest: "STATUS_CODE: {json_body}"
|
||||||
const match = error.message.match(/^(\d+):\s*(.+)$/);
|
const match = error.message.match(/^\d+:\s*(.+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const statusCode = match[1];
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(match[2]);
|
const parsed = JSON.parse(match[1]);
|
||||||
errorMessage = parsed.message || errorMessage;
|
errorMessage = parsed.message || errorMessage;
|
||||||
errorType = parsed.type || "";
|
|
||||||
|
|
||||||
// Se è un errore CCNL (409 con tipo CCNL_VIOLATION), mostra dialog conferma
|
|
||||||
if (statusCode === "409" && errorType === "CCNL_VIOLATION") {
|
|
||||||
setCcnlConfirmation({
|
|
||||||
message: errorMessage,
|
|
||||||
data: variables
|
|
||||||
});
|
|
||||||
return; // Non mostrare toast, mostra dialog
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = match[2];
|
errorMessage = match[1];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
@ -276,54 +233,6 @@ export default function GeneralPlanning() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutation per copiare turni settimanali
|
|
||||||
const copyWeekMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
return apiRequest("POST", "/api/shift-assignments/copy-week", {
|
|
||||||
weekStart: format(weekStart, "yyyy-MM-dd"),
|
|
||||||
location: selectedLocation,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async (response: any) => {
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Settimana copiata!",
|
|
||||||
description: `${data.copiedShifts} turni e ${data.copiedAssignments} assegnazioni copiate nella settimana successiva`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalida cache e naviga alla settimana successiva
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
|
|
||||||
setWeekStart(addWeeks(weekStart, 1)); // Naviga alla settimana copiata
|
|
||||||
setShowCopyWeekConfirmation(false);
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
let errorMessage = "Impossibile copiare la settimana";
|
|
||||||
|
|
||||||
if (error.message) {
|
|
||||||
const match = error.message.match(/^(\d+):\s*(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(match[2]);
|
|
||||||
errorMessage = parsed.message || errorMessage;
|
|
||||||
} catch {
|
|
||||||
errorMessage = match[2];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errorMessage = error.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Errore Copia Settimana",
|
|
||||||
description: errorMessage,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
|
|
||||||
setShowCopyWeekConfirmation(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handler per submit form assegnazione guardia
|
// Handler per submit form assegnazione guardia
|
||||||
const handleAssignGuard = () => {
|
const handleAssignGuard = () => {
|
||||||
if (!selectedCell || !selectedGuardId) return;
|
if (!selectedCell || !selectedGuardId) return;
|
||||||
@ -379,7 +288,7 @@ export default function GeneralPlanning() {
|
|||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
|
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
|
||||||
Planning Fissi
|
Planning Generale
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Vista settimanale turni con calcolo automatico guardie mancanti
|
Vista settimanale turni con calcolo automatico guardie mancanti
|
||||||
@ -407,7 +316,7 @@ export default function GeneralPlanning() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigazione settimana */}
|
{/* Navigazione settimana */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -434,16 +343,6 @@ 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 */}
|
||||||
@ -717,19 +616,19 @@ export default function GeneralPlanning() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Select guardia (tutte, evidenziate in rosso se impegnate) */}
|
{/* Select guardia disponibile */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Mostra TUTTE le guardie, ma filtra solo per ore ordinarie/straordinario
|
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
|
||||||
const filteredGuards = availableGuards?.filter(g =>
|
const filteredGuards = availableGuards?.filter(g =>
|
||||||
showOvertimeGuards || !g.requiresOvertime
|
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime) || false;
|
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime && g.isAvailable) || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="guard-select">Guardia</Label>
|
<Label htmlFor="guard-select">Guardia Disponibile</Label>
|
||||||
{!isLoadingGuards && hasOvertimeGuards && (
|
{!isLoadingGuards && hasOvertimeGuards && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -758,20 +657,15 @@ export default function GeneralPlanning() {
|
|||||||
{filteredGuards.length > 0 ? (
|
{filteredGuards.length > 0 ? (
|
||||||
filteredGuards.map((guard) => (
|
filteredGuards.map((guard) => (
|
||||||
<SelectItem key={guard.guardId} value={guard.guardId}>
|
<SelectItem key={guard.guardId} value={guard.guardId}>
|
||||||
<div className={`flex items-center gap-1.5 ${guard.isAvailable ? "" : "text-destructive font-medium"}`}>
|
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
|
||||||
{!guard.isAvailable && <Circle className="h-3 w-3 fill-current" />}
|
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
|
||||||
<span>
|
{guard.requiresOvertime && " 🔸"}
|
||||||
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
|
|
||||||
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
|
|
||||||
{guard.requiresOvertime && " 🔸"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<SelectItem value="no-guards" disabled>
|
<SelectItem value="no-guards" disabled>
|
||||||
{showOvertimeGuards
|
{showOvertimeGuards
|
||||||
? "Nessuna guardia"
|
? "Nessuna guardia disponibile"
|
||||||
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
|
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
@ -779,7 +673,7 @@ export default function GeneralPlanning() {
|
|||||||
</Select>
|
</Select>
|
||||||
{filteredGuards.length === 0 && !showOvertimeGuards && hasOvertimeGuards && (
|
{filteredGuards.length === 0 && !showOvertimeGuards && hasOvertimeGuards && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
ℹ️ Alcune guardie richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
|
ℹ️ Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{filteredGuards.length > 0 && selectedGuardId && (
|
{filteredGuards.length > 0 && selectedGuardId && (
|
||||||
@ -881,14 +775,14 @@ export default function GeneralPlanning() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guardie già assegnate - fuori dal form box per evitare di nascondere il form */}
|
{/* Guardie già assegnate - fuori dal form box per evitare di nascondere il form */}
|
||||||
{currentCellData && currentCellData.guards.length > 0 && (
|
{selectedCell.data.guards.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
Guardie Già Assegnate ({currentCellData.guards.length})
|
Guardie Già Assegnate ({selectedCell.data.guards.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{currentCellData.guards.map((guard, idx) => (
|
{selectedCell.data.guards.map((guard, idx) => (
|
||||||
<div key={idx} className="flex items-start justify-between gap-2 bg-muted/30 p-2.5 rounded border">
|
<div key={idx} className="flex items-start justify-between gap-2 bg-muted/30 p-2.5 rounded border">
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -926,61 +820,43 @@ export default function GeneralPlanning() {
|
|||||||
|
|
||||||
{/* Info turni esistenti */}
|
{/* Info turni esistenti */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="font-semibold text-sm">Informazioni Servizio</h3>
|
<h3 className="font-semibold text-sm">Situazione Attuale</h3>
|
||||||
|
|
||||||
{/* Tipo servizio e orario */}
|
|
||||||
{currentCellData && (
|
|
||||||
<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 Servizio</span>
|
|
||||||
<Badge variant="outline">{currentCellData.serviceType}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Orario Servizio</span>
|
|
||||||
<span className="text-sm font-medium">{currentCellData.serviceStartTime} - {currentCellData.serviceEndTime}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Ore Richieste</span>
|
|
||||||
<span className="text-sm font-bold">{currentCellData.serviceHours}h</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Turni Pianificati</p>
|
<p className="text-sm text-muted-foreground">Turni Pianificati</p>
|
||||||
<p className="text-2xl font-bold">{currentCellData?.shiftsCount || 0}</p>
|
<p className="text-2xl font-bold">{selectedCell.data.shiftsCount}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Ore Assegnate</p>
|
<p className="text-sm text-muted-foreground">Ore Totali</p>
|
||||||
<p className="text-2xl font-bold">{currentCellData?.totalShiftHours || 0}h</p>
|
<p className="text-2xl font-bold">{selectedCell.data.totalShiftHours}h</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guardie mancanti */}
|
{/* Guardie mancanti */}
|
||||||
{currentCellData && currentCellData.missingGuards > 0 && (
|
{selectedCell.data.missingGuards > 0 && (
|
||||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
<div className="flex items-center gap-2 text-destructive font-semibold mb-2">
|
<div className="flex items-center gap-2 text-destructive font-semibold mb-2">
|
||||||
<AlertTriangle className="h-5 w-5" />
|
<AlertTriangle className="h-5 w-5" />
|
||||||
Attenzione: Guardie Mancanti
|
Attenzione: Guardie Mancanti
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Servono ancora <span className="font-bold">{currentCellData.missingGuards}</span>{" "}
|
Servono ancora <span className="font-bold">{selectedCell.data.missingGuards}</span>{" "}
|
||||||
{currentCellData.missingGuards === 1 ? "guardia" : "guardie"} per coprire completamente il servizio
|
{selectedCell.data.missingGuards === 1 ? "guardia" : "guardie"} per coprire completamente il servizio
|
||||||
(calcolato su {currentCellData.totalShiftHours}h con max 9h per guardia e {currentCellData.minGuards} {currentCellData.minGuards === 1 ? "guardia minima" : "guardie minime"} contemporanee)
|
(calcolato su {selectedCell.data.totalShiftHours}h con max 9h per guardia e {selectedCell.data.minGuards} {selectedCell.data.minGuards === 1 ? "guardia minima" : "guardie minime"} contemporanee)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Veicoli */}
|
{/* Veicoli */}
|
||||||
{currentCellData && currentCellData.vehicles.length > 0 && (
|
{selectedCell.data.vehicles.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<Car className="h-4 w-4" />
|
<Car className="h-4 w-4" />
|
||||||
Veicoli Assegnati ({currentCellData.vehicles.length})
|
Veicoli Assegnati ({selectedCell.data.vehicles.length})
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{currentCellData.vehicles.map((vehicle, idx) => (
|
{selectedCell.data.vehicles.map((vehicle, idx) => (
|
||||||
<div key={idx} className="flex items-center gap-2 p-2 bg-accent/10 rounded-md">
|
<div key={idx} className="flex items-center gap-2 p-2 bg-accent/10 rounded-md">
|
||||||
<p className="font-medium">{vehicle.licensePlate}</p>
|
<p className="font-medium">{vehicle.licensePlate}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@ -1015,97 +891,6 @@ export default function GeneralPlanning() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Dialog conferma forzatura CCNL */}
|
|
||||||
<AlertDialog open={!!ccnlConfirmation} onOpenChange={() => setCcnlConfirmation(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
|
||||||
Superamento Limite CCNL
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription className="space-y-2">
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
{ccnlConfirmation?.message}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Vuoi forzare comunque l'assegnazione? L'operazione verrà registrata e potrai consultarla nei report.
|
|
||||||
</p>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel data-testid="button-cancel-force">Annulla</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => {
|
|
||||||
if (ccnlConfirmation) {
|
|
||||||
assignGuardMutation.mutate({
|
|
||||||
...ccnlConfirmation.data,
|
|
||||||
force: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-testid="button-confirm-force"
|
|
||||||
className="bg-yellow-600 hover:bg-yellow-700"
|
|
||||||
>
|
|
||||||
Forza Assegnazione
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
{/* Dialog conferma copia settimana */}
|
|
||||||
<AlertDialog open={showCopyWeekConfirmation} onOpenChange={setShowCopyWeekConfirmation}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
|
||||||
<Copy className="h-5 w-5" />
|
|
||||||
Copia Turno Settimanale
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription asChild>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-foreground font-medium">
|
|
||||||
Vuoi copiare tutti i turni della settimana corrente nella settimana successiva?
|
|
||||||
</p>
|
|
||||||
{planningData && (
|
|
||||||
<div className="space-y-2 bg-muted/30 p-3 rounded-md">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Settimana corrente:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{format(new Date(planningData.weekStart), "dd MMM", { locale: it })} -{" "}
|
|
||||||
{format(new Date(planningData.weekEnd), "dd MMM yyyy", { locale: it })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Verrà copiata in:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{format(addWeeks(new Date(planningData.weekStart), 1), "dd MMM", { locale: it })} -{" "}
|
|
||||||
{format(addWeeks(new Date(planningData.weekEnd), 1), "dd MMM yyyy", { locale: it })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Sede:</span>
|
|
||||||
<span className="font-medium">{formatLocation(selectedLocation)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Tutti i turni e le assegnazioni guardie verranno duplicati con le stesse caratteristiche (orari, dotazioni, veicoli).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel data-testid="button-cancel-copy-week">Annulla</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => copyWeekMutation.mutate()}
|
|
||||||
data-testid="button-confirm-copy-week"
|
|
||||||
disabled={copyWeekMutation.isPending}
|
|
||||||
>
|
|
||||||
{copyWeekMutation.isPending ? "Copia in corso..." : "Conferma Copia"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,244 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Calendar, MapPin, Clock, Shield, Car, ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
|
|
||||||
import { it } from "date-fns/locale";
|
|
||||||
|
|
||||||
interface ShiftAssignment {
|
|
||||||
id: string;
|
|
||||||
shiftId: string;
|
|
||||||
plannedStartTime: string;
|
|
||||||
plannedEndTime: string;
|
|
||||||
armed: boolean;
|
|
||||||
vehicleId: string | null;
|
|
||||||
vehiclePlate: string | null;
|
|
||||||
site: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
location: string;
|
|
||||||
};
|
|
||||||
shift: {
|
|
||||||
shiftDate: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MyShiftsFixed() {
|
|
||||||
// Data iniziale: inizio settimana corrente
|
|
||||||
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
|
||||||
const today = new Date();
|
|
||||||
return startOfWeek(today, { weekStartsOn: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query per recuperare i turni fissi della guardia loggata
|
|
||||||
const { data: user } = useQuery<any>({
|
|
||||||
queryKey: ["/api/auth/user"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: myShifts, isLoading } = useQuery<ShiftAssignment[]>({
|
|
||||||
queryKey: ["/api/my-shifts/fixed", currentWeekStart.toISOString()],
|
|
||||||
queryFn: async () => {
|
|
||||||
const weekEnd = addDays(currentWeekStart, 6);
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/my-shifts/fixed?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch shifts");
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
enabled: !!user,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Naviga settimana precedente
|
|
||||||
const handlePreviousWeek = () => {
|
|
||||||
setCurrentWeekStart(addDays(currentWeekStart, -7));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Naviga settimana successiva
|
|
||||||
const handleNextWeek = () => {
|
|
||||||
setCurrentWeekStart(addDays(currentWeekStart, 7));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Raggruppa i turni per giorno
|
|
||||||
const shiftsByDay = myShifts?.reduce((acc, shift) => {
|
|
||||||
const date = shift.shift.shiftDate;
|
|
||||||
if (!acc[date]) acc[date] = [];
|
|
||||||
acc[date].push(shift);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, ShiftAssignment[]>) || {};
|
|
||||||
|
|
||||||
// Genera array di 7 giorni della settimana
|
|
||||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
|
|
||||||
|
|
||||||
const locationLabels: Record<string, string> = {
|
|
||||||
roccapiemonte: "Roccapiemonte",
|
|
||||||
milano: "Milano",
|
|
||||||
roma: "Roma",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col p-6 space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold" data-testid="title-my-shifts-fixed">
|
|
||||||
I Miei Turni Fissi
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Visualizza i tuoi turni con orari e dotazioni operative
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigazione settimana */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePreviousWeek}
|
|
||||||
data-testid="button-prev-week"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
||||||
Settimana Precedente
|
|
||||||
</Button>
|
|
||||||
<CardTitle className="text-center">
|
|
||||||
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
|
|
||||||
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
|
|
||||||
</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleNextWeek}
|
|
||||||
data-testid="button-next-week"
|
|
||||||
>
|
|
||||||
Settimana Successiva
|
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{isLoading && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
|
||||||
Caricamento turni...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Griglia giorni settimana */}
|
|
||||||
{!isLoading && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{weekDays.map((day) => {
|
|
||||||
const dateStr = format(day, "yyyy-MM-dd");
|
|
||||||
const dayShifts = shiftsByDay[dateStr] || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-lg">
|
|
||||||
{format(day, "EEEE dd/MM", { locale: it })}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{dayShifts.length === 0
|
|
||||||
? "Nessun turno"
|
|
||||||
: `${dayShifts.length} turno${dayShifts.length > 1 ? "i" : ""}`}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{dayShifts.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
|
||||||
Riposo
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
dayShifts.map((shift) => {
|
|
||||||
// Parsing sicuro orari (DB in UTC → visualizza in Europe/Rome)
|
|
||||||
let startTime = "N/A";
|
|
||||||
let endTime = "N/A";
|
|
||||||
|
|
||||||
if (shift.plannedStartTime) {
|
|
||||||
const parsedStart = new Date(shift.plannedStartTime);
|
|
||||||
if (isValid(parsedStart)) {
|
|
||||||
startTime = parsedStart.toLocaleTimeString("it-IT", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
timeZone: "Europe/Rome"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shift.plannedEndTime) {
|
|
||||||
const parsedEnd = new Date(shift.plannedEndTime);
|
|
||||||
if (isValid(parsedEnd)) {
|
|
||||||
endTime = parsedEnd.toLocaleTimeString("it-IT", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
timeZone: "Europe/Rome"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={shift.id}
|
|
||||||
className="border rounded-lg p-3 space-y-2"
|
|
||||||
data-testid={`shift-${shift.id}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<p className="font-semibold text-sm">{shift.site.name}</p>
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<MapPin className="h-3 w-3" />
|
|
||||||
<span>{locationLabels[shift.site.location] || shift.site.location}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="font-medium">
|
|
||||||
{startTime} - {endTime}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dotazioni */}
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{shift.armed && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<Shield className="h-3 w-3 mr-1" />
|
|
||||||
Armato
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{shift.vehicleId && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<Car className="h-3 w-3 mr-1" />
|
|
||||||
{shift.vehiclePlate || "Automezzo"}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-1 border-t text-xs text-muted-foreground">
|
|
||||||
{shift.site.address}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,247 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Calendar, MapPin, Navigation, ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
|
|
||||||
import { it } from "date-fns/locale";
|
|
||||||
|
|
||||||
interface PatrolRouteStop {
|
|
||||||
siteId: string;
|
|
||||||
siteName: string;
|
|
||||||
siteAddress: string;
|
|
||||||
sequenceOrder: number;
|
|
||||||
latitude: string | null;
|
|
||||||
longitude: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PatrolRoute {
|
|
||||||
id: string;
|
|
||||||
shiftDate: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
location: string;
|
|
||||||
status: string;
|
|
||||||
vehicleId: string | null;
|
|
||||||
vehiclePlate: string | null;
|
|
||||||
stops: PatrolRouteStop[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MyShiftsMobile() {
|
|
||||||
// Data iniziale: inizio settimana corrente
|
|
||||||
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
|
||||||
const today = new Date();
|
|
||||||
return startOfWeek(today, { weekStartsOn: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query per recuperare i turni mobile della guardia loggata
|
|
||||||
const { data: user } = useQuery<any>({
|
|
||||||
queryKey: ["/api/auth/user"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: myRoutes, isLoading } = useQuery<PatrolRoute[]>({
|
|
||||||
queryKey: ["/api/my-shifts/mobile", currentWeekStart.toISOString()],
|
|
||||||
queryFn: async () => {
|
|
||||||
const weekEnd = addDays(currentWeekStart, 6);
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/my-shifts/mobile?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch patrol routes");
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
enabled: !!user,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Naviga settimana precedente
|
|
||||||
const handlePreviousWeek = () => {
|
|
||||||
setCurrentWeekStart(addDays(currentWeekStart, -7));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Naviga settimana successiva
|
|
||||||
const handleNextWeek = () => {
|
|
||||||
setCurrentWeekStart(addDays(currentWeekStart, 7));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Raggruppa i patrol routes per giorno
|
|
||||||
const routesByDay = myRoutes?.reduce((acc, route) => {
|
|
||||||
const date = route.shiftDate;
|
|
||||||
if (!acc[date]) acc[date] = [];
|
|
||||||
acc[date].push(route);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, PatrolRoute[]>) || {};
|
|
||||||
|
|
||||||
// Genera array di 7 giorni della settimana
|
|
||||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
|
|
||||||
|
|
||||||
const locationLabels: Record<string, string> = {
|
|
||||||
roccapiemonte: "Roccapiemonte",
|
|
||||||
milano: "Milano",
|
|
||||||
roma: "Roma",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
planned: "Pianificato",
|
|
||||||
in_progress: "In Corso",
|
|
||||||
completed: "Completato",
|
|
||||||
cancelled: "Annullato",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
planned: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
|
||||||
in_progress: "bg-green-500/10 text-green-500 border-green-500/20",
|
|
||||||
completed: "bg-gray-500/10 text-gray-500 border-gray-500/20",
|
|
||||||
cancelled: "bg-red-500/10 text-red-500 border-red-500/20",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col p-6 space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold" data-testid="title-my-shifts-mobile">
|
|
||||||
I Miei Turni Pattuglia
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Visualizza i tuoi percorsi di pattuglia con sequenza tappe
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigazione settimana */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePreviousWeek}
|
|
||||||
data-testid="button-prev-week"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
||||||
Settimana Precedente
|
|
||||||
</Button>
|
|
||||||
<CardTitle className="text-center">
|
|
||||||
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
|
|
||||||
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
|
|
||||||
</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleNextWeek}
|
|
||||||
data-testid="button-next-week"
|
|
||||||
>
|
|
||||||
Settimana Successiva
|
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{isLoading && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
|
||||||
Caricamento turni pattuglia...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Griglia giorni settimana */}
|
|
||||||
{!isLoading && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{weekDays.map((day) => {
|
|
||||||
const dateStr = format(day, "yyyy-MM-dd");
|
|
||||||
const dayRoutes = routesByDay[dateStr] || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-lg">
|
|
||||||
{format(day, "EEEE dd/MM", { locale: it })}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{dayRoutes.length === 0
|
|
||||||
? "Nessuna pattuglia"
|
|
||||||
: `${dayRoutes.length} pattuglia${dayRoutes.length > 1 ? "e" : ""}`}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{dayRoutes.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
|
||||||
Riposo
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
dayRoutes.map((route) => (
|
|
||||||
<div
|
|
||||||
key={route.id}
|
|
||||||
className="border rounded-lg p-3 space-y-3"
|
|
||||||
data-testid={`patrol-route-${route.id}`}
|
|
||||||
>
|
|
||||||
{/* Header pattuglia */}
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Navigation className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="font-semibold text-sm">
|
|
||||||
Pattuglia {locationLabels[route.location]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<MapPin className="h-3 w-3" />
|
|
||||||
<span>{route.stops.length} tappe</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={statusColors[route.status] || ""}
|
|
||||||
>
|
|
||||||
{statusLabels[route.status] || route.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sequenza tappe */}
|
|
||||||
<div className="space-y-2 pl-4 border-l-2 border-muted">
|
|
||||||
{route.stops
|
|
||||||
.sort((a, b) => a.sequenceOrder - b.sequenceOrder)
|
|
||||||
.map((stop, index) => (
|
|
||||||
<div
|
|
||||||
key={stop.siteId}
|
|
||||||
className="space-y-1"
|
|
||||||
data-testid={`stop-${index}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Badge className="bg-green-600 h-5 w-5 p-0 flex items-center justify-center text-xs">
|
|
||||||
{stop.sequenceOrder}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex-1 space-y-0.5">
|
|
||||||
<p className="text-sm font-medium leading-tight">
|
|
||||||
{stop.siteName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground leading-tight">
|
|
||||||
{stop.siteAddress}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info veicolo */}
|
|
||||||
{route.vehiclePlate && (
|
|
||||||
<div className="pt-2 border-t text-xs text-muted-foreground">
|
|
||||||
Automezzo: {route.vehiclePlate}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -35,30 +35,6 @@ interface SiteReport {
|
|||||||
totalShifts: number;
|
totalShifts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomerReport {
|
|
||||||
customerId: string;
|
|
||||||
customerName: string;
|
|
||||||
sites: {
|
|
||||||
siteId: string;
|
|
||||||
siteName: string;
|
|
||||||
serviceTypes: {
|
|
||||||
name: string;
|
|
||||||
hours: number;
|
|
||||||
shifts: number;
|
|
||||||
passages: number;
|
|
||||||
inspections: number;
|
|
||||||
interventions: number;
|
|
||||||
}[];
|
|
||||||
totalHours: number;
|
|
||||||
totalShifts: number;
|
|
||||||
}[];
|
|
||||||
totalHours: number;
|
|
||||||
totalShifts: number;
|
|
||||||
totalPatrolPassages: number;
|
|
||||||
totalInspections: number;
|
|
||||||
totalInterventions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Reports() {
|
export default function Reports() {
|
||||||
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||||
const [selectedMonth, setSelectedMonth] = useState<string>(format(new Date(), "yyyy-MM"));
|
const [selectedMonth, setSelectedMonth] = useState<string>(format(new Date(), "yyyy-MM"));
|
||||||
@ -103,28 +79,6 @@ export default function Reports() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query per report clienti
|
|
||||||
const { data: customerReport, isLoading: isLoadingCustomers } = useQuery<{
|
|
||||||
month: string;
|
|
||||||
location: string;
|
|
||||||
customers: CustomerReport[];
|
|
||||||
summary: {
|
|
||||||
totalCustomers: number;
|
|
||||||
totalHours: number;
|
|
||||||
totalShifts: number;
|
|
||||||
totalPatrolPassages: number;
|
|
||||||
totalInspections: number;
|
|
||||||
totalInterventions: number;
|
|
||||||
};
|
|
||||||
}>({
|
|
||||||
queryKey: ["/api/reports/customer-billing", selectedMonth, selectedLocation],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await fetch(`/api/reports/customer-billing?month=${selectedMonth}&location=${selectedLocation}`);
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch customer report");
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Genera mesi disponibili (ultimi 12 mesi)
|
// Genera mesi disponibili (ultimi 12 mesi)
|
||||||
const availableMonths = Array.from({ length: 12 }, (_, i) => {
|
const availableMonths = Array.from({ length: 12 }, (_, i) => {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
@ -170,28 +124,6 @@ export default function Reports() {
|
|||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export CSV clienti
|
|
||||||
const exportCustomersCSV = () => {
|
|
||||||
if (!customerReport?.customers) return;
|
|
||||||
|
|
||||||
const headers = "Cliente,Sito,Tipologia Servizio,Ore,Turni,Passaggi,Ispezioni,Interventi\n";
|
|
||||||
const rows = customerReport.customers.flatMap(c =>
|
|
||||||
c.sites.flatMap(s =>
|
|
||||||
s.serviceTypes.map(st =>
|
|
||||||
`"${c.customerName}","${s.siteName}","${st.name}",${st.hours},${st.shifts},${st.passages},${st.inspections},${st.interventions}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).join("\n");
|
|
||||||
|
|
||||||
const csv = headers + rows;
|
|
||||||
const blob = new Blob([csv], { type: "text/csv" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `fatturazione_clienti_${selectedMonth}_${selectedLocation}.csv`;
|
|
||||||
a.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto p-6 space-y-6">
|
<div className="h-full overflow-auto p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -245,7 +177,7 @@ export default function Reports() {
|
|||||||
|
|
||||||
{/* Tabs Report */}
|
{/* Tabs Report */}
|
||||||
<Tabs defaultValue="guards" className="space-y-6">
|
<Tabs defaultValue="guards" className="space-y-6">
|
||||||
<TabsList className="grid w-full max-w-[600px] grid-cols-3">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="guards" data-testid="tab-guard-report">
|
<TabsTrigger value="guards" data-testid="tab-guard-report">
|
||||||
<Users className="h-4 w-4 mr-2" />
|
<Users className="h-4 w-4 mr-2" />
|
||||||
Report Guardie
|
Report Guardie
|
||||||
@ -254,10 +186,6 @@ export default function Reports() {
|
|||||||
<Building2 className="h-4 w-4 mr-2" />
|
<Building2 className="h-4 w-4 mr-2" />
|
||||||
Report Siti
|
Report Siti
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="customers" data-testid="tab-customer-report">
|
|
||||||
<Building2 className="h-4 w-4 mr-2" />
|
|
||||||
Report Clienti
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Tab Report Guardie */}
|
{/* Tab Report Guardie */}
|
||||||
@ -465,154 +393,6 @@ export default function Reports() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tab Report Clienti */}
|
|
||||||
<TabsContent value="customers" className="space-y-4">
|
|
||||||
{isLoadingCustomers ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Skeleton className="h-32 w-full" />
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</div>
|
|
||||||
) : customerReport ? (
|
|
||||||
<>
|
|
||||||
{/* Summary cards */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-5">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription className="flex items-center gap-2">
|
|
||||||
<Building2 className="h-4 w-4" />
|
|
||||||
Clienti
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalCustomers}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription className="flex items-center gap-2">
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
Ore Totali
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalHours}h</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription>Passaggi</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalPatrolPassages}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription>Ispezioni</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalInspections}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardDescription>Interventi</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-2xl font-semibold">{customerReport.summary.totalInterventions}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabella clienti */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Fatturazione per Cliente</CardTitle>
|
|
||||||
<CardDescription>Dettaglio siti e servizi erogati</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button onClick={exportCustomersCSV} data-testid="button-export-customers">
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{customerReport.customers.length > 0 ? (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{customerReport.customers.map((customer) => (
|
|
||||||
<div key={customer.customerId} className="border-2 rounded-lg p-4" data-testid={`customer-report-${customer.customerId}`}>
|
|
||||||
{/* Header Cliente */}
|
|
||||||
<div className="flex items-center justify-between mb-4 pb-3 border-b">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-xl">{customer.customerName}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">{customer.sites.length} siti attivi</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="default" className="text-base px-3 py-1">{customer.totalHours}h totali</Badge>
|
|
||||||
<Badge variant="outline">{customer.totalShifts} turni</Badge>
|
|
||||||
{customer.totalPatrolPassages > 0 && (
|
|
||||||
<Badge variant="secondary">{customer.totalPatrolPassages} passaggi</Badge>
|
|
||||||
)}
|
|
||||||
{customer.totalInspections > 0 && (
|
|
||||||
<Badge variant="secondary">{customer.totalInspections} ispezioni</Badge>
|
|
||||||
)}
|
|
||||||
{customer.totalInterventions > 0 && (
|
|
||||||
<Badge variant="secondary">{customer.totalInterventions} interventi</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lista Siti */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{customer.sites.map((site) => (
|
|
||||||
<div key={site.siteId} className="bg-muted/30 rounded-md p-3">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h4 className="font-medium">{site.siteName}</h4>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="text-xs">{site.totalHours}h</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">{site.totalShifts} turni</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{site.serviceTypes.map((st, idx) => (
|
|
||||||
<div key={idx} className="flex items-center justify-between text-sm p-2 rounded bg-background">
|
|
||||||
<span className="text-muted-foreground">{st.name}</span>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{st.hours > 0 && <span className="font-mono">{st.hours}h</span>}
|
|
||||||
{st.passages > 0 && (
|
|
||||||
<Badge variant="secondary" className="text-xs">{st.passages} passaggi</Badge>
|
|
||||||
)}
|
|
||||||
{st.inspections > 0 && (
|
|
||||||
<Badge variant="secondary" className="text-xs">{st.inspections} ispezioni</Badge>
|
|
||||||
)}
|
|
||||||
{st.interventions > 0 && (
|
|
||||||
<Badge variant="secondary" className="text-xs">{st.interventions} interventi</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-muted-foreground py-8">Nessun cliente con servizi fatturabili</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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, Navigation, Shield, Car as CarIcon, MapPin } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Users, Building2 } 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,15 +12,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
|
|
||||||
type Location = "roccapiemonte" | "milano" | "roma";
|
type Location = "roccapiemonte" | "milano" | "roma";
|
||||||
|
|
||||||
interface FixedShiftDetail {
|
interface ShiftDetail {
|
||||||
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;
|
||||||
@ -29,42 +27,14 @@ interface FixedShiftDetail {
|
|||||||
hours: number;
|
hours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FixedGuardSchedule {
|
interface GuardSchedule {
|
||||||
guardId: string;
|
guardId: string;
|
||||||
guardName: string;
|
guardName: string;
|
||||||
badgeNumber: string;
|
badgeNumber: string;
|
||||||
shifts: FixedShiftDetail[];
|
shifts: ShiftDetail[];
|
||||||
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;
|
||||||
@ -78,7 +48,6 @@ interface SiteSchedule {
|
|||||||
guardName: string;
|
guardName: string;
|
||||||
badgeNumber: string;
|
badgeNumber: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
isArmed: boolean;
|
|
||||||
}[];
|
}[];
|
||||||
vehicle?: {
|
vehicle?: {
|
||||||
licensePlate: string;
|
licensePlate: string;
|
||||||
@ -95,30 +64,20 @@ 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-fixed" | "guard-mobile" | "site">("guard-fixed");
|
const [viewMode, setViewMode] = useState<"guard" | "site">("guard");
|
||||||
|
|
||||||
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 Agenti Fissi
|
// Query per vista Guardie
|
||||||
const { data: fixedGuardSchedules, isLoading: isLoadingFixedGuards } = useQuery<FixedGuardSchedule[]>({
|
const { data: guardSchedules, isLoading: isLoadingGuards } = useQuery<GuardSchedule[]>({
|
||||||
queryKey: ["/api/service-planning/guards-fixed", weekStartStr, selectedLocation],
|
queryKey: ["/api/service-planning/by-guard", weekStartStr, selectedLocation],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`/api/service-planning/guards-fixed?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
const response = await fetch(`/api/service-planning/by-guard?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
||||||
if (!response.ok) throw new Error("Failed to fetch fixed guard schedules");
|
if (!response.ok) throw new Error("Failed to fetch guard schedules");
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
enabled: viewMode === "guard-fixed",
|
enabled: viewMode === "guard",
|
||||||
});
|
|
||||||
|
|
||||||
// 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
|
||||||
@ -142,7 +101,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 agente fisso, agente mobile o per sito
|
Visualizza orari e dotazioni per guardia o sito
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -186,15 +145,11 @@ export default function ServicePlanning() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tabs per vista */}
|
{/* Tabs per vista */}
|
||||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard-fixed" | "guard-mobile" | "site")}>
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard" | "site")}>
|
||||||
<TabsList className="grid w-full max-w-2xl grid-cols-3">
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
<TabsTrigger value="guard-fixed" data-testid="tab-guard-fixed-view">
|
<TabsTrigger value="guard" data-testid="tab-guard-view">
|
||||||
<Users className="h-4 w-4 mr-2" />
|
<Users className="h-4 w-4 mr-2" />
|
||||||
Agenti Fissi
|
Vista Agente
|
||||||
</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" />
|
||||||
@ -202,18 +157,18 @@ export default function ServicePlanning() {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Vista Agenti Fissi */}
|
{/* Vista Agente */}
|
||||||
<TabsContent value="guard-fixed" className="space-y-4 mt-6">
|
<TabsContent value="guard" className="space-y-4 mt-6">
|
||||||
{isLoadingFixedGuards ? (
|
{isLoadingGuards ? (
|
||||||
<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>
|
||||||
) : fixedGuardSchedules && fixedGuardSchedules.length > 0 ? (
|
) : guardSchedules && guardSchedules.length > 0 ? (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{fixedGuardSchedules.map((guard) => (
|
{guardSchedules.map((guard) => (
|
||||||
<Card key={guard.guardId} data-testid={`card-guard-fixed-${guard.guardId}`}>
|
<Card key={guard.guardId} data-testid={`card-guard-${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">
|
||||||
@ -224,134 +179,25 @@ 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 fisso assegnato</p>
|
<p className="text-sm text-muted-foreground">Nessun turno 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="p-3 rounded-md bg-muted/50 space-y-2"
|
className="flex items-start justify-between p-3 rounded-md bg-muted/50"
|
||||||
data-testid={`shift-${shift.shiftId}`}
|
data-testid={`shift-${shift.shiftId}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="space-y-1">
|
||||||
<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">
|
|
||||||
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to} ({shift.hours}h)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
{shift.isArmed && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<Shield className="h-3 w-3 mr-1" />
|
|
||||||
Armato
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{shift.vehicle && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<CarIcon className="h-3 w-3 mr-1" />
|
|
||||||
{shift.vehicle.licensePlate}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<p className="text-center text-muted-foreground">Nessun agente con turni fissi assegnati</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Vista Agenti Mobili */}
|
|
||||||
<TabsContent value="guard-mobile" className="space-y-4 mt-6">
|
|
||||||
{isLoadingMobileGuards ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-32 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : mobileGuardSchedules && mobileGuardSchedules.length > 0 ? (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{mobileGuardSchedules.map((guard) => (
|
|
||||||
<Card key={guard.guardId} data-testid={`card-guard-mobile-${guard.guardId}`}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-lg">
|
|
||||||
{guard.guardName} <Badge variant="outline">{guard.badgeNumber}</Badge>
|
|
||||||
</CardTitle>
|
|
||||||
<Badge>{guard.totalRoutes} {guard.totalRoutes === 1 ? 'percorso' : 'percorsi'}</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{guard.routes.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">Nessun percorso pattuglia assegnato</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{guard.routes.map((route) => (
|
|
||||||
<div
|
|
||||||
key={route.routeId}
|
|
||||||
className="p-3 rounded-md bg-muted/50 space-y-3"
|
|
||||||
data-testid={`route-${route.routeId}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="font-medium">
|
|
||||||
{format(new Date(route.shiftDate), "EEEE d MMM yyyy", { locale: it })}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{route.startTime} - {route.endTime}
|
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to} ({shift.hours}h)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{shift.vehicle && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
<div className="flex gap-2">
|
🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model})
|
||||||
{route.isArmedRoute && (
|
</div>
|
||||||
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -364,7 +210,7 @@ export default function ServicePlanning() {
|
|||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-center text-muted-foreground">Nessun agente con percorsi pattuglia assegnati</p>
|
<p className="text-center text-muted-foreground">Nessuna guardia con turni assegnati</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@ -406,29 +252,20 @@ 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 flex items-center justify-between">
|
<div key={idx} className="text-sm text-muted-foreground">
|
||||||
<span>{guard.guardName} ({guard.badgeNumber}) - {guard.hours}h</span>
|
👤 {guard.guardName} ({guard.badgeNumber}) - {guard.hours}h
|
||||||
{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>
|
||||||
|
|||||||
@ -166,7 +166,6 @@ export default function Services() {
|
|||||||
description: "",
|
description: "",
|
||||||
icon: "Building2",
|
icon: "Building2",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
classification: "fisso", // ✅ NUOVO: Discriminante Planning Fissi/Mobile
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -179,7 +178,6 @@ export default function Services() {
|
|||||||
description: "",
|
description: "",
|
||||||
icon: "Building2",
|
icon: "Building2",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
classification: "fisso", // ✅ NUOVO: Discriminante Planning Fissi/Mobile
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -237,7 +235,10 @@ export default function Services() {
|
|||||||
description: type.description,
|
description: type.description,
|
||||||
icon: type.icon,
|
icon: type.icon,
|
||||||
color: type.color,
|
color: type.color,
|
||||||
classification: type.classification, // ✅ NUOVO: includi classification
|
fixedPostHours: type.fixedPostHours || null,
|
||||||
|
patrolPassages: type.patrolPassages || null,
|
||||||
|
inspectionFrequency: type.inspectionFrequency || null,
|
||||||
|
responseTimeMinutes: type.responseTimeMinutes || null,
|
||||||
isActive: type.isActive,
|
isActive: type.isActive,
|
||||||
});
|
});
|
||||||
setEditTypeDialogOpen(true);
|
setEditTypeDialogOpen(true);
|
||||||
@ -1070,28 +1071,91 @@ export default function Services() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ NUOVO: Classification (Fisso/Mobile) */}
|
<div className="space-y-4 p-4 border rounded-lg">
|
||||||
<FormField
|
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
|
||||||
control={createTypeForm.control}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
name="classification"
|
<FormField
|
||||||
render={({ field }) => (
|
control={createTypeForm.control}
|
||||||
<FormItem>
|
name="fixedPostHours"
|
||||||
<FormLabel>Tipo Pianificazione*</FormLabel>
|
render={({ field }) => (
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Ore Presidio Fisso</FormLabel>
|
||||||
<SelectTrigger data-testid="select-type-classification">
|
<FormControl>
|
||||||
<SelectValue />
|
<Input
|
||||||
</SelectTrigger>
|
type="number"
|
||||||
</FormControl>
|
{...field}
|
||||||
<SelectContent>
|
value={field.value || ""}
|
||||||
<SelectItem value="fisso">Fisso (Planning Fissi)</SelectItem>
|
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
<SelectItem value="mobile">Mobile (Planning Mobile)</SelectItem>
|
placeholder="es: 8, 12"
|
||||||
</SelectContent>
|
data-testid="input-fixed-post-hours"
|
||||||
</Select>
|
/>
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={createTypeForm.control}
|
||||||
|
name="patrolPassages"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Passaggi Pattugliamento</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
placeholder="es: 3, 5"
|
||||||
|
data-testid="input-patrol-passages"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={createTypeForm.control}
|
||||||
|
name="inspectionFrequency"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Frequenza Ispezioni (min)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
placeholder="es: 60, 120"
|
||||||
|
data-testid="input-inspection-frequency"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={createTypeForm.control}
|
||||||
|
name="responseTimeMinutes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tempo Risposta (min)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
placeholder="es: 15, 30"
|
||||||
|
data-testid="input-response-time"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={createTypeForm.control}
|
control={createTypeForm.control}
|
||||||
@ -1245,30 +1309,7 @@ export default function Services() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ NUOVO: Classification (Fisso/Mobile) */}
|
<div className="space-y-4 p-4 border rounded-lg">
|
||||||
<FormField
|
|
||||||
control={editTypeForm.control}
|
|
||||||
name="classification"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Tipo Pianificazione*</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} value={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger data-testid="select-edit-type-classification">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="fisso">Fisso (Planning Fissi)</SelectItem>
|
|
||||||
<SelectItem value="mobile">Mobile (Planning Mobile)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-4 p-4 border rounded-lg" style={{display: "none"}}>
|
|
||||||
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
|
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@ -1,284 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { MapPin, Shield, Car, Clock, User, ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
|
|
||||||
import { it } from "date-fns/locale";
|
|
||||||
|
|
||||||
interface GuardAssignment {
|
|
||||||
guardId: string;
|
|
||||||
guardName: string;
|
|
||||||
badgeNumber: string;
|
|
||||||
plannedStartTime: string;
|
|
||||||
plannedEndTime: string;
|
|
||||||
armed: boolean;
|
|
||||||
vehicleId: string | null;
|
|
||||||
vehiclePlate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SiteDayPlan {
|
|
||||||
date: string;
|
|
||||||
guards: GuardAssignment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Site {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
location: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SitePlanningView() {
|
|
||||||
const [selectedSiteId, setSelectedSiteId] = useState<string>("");
|
|
||||||
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
|
||||||
const today = new Date();
|
|
||||||
return startOfWeek(today, { weekStartsOn: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query sites
|
|
||||||
const { data: sites } = useQuery<Site[]>({
|
|
||||||
queryKey: ["/api/sites"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query site planning
|
|
||||||
const { data: sitePlanning, isLoading } = useQuery<SiteDayPlan[]>({
|
|
||||||
queryKey: ["/api/site-planning", selectedSiteId, currentWeekStart.toISOString()],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!selectedSiteId) return [];
|
|
||||||
const weekEnd = addDays(currentWeekStart, 6);
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/site-planning/${selectedSiteId}?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch site planning");
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
enabled: !!selectedSiteId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Naviga settimana precedente
|
|
||||||
const handlePreviousWeek = () => {
|
|
||||||
setCurrentWeekStart(addDays(currentWeekStart, -7));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Naviga settimana successiva
|
|
||||||
const handleNextWeek = () => {
|
|
||||||
setCurrentWeekStart(addDays(currentWeekStart, 7));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Raggruppa per giorno
|
|
||||||
const planningByDay = sitePlanning?.reduce((acc, day) => {
|
|
||||||
acc[day.date] = day.guards;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, GuardAssignment[]>) || {};
|
|
||||||
|
|
||||||
// Genera array di 7 giorni della settimana
|
|
||||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
|
|
||||||
|
|
||||||
const selectedSite = sites?.find(s => s.id === selectedSiteId);
|
|
||||||
|
|
||||||
const locationLabels: Record<string, string> = {
|
|
||||||
roccapiemonte: "Roccapiemonte",
|
|
||||||
milano: "Milano",
|
|
||||||
roma: "Roma",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col p-6 space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold" data-testid="title-site-planning-view">
|
|
||||||
Planning per Sito
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Visualizza tutti gli agenti assegnati a un sito con dotazioni
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selettore sito */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Seleziona Sito</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Select value={selectedSiteId} onValueChange={setSelectedSiteId}>
|
|
||||||
<SelectTrigger data-testid="select-site">
|
|
||||||
<SelectValue placeholder="Seleziona un sito..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{sites?.map((site) => (
|
|
||||||
<SelectItem key={site.id} value={site.id} data-testid={`site-option-${site.id}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{site.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
({locationLabels[site.location] || site.location})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{selectedSite && (
|
|
||||||
<div className="p-3 border rounded-lg bg-muted/20">
|
|
||||||
<p className="font-semibold">{selectedSite.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{selectedSite.address}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Navigazione settimana */}
|
|
||||||
{selectedSiteId && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handlePreviousWeek}
|
|
||||||
data-testid="button-prev-week"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
||||||
Settimana Precedente
|
|
||||||
</Button>
|
|
||||||
<CardTitle className="text-center">
|
|
||||||
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
|
|
||||||
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
|
|
||||||
</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleNextWeek}
|
|
||||||
data-testid="button-next-week"
|
|
||||||
>
|
|
||||||
Settimana Successiva
|
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{isLoading && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
|
||||||
Caricamento planning sito...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Griglia giorni settimana */}
|
|
||||||
{selectedSiteId && !isLoading && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{weekDays.map((day) => {
|
|
||||||
const dateStr = format(day, "yyyy-MM-dd");
|
|
||||||
const dayGuards = planningByDay[dateStr] || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-lg">
|
|
||||||
{format(day, "EEEE dd/MM", { locale: it })}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{dayGuards.length === 0
|
|
||||||
? "Nessun agente"
|
|
||||||
: `${dayGuards.length} agente${dayGuards.length > 1 ? "i" : ""}`}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{dayGuards.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
|
||||||
Nessuna copertura
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
dayGuards.map((guard, index) => {
|
|
||||||
// Parsing sicuro orari (DB in UTC → visualizza in Europe/Rome)
|
|
||||||
let startTime = "N/A";
|
|
||||||
let endTime = "N/A";
|
|
||||||
|
|
||||||
if (guard.plannedStartTime) {
|
|
||||||
const parsedStart = new Date(guard.plannedStartTime);
|
|
||||||
if (isValid(parsedStart)) {
|
|
||||||
startTime = parsedStart.toLocaleTimeString("it-IT", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
timeZone: "Europe/Rome"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (guard.plannedEndTime) {
|
|
||||||
const parsedEnd = new Date(guard.plannedEndTime);
|
|
||||||
if (isValid(parsedEnd)) {
|
|
||||||
endTime = parsedEnd.toLocaleTimeString("it-IT", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
timeZone: "Europe/Rome"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${guard.guardId}-${index}`}
|
|
||||||
className="border rounded-lg p-3 space-y-2"
|
|
||||||
data-testid={`guard-assignment-${index}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<User className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="font-semibold text-sm">{guard.guardName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Matricola: {guard.badgeNumber}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="font-medium">
|
|
||||||
{startTime} - {endTime}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dotazioni */}
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{guard.armed && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<Shield className="h-3 w-3 mr-1" />
|
|
||||||
Armato
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{guard.vehicleId && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
<Car className="h-3 w-3 mr-1" />
|
|
||||||
{guard.vehiclePlate || "Automezzo"}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { Site, InsertSite, Customer, ServiceType } from "@shared/schema";
|
import { Site, InsertSite } from "@shared/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
@ -18,6 +18,13 @@ import { StatusBadge } from "@/components/status-badge";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
const shiftTypeLabels: Record<string, string> = {
|
||||||
|
fixed_post: "Presidio Fisso",
|
||||||
|
patrol: "Pattugliamento",
|
||||||
|
night_inspection: "Ispettorato Notturno",
|
||||||
|
quick_response: "Pronto Intervento",
|
||||||
|
};
|
||||||
|
|
||||||
const locationLabels: Record<string, string> = {
|
const locationLabels: Record<string, string> = {
|
||||||
roccapiemonte: "Roccapiemonte",
|
roccapiemonte: "Roccapiemonte",
|
||||||
milano: "Milano",
|
milano: "Milano",
|
||||||
@ -28,29 +35,17 @@ export default function Sites() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingSite, setEditingSite] = useState<Site | null>(null);
|
const [editingSite, setEditingSite] = useState<Site | null>(null);
|
||||||
const [isGeocoding, setIsGeocoding] = useState(false);
|
|
||||||
const [isGeocodingEdit, setIsGeocodingEdit] = useState(false);
|
|
||||||
|
|
||||||
const { data: sites, isLoading } = useQuery<Site[]>({
|
const { data: sites, isLoading } = useQuery<Site[]>({
|
||||||
queryKey: ["/api/sites"],
|
queryKey: ["/api/sites"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: customers } = useQuery<Customer[]>({
|
|
||||||
queryKey: ["/api/customers"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: serviceTypes } = useQuery<ServiceType[]>({
|
|
||||||
queryKey: ["/api/service-types"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<InsertSite>({
|
const form = useForm<InsertSite>({
|
||||||
resolver: zodResolver(insertSiteSchema),
|
resolver: zodResolver(insertSiteSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
address: "",
|
address: "",
|
||||||
customerId: undefined,
|
shiftType: "fixed_post",
|
||||||
location: "roccapiemonte",
|
|
||||||
serviceTypeId: undefined,
|
|
||||||
minGuards: 1,
|
minGuards: 1,
|
||||||
requiresArmed: false,
|
requiresArmed: false,
|
||||||
requiresDriverLicense: false,
|
requiresDriverLicense: false,
|
||||||
@ -59,8 +54,6 @@ export default function Sites() {
|
|||||||
contractEndDate: undefined,
|
contractEndDate: undefined,
|
||||||
serviceStartTime: "",
|
serviceStartTime: "",
|
||||||
serviceEndTime: "",
|
serviceEndTime: "",
|
||||||
latitude: undefined,
|
|
||||||
longitude: undefined,
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -70,9 +63,7 @@ export default function Sites() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
address: "",
|
address: "",
|
||||||
customerId: undefined,
|
shiftType: "fixed_post",
|
||||||
location: "roccapiemonte",
|
|
||||||
serviceTypeId: undefined,
|
|
||||||
minGuards: 1,
|
minGuards: 1,
|
||||||
requiresArmed: false,
|
requiresArmed: false,
|
||||||
requiresDriverLicense: false,
|
requiresDriverLicense: false,
|
||||||
@ -81,8 +72,6 @@ export default function Sites() {
|
|||||||
contractEndDate: undefined,
|
contractEndDate: undefined,
|
||||||
serviceStartTime: "",
|
serviceStartTime: "",
|
||||||
serviceEndTime: "",
|
serviceEndTime: "",
|
||||||
latitude: undefined,
|
|
||||||
longitude: undefined,
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -131,82 +120,6 @@ export default function Sites() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleGeocode = async () => {
|
|
||||||
const address = form.getValues("address");
|
|
||||||
if (!address) {
|
|
||||||
toast({
|
|
||||||
title: "Indirizzo mancante",
|
|
||||||
description: "Inserisci un indirizzo prima di cercare le coordinate",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGeocoding(true);
|
|
||||||
try {
|
|
||||||
const response = await apiRequest(
|
|
||||||
"POST",
|
|
||||||
"/api/geocode",
|
|
||||||
{ address }
|
|
||||||
);
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
form.setValue("latitude", result.latitude);
|
|
||||||
form.setValue("longitude", result.longitude);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Coordinate trovate",
|
|
||||||
description: `Indirizzo: ${result.displayName}`,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
toast({
|
|
||||||
title: "Errore geocodifica",
|
|
||||||
description: error.message || "Impossibile trovare le coordinate per questo indirizzo",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsGeocoding(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGeocodeEdit = async () => {
|
|
||||||
const address = editForm.getValues("address");
|
|
||||||
if (!address) {
|
|
||||||
toast({
|
|
||||||
title: "Indirizzo mancante",
|
|
||||||
description: "Inserisci un indirizzo prima di cercare le coordinate",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGeocodingEdit(true);
|
|
||||||
try {
|
|
||||||
const response = await apiRequest(
|
|
||||||
"POST",
|
|
||||||
"/api/geocode",
|
|
||||||
{ address }
|
|
||||||
);
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
editForm.setValue("latitude", result.latitude);
|
|
||||||
editForm.setValue("longitude", result.longitude);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Coordinate trovate",
|
|
||||||
description: `Indirizzo: ${result.displayName}`,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
toast({
|
|
||||||
title: "Errore geocodifica",
|
|
||||||
description: error.message || "Impossibile trovare le coordinate per questo indirizzo",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsGeocodingEdit(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (data: InsertSite) => {
|
const onSubmit = (data: InsertSite) => {
|
||||||
createMutation.mutate(data);
|
createMutation.mutate(data);
|
||||||
};
|
};
|
||||||
@ -221,12 +134,9 @@ export default function Sites() {
|
|||||||
setEditingSite(site);
|
setEditingSite(site);
|
||||||
editForm.reset({
|
editForm.reset({
|
||||||
name: site.name,
|
name: site.name,
|
||||||
address: site.address || "",
|
address: site.address,
|
||||||
latitude: site.latitude || "",
|
|
||||||
longitude: site.longitude || "",
|
|
||||||
customerId: site.customerId ?? undefined,
|
|
||||||
location: site.location,
|
location: site.location,
|
||||||
serviceTypeId: site.serviceTypeId ?? undefined,
|
shiftType: site.shiftType,
|
||||||
minGuards: site.minGuards,
|
minGuards: site.minGuards,
|
||||||
requiresArmed: site.requiresArmed,
|
requiresArmed: site.requiresArmed,
|
||||||
requiresDriverLicense: site.requiresDriverLicense,
|
requiresDriverLicense: site.requiresDriverLicense,
|
||||||
@ -318,94 +228,6 @@ export default function Sites() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border rounded-lg p-4 space-y-4 bg-muted/50">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<MapPin className="h-4 w-4" />
|
|
||||||
Coordinate GPS (per mappa)
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleGeocode}
|
|
||||||
disabled={isGeocoding || !form.watch("address")}
|
|
||||||
data-testid="button-geocode"
|
|
||||||
>
|
|
||||||
{isGeocoding ? "Ricerca in corso..." : "📍 Trova Coordinate"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="latitude"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Latitudine</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="41.9028"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
data-testid="input-latitude"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="longitude"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Longitudine</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="12.4964"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
data-testid="input-longitude"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Le coordinate GPS permettono di visualizzare il sito sulla mappa in Planning Mobile
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="customerId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Cliente (opzionale)</FormLabel>
|
|
||||||
<Select onValueChange={(value) => field.onChange(value || undefined)} value={field.value ?? undefined}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger data-testid="select-customer">
|
|
||||||
<SelectValue placeholder="Nessun cliente" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{customers?.map((customer) => (
|
|
||||||
<SelectItem key={customer.id} value={customer.id}>
|
|
||||||
{customer.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="location"
|
name="location"
|
||||||
@ -489,22 +311,21 @@ export default function Sites() {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="serviceTypeId"
|
name="shiftType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Tipologia Servizio (opzionale)</FormLabel>
|
<FormLabel>Tipologia Servizio</FormLabel>
|
||||||
<Select onValueChange={field.onChange} value={field.value ?? undefined}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger data-testid="select-service-type">
|
<SelectTrigger data-testid="select-shift-type">
|
||||||
<SelectValue placeholder="Seleziona tipo servizio" />
|
<SelectValue placeholder="Seleziona tipo servizio" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{serviceTypes?.filter(st => st.isActive).map((serviceType) => (
|
<SelectItem value="fixed_post">Presidio Fisso</SelectItem>
|
||||||
<SelectItem key={serviceType.id} value={serviceType.id}>
|
<SelectItem value="patrol">Pattugliamento</SelectItem>
|
||||||
{serviceType.label}
|
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="quick_response">Pronto Intervento</SelectItem>
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -668,94 +489,6 @@ export default function Sites() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="border rounded-lg p-4 space-y-4 bg-muted/50">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm font-medium flex items-center gap-2">
|
|
||||||
<MapPin className="h-4 w-4" />
|
|
||||||
Coordinate GPS (per mappa)
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleGeocodeEdit}
|
|
||||||
disabled={isGeocodingEdit || !editForm.watch("address")}
|
|
||||||
data-testid="button-geocode-edit"
|
|
||||||
>
|
|
||||||
{isGeocodingEdit ? "Ricerca in corso..." : "📍 Trova Coordinate"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={editForm.control}
|
|
||||||
name="latitude"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Latitudine</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="41.9028"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
data-testid="input-edit-latitude"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={editForm.control}
|
|
||||||
name="longitude"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Longitudine</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="12.4964"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
data-testid="input-edit-longitude"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Le coordinate GPS permettono di visualizzare il sito sulla mappa in Planning Mobile
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={editForm.control}
|
|
||||||
name="customerId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Cliente (opzionale)</FormLabel>
|
|
||||||
<Select onValueChange={(value) => field.onChange(value || undefined)} value={field.value ?? undefined}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger data-testid="select-edit-customer">
|
|
||||||
<SelectValue placeholder="Nessun cliente" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{customers?.map((customer) => (
|
|
||||||
<SelectItem key={customer.id} value={customer.id}>
|
|
||||||
{customer.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
name="location"
|
name="location"
|
||||||
@ -839,22 +572,21 @@ export default function Sites() {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
name="serviceTypeId"
|
name="shiftType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Tipologia Servizio (opzionale)</FormLabel>
|
<FormLabel>Tipologia Servizio</FormLabel>
|
||||||
<Select onValueChange={field.onChange} value={field.value ?? undefined}>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger data-testid="select-edit-service-type">
|
<SelectTrigger data-testid="select-edit-shift-type">
|
||||||
<SelectValue placeholder="Seleziona tipo servizio" />
|
<SelectValue placeholder="Seleziona tipo servizio" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{serviceTypes?.filter(st => st.isActive).map((serviceType) => (
|
<SelectItem value="fixed_post">Presidio Fisso</SelectItem>
|
||||||
<SelectItem key={serviceType.id} value={serviceType.id}>
|
<SelectItem value="patrol">Pattugliamento</SelectItem>
|
||||||
{serviceType.label}
|
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="quick_response">Pronto Intervento</SelectItem>
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -1033,14 +765,9 @@ export default function Sites() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{site.serviceTypeId && serviceTypes && (() => {
|
<Badge variant="outline">
|
||||||
const serviceType = serviceTypes.find(st => st.id === site.serviceTypeId);
|
{shiftTypeLabels[site.shiftType]}
|
||||||
return serviceType ? (
|
</Badge>
|
||||||
<Badge variant="outline" data-testid={`badge-service-type-${site.id}`}>
|
|
||||||
{serviceType.label}
|
|
||||||
</Badge>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const status = getContractStatus(site);
|
const status = getContractStatus(site);
|
||||||
const statusInfo = contractStatusLabels[status];
|
const statusInfo = contractStatusLabels[status];
|
||||||
|
|||||||
@ -1,448 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Users, Clock, MapPin, Navigation, ExternalLink } from "lucide-react";
|
|
||||||
import { format, parseISO, addDays, startOfWeek, addWeeks } from "date-fns";
|
|
||||||
import { it } from "date-fns/locale";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
|
|
||||||
type AbsenceType = "sick_leave" | "vacation" | "personal_leave" | "injury";
|
|
||||||
|
|
||||||
interface GuardScheduleData {
|
|
||||||
guard: {
|
|
||||||
id: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
badgeNumber: string;
|
|
||||||
};
|
|
||||||
fixedShifts: Array<{
|
|
||||||
assignmentId: string;
|
|
||||||
shiftId: string;
|
|
||||||
plannedStartTime: Date;
|
|
||||||
plannedEndTime: Date;
|
|
||||||
siteName: string;
|
|
||||||
siteId: string;
|
|
||||||
}>;
|
|
||||||
mobileShifts: Array<{
|
|
||||||
routeId: string;
|
|
||||||
shiftDate: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
}>;
|
|
||||||
absences: Array<{
|
|
||||||
id: string;
|
|
||||||
type: AbsenceType;
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WeeklyScheduleResponse {
|
|
||||||
weekStart: string;
|
|
||||||
weekEnd: string;
|
|
||||||
location: string;
|
|
||||||
guards: GuardScheduleData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ABSENCE_LABELS: Record<AbsenceType, string> = {
|
|
||||||
sick_leave: "Malattia",
|
|
||||||
vacation: "Ferie",
|
|
||||||
personal_leave: "Permesso",
|
|
||||||
injury: "Infortunio",
|
|
||||||
};
|
|
||||||
|
|
||||||
type DialogData = {
|
|
||||||
type: "fixed" | "mobile";
|
|
||||||
guardName: string;
|
|
||||||
date: string;
|
|
||||||
data: any;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
export default function WeeklyGuards() {
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
|
|
||||||
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
|
|
||||||
startOfWeek(new Date(), { weekStartsOn: 1 }) // Inizia lunedì
|
|
||||||
);
|
|
||||||
const [dialogData, setDialogData] = useState<DialogData>(null);
|
|
||||||
|
|
||||||
const { data: scheduleData, isLoading, error } = useQuery<WeeklyScheduleResponse>({
|
|
||||||
queryKey: ["/api/weekly-guards-schedule", selectedLocation, format(currentWeekStart, "yyyy-MM-dd")],
|
|
||||||
queryFn: async () => {
|
|
||||||
const startDate = format(currentWeekStart, "yyyy-MM-dd");
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/weekly-guards-schedule?location=${selectedLocation}&startDate=${startDate}`
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch weekly schedule");
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
enabled: !!selectedLocation,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper per ottenere i giorni della settimana
|
|
||||||
const getWeekDays = () => {
|
|
||||||
const days = [];
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
days.push(addDays(currentWeekStart, i));
|
|
||||||
}
|
|
||||||
return days;
|
|
||||||
};
|
|
||||||
|
|
||||||
const weekDays = getWeekDays();
|
|
||||||
|
|
||||||
// Helper per trovare l'attività di una guardia in un giorno specifico
|
|
||||||
const getDayActivity = (guardData: GuardScheduleData, date: Date) => {
|
|
||||||
const dateStr = format(date, "yyyy-MM-dd");
|
|
||||||
|
|
||||||
// Controlla assenze
|
|
||||||
const absence = guardData.absences.find(abs => {
|
|
||||||
const startDate = abs.startDate;
|
|
||||||
const endDate = abs.endDate;
|
|
||||||
return dateStr >= startDate && dateStr <= endDate;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (absence) {
|
|
||||||
return {
|
|
||||||
type: "absence" as const,
|
|
||||||
label: ABSENCE_LABELS[absence.type],
|
|
||||||
data: absence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Controlla turni fissi
|
|
||||||
const fixedShift = guardData.fixedShifts.find(shift => {
|
|
||||||
const shiftDate = format(new Date(shift.plannedStartTime), "yyyy-MM-dd");
|
|
||||||
return shiftDate === dateStr;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fixedShift) {
|
|
||||||
const startTime = format(new Date(fixedShift.plannedStartTime), "HH:mm");
|
|
||||||
const endTime = format(new Date(fixedShift.plannedEndTime), "HH:mm");
|
|
||||||
return {
|
|
||||||
type: "fixed" as const,
|
|
||||||
label: `${fixedShift.siteName} ${startTime}-${endTime}`,
|
|
||||||
data: fixedShift,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Controlla turni mobili
|
|
||||||
const mobileShift = guardData.mobileShifts.find(shift => shift.shiftDate === dateStr);
|
|
||||||
|
|
||||||
if (mobileShift) {
|
|
||||||
return {
|
|
||||||
type: "mobile" as const,
|
|
||||||
label: `Pattuglia ${mobileShift.startTime}-${mobileShift.endTime}`,
|
|
||||||
data: mobileShift,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreviousWeek = () => {
|
|
||||||
setCurrentWeekStart(prev => addWeeks(prev, -1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextWeek = () => {
|
|
||||||
setCurrentWeekStart(prev => addWeeks(prev, 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCellClick = (guardData: GuardScheduleData, activity: ReturnType<typeof getDayActivity>, date: Date) => {
|
|
||||||
if (!activity || activity.type === "absence") return;
|
|
||||||
|
|
||||||
const guardName = `${guardData.guard.lastName} ${guardData.guard.firstName}`;
|
|
||||||
const dateStr = format(date, "EEEE dd MMMM yyyy", { locale: it });
|
|
||||||
|
|
||||||
setDialogData({
|
|
||||||
type: activity.type,
|
|
||||||
guardName,
|
|
||||||
date: dateStr,
|
|
||||||
data: activity.data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
|
||||||
<Users className="h-8 w-8 text-primary" />
|
|
||||||
Guardie Settimanale
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Vista riepilogativa delle assegnazioni settimanali per sede
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtri */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="h-5 w-5" />
|
|
||||||
Filtri Visualizzazione
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Seleziona sede e settimana per visualizzare le assegnazioni
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-end gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="text-sm font-medium mb-2 block">Sede</label>
|
|
||||||
<Select
|
|
||||||
value={selectedLocation}
|
|
||||||
onValueChange={setSelectedLocation}
|
|
||||||
data-testid="select-location"
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Seleziona sede" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
|
||||||
<SelectItem value="milano">Milano</SelectItem>
|
|
||||||
<SelectItem value="roma">Roma</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handlePreviousWeek}
|
|
||||||
data-testid="button-previous-week"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="px-4 py-2 border rounded-md bg-muted min-w-[280px] text-center">
|
|
||||||
<span className="font-medium">
|
|
||||||
{format(currentWeekStart, "d MMM", { locale: it })} - {format(addDays(currentWeekStart, 6), "d MMM yyyy", { locale: it })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleNextWeek}
|
|
||||||
data-testid="button-next-week"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Griglia Settimanale */}
|
|
||||||
{isLoading ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<p className="text-center text-muted-foreground">Caricamento...</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : error ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<p className="text-center text-destructive">
|
|
||||||
Errore nel caricamento della pianificazione. Riprova più tardi.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : scheduleData && scheduleData.guards.length > 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b bg-muted/50">
|
|
||||||
<th className="sticky left-0 z-10 bg-muted/50 text-left p-3 font-medium min-w-[180px]">
|
|
||||||
Guardia
|
|
||||||
</th>
|
|
||||||
{weekDays.map((day, index) => (
|
|
||||||
<th key={index} className="text-center p-3 font-medium min-w-[200px]">
|
|
||||||
<div>{format(day, "EEE", { locale: it })}</div>
|
|
||||||
<div className="text-xs text-muted-foreground font-normal">
|
|
||||||
{format(day, "dd/MM")}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{scheduleData.guards.map((guardData) => (
|
|
||||||
<tr
|
|
||||||
key={guardData.guard.id}
|
|
||||||
className="border-b hover:bg-muted/30"
|
|
||||||
data-testid={`row-guard-${guardData.guard.id}`}
|
|
||||||
>
|
|
||||||
<td className="sticky left-0 z-10 bg-background p-3 font-medium border-r">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm">
|
|
||||||
{guardData.guard.lastName} {guardData.guard.firstName}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
#{guardData.guard.badgeNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{weekDays.map((day, dayIndex) => {
|
|
||||||
const activity = getDayActivity(guardData, day);
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
key={dayIndex}
|
|
||||||
className="p-2 text-center align-middle"
|
|
||||||
>
|
|
||||||
{activity ? (
|
|
||||||
activity.type === "absence" ? (
|
|
||||||
<div
|
|
||||||
className="text-xs px-2 py-1.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-300"
|
|
||||||
data-testid={`cell-absence-${guardData.guard.id}-${dayIndex}`}
|
|
||||||
>
|
|
||||||
{activity.label}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full h-auto text-xs px-2 py-1.5 whitespace-normal hover-elevate"
|
|
||||||
onClick={() => handleCellClick(guardData, activity, day)}
|
|
||||||
data-testid={`button-shift-${guardData.guard.id}-${dayIndex}`}
|
|
||||||
>
|
|
||||||
{activity.label}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<p className="text-center text-muted-foreground">
|
|
||||||
Nessuna guardia trovata per la sede selezionata
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dialog Dettaglio Turno */}
|
|
||||||
<Dialog open={!!dialogData} onOpenChange={() => setDialogData(null)}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
{dialogData?.type === "fixed" ? (
|
|
||||||
<>
|
|
||||||
<MapPin className="h-5 w-5" />
|
|
||||||
Turno Fisso - {dialogData?.guardName}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Navigation className="h-5 w-5" />
|
|
||||||
Turno Mobile - {dialogData?.guardName}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{dialogData?.date}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{dialogData && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{dialogData.type === "fixed" ? (
|
|
||||||
// Dettagli turno fisso
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="bg-muted/30 p-3 rounded-md space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Sito</span>
|
|
||||||
<span className="text-sm font-medium">{dialogData.data.siteName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Orario</span>
|
|
||||||
<div className="flex items-center gap-1 text-sm font-medium">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{format(new Date(dialogData.data.plannedStartTime), "HH:mm")} - {format(new Date(dialogData.data.plannedEndTime), "HH:mm")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Durata</span>
|
|
||||||
<span className="text-sm font-bold">
|
|
||||||
{Math.round((new Date(dialogData.data.plannedEndTime).getTime() - new Date(dialogData.data.plannedStartTime).getTime()) / (1000 * 60 * 60))}h
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Per modificare questo turno, vai alla pagina <Link href="/general-planning" className="text-primary font-medium hover:underline">Planning Fissi</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Dettagli turno mobile
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="bg-muted/30 p-3 rounded-md space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Tipo</span>
|
|
||||||
<Badge variant="outline">Pattuglia</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Orario</span>
|
|
||||||
<div className="flex items-center gap-1 text-sm font-medium">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{dialogData.data.startTime} - {dialogData.data.endTime}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Per visualizzare il percorso completo e modificare il turno, vai alla pagina <Link href="/planning-mobile" className="text-primary font-medium hover:underline">Planning Mobile</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDialogData(null)}>
|
|
||||||
Chiudi
|
|
||||||
</Button>
|
|
||||||
{dialogData?.type === "fixed" ? (
|
|
||||||
<Link href="/general-planning">
|
|
||||||
<Button data-testid="button-goto-planning-fissi">
|
|
||||||
<ExternalLink className="h-4 w-4 mr-2" />
|
|
||||||
Vai a Planning Fissi
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Link href="/planning-mobile">
|
|
||||||
<Button data-testid="button-goto-planning-mobile">
|
|
||||||
<ExternalLink className="h-4 w-4 mr-2" />
|
|
||||||
Vai a Planning Mobile
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
BIN
database-backups/vigilanzaturni_v1.0.24_20251018_102519.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.24_20251018_102519.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.25_20251021_141055.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.25_20251021_141055.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.26_20251021_154042.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.26_20251021_154042.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.27_20251021_162727.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.27_20251021_162727.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.28_20251021_165619.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.28_20251021_165619.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.29_20251021_171921.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.29_20251021_171921.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.30_20251022_071255.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.30_20251022_071255.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.31_20251022_081911.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.31_20251022_081911.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.32_20251022_083408.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.32_20251022_083408.sql.gz
Normal file
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.33_20251022_085205.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.33_20251022_085205.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
105
package-lock.json
generated
105
package-lock.json
generated
@ -9,9 +9,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
"@neondatabase/serverless": "^0.10.4",
|
||||||
@ -44,7 +41,6 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"@types/memoizee": "^0.4.12",
|
"@types/memoizee": "^0.4.12",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@ -60,7 +56,6 @@
|
|||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memoizee": "^0.4.17",
|
"memoizee": "^0.4.17",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
@ -74,7 +69,6 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
@ -420,59 +414,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/core": {
|
|
||||||
"version": "6.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0",
|
|
||||||
"react-dom": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/sortable": {
|
|
||||||
"version": "10.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
|
||||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@dnd-kit/core": "^6.3.0",
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/utilities": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||||
@ -2849,17 +2790,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
|
||||||
},
|
},
|
||||||
"node_modules/@react-leaflet/core": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
|
||||||
"license": "Hippocratic-2.1",
|
|
||||||
"peerDependencies": {
|
|
||||||
"leaflet": "^1.9.0",
|
|
||||||
"react": "^18.0.0",
|
|
||||||
"react-dom": "^18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@replit/vite-plugin-cartographer": {
|
"node_modules/@replit/vite-plugin-cartographer": {
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@replit/vite-plugin-cartographer/-/vite-plugin-cartographer-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@replit/vite-plugin-cartographer/-/vite-plugin-cartographer-0.3.1.tgz",
|
||||||
@ -3641,12 +3571,6 @@
|
|||||||
"@types/express": "*"
|
"@types/express": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/geojson": {
|
|
||||||
"version": "7946.0.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
|
||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/http-errors": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
|
||||||
@ -3654,15 +3578,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/leaflet": {
|
|
||||||
"version": "1.9.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
|
||||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/geojson": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/memoizee": {
|
"node_modules/@types/memoizee": {
|
||||||
"version": "0.4.12",
|
"version": "0.4.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz",
|
||||||
@ -5634,12 +5549,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/leaflet": {
|
|
||||||
"version": "1.9.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.29.2",
|
"version": "1.29.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
|
||||||
@ -6911,20 +6820,6 @@
|
|||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-leaflet": {
|
|
||||||
"version": "4.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
|
||||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
|
||||||
"license": "Hippocratic-2.1",
|
|
||||||
"dependencies": {
|
|
||||||
"@react-leaflet/core": "^2.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"leaflet": "^1.9.0",
|
|
||||||
"react": "^18.0.0",
|
|
||||||
"react-dom": "^18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|||||||
@ -11,9 +11,6 @@
|
|||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@jridgewell/trace-mapping": "^0.3.25",
|
"@jridgewell/trace-mapping": "^0.3.25",
|
||||||
"@neondatabase/serverless": "^0.10.4",
|
"@neondatabase/serverless": "^0.10.4",
|
||||||
@ -46,7 +43,6 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
|
||||||
"@types/memoizee": "^0.4.12",
|
"@types/memoizee": "^0.4.12",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@ -62,7 +58,6 @@
|
|||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"framer-motion": "^11.13.1",
|
"framer-motion": "^11.13.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"memoizee": "^0.4.17",
|
"memoizee": "^0.4.17",
|
||||||
"memorystore": "^1.6.7",
|
"memorystore": "^1.6.7",
|
||||||
@ -76,7 +71,6 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
|||||||
115
replit.md
115
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. Key capabilities include comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system. The project aims to provide a robust, scalable solution for security companies, improving operational control and resource allocation.
|
VigilanzaTurni is a professional 24/7 shift management system designed for security companies. It offers multi-role authentication (Admin, Coordinator, Guard, Client), comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system. The system supports multi-location operations (Roccapiemonte, Milano, Roma) managing 250+ security personnel across different branches. The project aims to streamline operations and enhance efficiency for security institutes.
|
||||||
|
|
||||||
## User Preferences
|
## User Preferences
|
||||||
- Interfaccia in italiano
|
- Interfaccia in italiano
|
||||||
@ -19,7 +19,6 @@ VigilanzaTurni is a professional 24/7 shift management system for security compa
|
|||||||
- **Autenticazione**: Replit Auth (OIDC)
|
- **Autenticazione**: Replit Auth (OIDC)
|
||||||
- **State Management**: TanStack Query v5
|
- **State Management**: TanStack Query v5
|
||||||
- **Routing**: Wouter
|
- **Routing**: Wouter
|
||||||
- **Maps**: Leaflet + react-leaflet + OpenStreetMap tiles
|
|
||||||
|
|
||||||
### Design System
|
### Design System
|
||||||
- **Font Principale**: Inter (sans-serif)
|
- **Font Principale**: Inter (sans-serif)
|
||||||
@ -29,50 +28,74 @@ 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 supports managing users, guards, certifications, sites, shifts, shift assignments, notifications, customers, and service types. It also includes tables for advanced scheduling constraints such as guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Service types include specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, and `responseTimeMinutes`.
|
The database includes core tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, and `notifications`. Advanced scheduling and constraints are managed via `guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, and `absence_affected_shifts`. All tables include appropriate foreign keys and unique constraints to maintain data integrity.
|
||||||
|
|
||||||
### Core Features
|
**Recent Schema Updates (October 2025)**:
|
||||||
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch.
|
- Service types now include specialized parameters: `fixedPostHours` (ore presidio fisso), `patrolPassages` (numero passaggi pattuglia), `inspectionFrequency` (frequenza ispezioni), `responseTimeMinutes` (tempo risposta pronto intervento)
|
||||||
- **Service Type Classification**: Classifies services as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules.
|
- Sites include service schedule fields: `serviceStartTime` and `serviceEndTime` (formato HH:MM)
|
||||||
- **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.
|
- **Contract Management**: Sites now include contract fields: `contractReference` (codice contratto), `contractStartDate`, `contractEndDate` (date validità contratto in formato YYYY-MM-DD)
|
||||||
- **Planning Mobile**: Guard-centric interface for mobile services, displaying guard availability and hours, with an interactive Leaflet map showing sites. Features include:
|
- Sites now reference service types via `serviceTypeId` foreign key; `shiftType` is optional and can be derived from service type
|
||||||
- **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
|
- **Multi-Location Support**: Added `location` field (enum: roccapiemonte, milano, roma) to `sites`, `guards`, and `vehicles` tables for complete multi-sede resource isolation
|
||||||
- **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
|
**Recent Features (October 17-18, 2025)**:
|
||||||
- **Patrol Sequence List View**: Daily view of planned patrol routes with stops visualization
|
- **Multi-Sede Operational Planning**: Redesigned operational planning workflow with location-first approach:
|
||||||
- **Custom Shift Timing**: Configurable start time and duration for each patrol route (replaces hardcoded 08:00-20:00)
|
1. Select sede (Roccapiemonte/Milano/Roma) - first step with default value
|
||||||
- **Shift Overlap Validation**: POST /api/patrol-routes/check-overlaps endpoint verifies:
|
2. Select date
|
||||||
- No conflicts with existing fixed post shifts (shift_assignments)
|
3. View uncovered sites filtered by selected sede
|
||||||
- No conflicts with other mobile patrol routes
|
4. Select site → view available resources (guards and vehicles) filtered by sede
|
||||||
- Weekly hours compliance with contract parameters (maxHoursPerWeek + maxOvertimePerWeek)
|
5. Assign resources and create shift
|
||||||
- **Force-Save Dialog**: Interactive conflict resolution when saving patrol routes with overlaps or contractual limit violations; shows detailed conflict information and allows coordinator override
|
- **Location-Based Filtering**: Backend endpoints use INNER JOIN with sites table to ensure complete resource isolation between locations - guards/vehicles in one sede remain available even when assigned to shifts in other sedi
|
||||||
- **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)
|
- **Site Management**: Added sede selection in site creation/editing forms with visual badges showing location in site listings
|
||||||
- **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export.
|
- **Planning Generale (October 18, 2025)**: New weekly planning overview feature showing all sites × 7 days in table format:
|
||||||
- **Dashboard Operativa**: Live KPIs and real-time shift status.
|
- **Contract filtering**: Shows only sites with active contracts in the week dates (`contractStartDate <= weekEnd AND contractEndDate >= weekStart`)
|
||||||
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
|
- Backend endpoint `/api/general-planning?weekStart=YYYY-MM-DD&location=sede` with complex joins and location filtering
|
||||||
- **Gestione Siti/Commesse**: Sites are associated with service types, including schedule, contract management, and location assignment. Automatic geocoding is supported.
|
- Automatic missing guards calculation: `ceil(totalShiftHours / maxHoursPerGuard) × minGuards - assignedGuards` (e.g., 24h shift, 2 guards min, 9h max = 6 total needed)
|
||||||
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses.
|
- **Weekly summary**: Shows total guards needed, guards assigned (counting slots, not unique people), and guards missing for the entire week
|
||||||
- **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.
|
- Table cells display: assigned guards with hours, vehicles, missing guards badge (if any), shift count, total hours
|
||||||
- **Guard Planning Views**: Dedicated views for guards to see their fixed post shifts and mobile patrol routes.
|
- Interactive cells with click handler opening detail dialog
|
||||||
- **Site Planning View**: Coordinators can view all guards assigned to a specific site over a week.
|
- Dialog shows: shift count, total hours, guard list with hours and badge numbers, vehicle list, missing guards warning with explanation
|
||||||
- **Shift Duplication Features**:
|
- **Direct Shift Creation from Dialog**: Users can now create multi-day shifts directly from the Planning Generale dialog:
|
||||||
- **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.
|
- Select guard from dropdown showing name + weekly available hours (max 45h - assigned hours)
|
||||||
- **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).
|
- Specify number of consecutive days (1-7)
|
||||||
- **Guardie Settimanale**: Compact weekly schedule view showing all guards' assignments across the week in a grid format. Features include:
|
- Backend endpoint `POST /api/general-planning/shifts` with atomic transaction using `db.transaction()` - all shifts created or none (rollback on error)
|
||||||
- **Weekly Grid View**: Guard names in first column, 7 daily columns (Mon-Sun) with compact cell display
|
- Validates contract dates, site and guard existence before transaction
|
||||||
- **Multi-Source Aggregation**: GET /api/weekly-guards-schedule endpoint aggregates fixed shifts, patrol routes, and absences by location and week
|
- Automatically creates shifts spanning multiple days with correct time ranges from site service schedule
|
||||||
- **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)
|
- TanStack Query mutation with cache invalidation for real-time planning grid updates
|
||||||
- **Read-Only Dialogs**: Clicking cells opens appropriate dialog (fixed shift details or mobile patrol info) with navigation links to Planning Fissi/Mobile for edits
|
- "Modifica in Pianificazione Operativa" button in dialog navigates to operational planning page with pre-filled date/location parameters
|
||||||
- **Location and Week Filters**: Dropdown for branch selection, week navigation with prev/next buttons displaying "Settimana dal DD MMM al DD MMM YYYY"
|
- Week navigation (previous/next week) with location selector
|
||||||
|
- Operational planning page now supports query parameters (`?date=YYYY-MM-DD&location=sede`) for seamless integration
|
||||||
|
|
||||||
|
**Recent Bug Fixes (October 17-18, 2025)**:
|
||||||
|
- **Operational Planning Date Handling**: Fixed date sanitization in `/api/operational-planning/uncovered-sites` and `/api/operational-planning/availability` endpoints to handle malformed date inputs (e.g., "2025-10-17/2025-10-17"). Both endpoints now validate dates using `parseISO`/`isValid` and return 400 for invalid formats.
|
||||||
|
- **Checkbox Event Propagation**: Fixed double-toggle bug in operational planning resource selection by wrapping vehicle and guard checkboxes in `<div onClick={e => e.stopPropagation()}>` to prevent Card onClick from firing when clicking checkboxes.
|
||||||
|
- **Multi-Sede Resource Isolation**: Fixed critical bug where resources from different sedi were incorrectly marked as unavailable due to global shift queries. Now both availability and uncovered-sites endpoints filter shifts by location using JOIN with sites table.
|
||||||
|
- **QueryKey Cache Invalidation**: Fixed queryKey structure from single-string to hierarchical array with custom queryFn to enable targeted cache invalidation by location and date while preventing URL concatenation errors.
|
||||||
|
- **apiRequest Parameter Order (October 18, 2025)**: Fixed inverted parameters bug in Planning Generale shift creation mutation. Changed `apiRequest(url, method, data)` to correct signature `apiRequest(method, url, data)` matching queryClient.ts function definition.
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
Comprehensive RESTful API endpoints are provided for Authentication, Users, Guards, Sites, Shifts, and Notifications, supporting full CRUD operations with role-based access control.
|
||||||
|
|
||||||
|
### Frontend Routes
|
||||||
|
Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/notifications`, and `/users`, with access controlled by user roles.
|
||||||
|
|
||||||
### User Roles
|
### User Roles
|
||||||
- **Admin**: Full access.
|
- **Admin**: Full access to all functionalities, managing guards, sites, shifts, and reports.
|
||||||
- **Coordinator**: Shift planning, guard assignment, operational site management, reporting.
|
- **Coordinator**: Shift planning, guard assignment, operational site management, and reporting.
|
||||||
- **Guard**: View assigned shifts, time-punching, notifications, personal profile.
|
- **Guard**: View assigned shifts, future time-punching, notifications, and personal profile.
|
||||||
- **Client**: View assigned sites, service reporting, KPIs.
|
- **Client**: View assigned sites, service reporting, and KPIs.
|
||||||
|
|
||||||
### Critical Date/Timezone Handling
|
### Key Features
|
||||||
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.
|
- **Dashboard Operativa**: Live KPIs (active shifts, total guards, active sites, expiring certifications) and real-time shift status.
|
||||||
|
- **Gestione Guardie**: Complete profiles with skill matrix (armed, fire safety, first aid, driver's license), certification management with automatic expiry, and unique badge numbers.
|
||||||
|
- **Gestione Siti/Commesse**: Service types with specialized parameters (fixed post hours, patrol passages, inspection frequency, response time) and minimum requirements (guard count, armed, driver's license). Sites include service schedule (start/end time), contract management (reference code, validity period with start/end dates), and location/sede assignment. Contract status is visualized with badges (active/expiring/expired) and enforces shift creation only within active contract periods.
|
||||||
|
- **Pianificazione Operativa Multi-Sede**: Location-aware workflow for shift assignment:
|
||||||
|
1. Select sede (Roccapiemonte/Milano/Roma) → filters all subsequent data by location
|
||||||
|
2. Select date → view uncovered sites with coverage status (sede-filtered)
|
||||||
|
3. Select site → view available resources (guards and vehicles matching sede and requirements)
|
||||||
|
4. Assign resources → create shift with atomic guard assignments and vehicle allocation
|
||||||
|
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses (planned, active, completed, cancelled).
|
||||||
|
- **Reportistica**: Total hours worked, monthly hours per guard, shift statistics, and data export capabilities.
|
||||||
|
- **Advanced Planning**: Management of guard constraints (preferences, max hours, rest days), site preferences (preferred/blacklisted guards), contract parameters, training courses, holidays, and absences with substitution system.
|
||||||
|
|
||||||
## External Dependencies
|
## External Dependencies
|
||||||
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
|
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
|
||||||
@ -83,7 +106,7 @@ The system handles timezone conversions for shift times, converting Italy local
|
|||||||
- **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.
|
- **PM2**: Production process manager for Node.js applications.
|
||||||
- **Nominatim**: OpenStreetMap geocoding API for automatic address-to-coordinates conversion.
|
- **Nginx**: As a reverse proxy for the production environment.
|
||||||
- **OSRM (Open Source Routing Machine)**: Public API (router.project-osrm.org) for distance matrix calculation and route optimization in Planning Mobile. No authentication required.
|
- **Let's Encrypt**: For SSL/TLS certificates.
|
||||||
- **@dnd-kit**: Drag-and-drop library (@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities) for interactive patrol route reordering.
|
- **GitLab CI/CD**: For continuous integration and deployment.
|
||||||
|
|||||||
@ -138,37 +138,12 @@ export async function setupLocalAuth(app: Express) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Route login locale POST
|
// Route login locale POST
|
||||||
app.post("/api/local-login", (req, res, next) => {
|
app.post("/api/local-login", passport.authenticate("local"), (req, res) => {
|
||||||
passport.authenticate("local", (err: any, user: any, info: any) => {
|
res.json({
|
||||||
if (err) {
|
success: true,
|
||||||
return res.status(500).json({
|
user: req.user,
|
||||||
success: false,
|
message: "Login effettuato con successo"
|
||||||
message: "Errore durante il login"
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
message: info?.message || "Email o password non corretti"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
req.login(user, (loginErr) => {
|
|
||||||
if (loginErr) {
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "Errore durante il login"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
user: req.user,
|
|
||||||
message: "Login effettuato con successo"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})(req, res, next);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2077
server/routes.ts
2077
server/routes.ts
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,6 @@ import {
|
|||||||
guards,
|
guards,
|
||||||
certifications,
|
certifications,
|
||||||
vehicles,
|
vehicles,
|
||||||
customers,
|
|
||||||
sites,
|
sites,
|
||||||
shifts,
|
shifts,
|
||||||
shiftAssignments,
|
shiftAssignments,
|
||||||
@ -27,8 +26,6 @@ import {
|
|||||||
type InsertCertification,
|
type InsertCertification,
|
||||||
type Vehicle,
|
type Vehicle,
|
||||||
type InsertVehicle,
|
type InsertVehicle,
|
||||||
type Customer,
|
|
||||||
type InsertCustomer,
|
|
||||||
type Site,
|
type Site,
|
||||||
type InsertSite,
|
type InsertSite,
|
||||||
type Shift,
|
type Shift,
|
||||||
@ -88,13 +85,6 @@ export interface IStorage {
|
|||||||
updateServiceType(id: string, serviceType: Partial<InsertServiceType>): Promise<ServiceType | undefined>;
|
updateServiceType(id: string, serviceType: Partial<InsertServiceType>): Promise<ServiceType | undefined>;
|
||||||
deleteServiceType(id: string): Promise<ServiceType | undefined>;
|
deleteServiceType(id: string): Promise<ServiceType | undefined>;
|
||||||
|
|
||||||
// Customer operations
|
|
||||||
getAllCustomers(): Promise<Customer[]>;
|
|
||||||
getCustomer(id: string): Promise<Customer | undefined>;
|
|
||||||
createCustomer(customer: InsertCustomer): Promise<Customer>;
|
|
||||||
updateCustomer(id: string, customer: Partial<InsertCustomer>): Promise<Customer | undefined>;
|
|
||||||
deleteCustomer(id: string): Promise<Customer | undefined>;
|
|
||||||
|
|
||||||
// Site operations
|
// Site operations
|
||||||
getAllSites(): Promise<Site[]>;
|
getAllSites(): Promise<Site[]>;
|
||||||
getSite(id: string): Promise<Site | undefined>;
|
getSite(id: string): Promise<Site | undefined>;
|
||||||
@ -352,35 +342,6 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customer operations
|
|
||||||
async getAllCustomers(): Promise<Customer[]> {
|
|
||||||
return await db.select().from(customers).orderBy(desc(customers.createdAt));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCustomer(id: string): Promise<Customer | undefined> {
|
|
||||||
const [customer] = await db.select().from(customers).where(eq(customers.id, id));
|
|
||||||
return customer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCustomer(customer: InsertCustomer): Promise<Customer> {
|
|
||||||
const [newCustomer] = await db.insert(customers).values(customer).returning();
|
|
||||||
return newCustomer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCustomer(id: string, customerData: Partial<InsertCustomer>): Promise<Customer | undefined> {
|
|
||||||
const [updated] = await db
|
|
||||||
.update(customers)
|
|
||||||
.set({ ...customerData, updatedAt: new Date() })
|
|
||||||
.where(eq(customers.id, id))
|
|
||||||
.returning();
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteCustomer(id: string): Promise<Customer | undefined> {
|
|
||||||
const [deleted] = await db.delete(customers).where(eq(customers.id, id)).returning();
|
|
||||||
return deleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Site operations
|
// Site operations
|
||||||
async getAllSites(): Promise<Site[]> {
|
async getAllSites(): Promise<Site[]> {
|
||||||
return await db.select().from(sites);
|
return await db.select().from(sites);
|
||||||
|
|||||||
136
shared/schema.ts
136
shared/schema.ts
@ -91,11 +91,6 @@ export const locationEnum = pgEnum("location", [
|
|||||||
"roma", // Sede Roma
|
"roma", // Sede Roma
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const serviceClassificationEnum = pgEnum("service_classification", [
|
|
||||||
"fisso", // Presidio fisso - Planning Fissi
|
|
||||||
"mobile", // Pattuglie/ronde/interventi - Planning Mobile
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ============= SESSION & AUTH TABLES (Replit Auth) =============
|
// ============= SESSION & AUTH TABLES (Replit Auth) =============
|
||||||
|
|
||||||
// Session storage table - mandatory for Replit Auth
|
// Session storage table - mandatory for Replit Auth
|
||||||
@ -194,9 +189,6 @@ export const serviceTypes = pgTable("service_types", {
|
|||||||
icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide
|
icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide
|
||||||
color: varchar("color").notNull().default("blue"), // blue, green, purple, orange
|
color: varchar("color").notNull().default("blue"), // blue, green, purple, orange
|
||||||
|
|
||||||
// ✅ NUOVO: Classificazione servizio - determina quale planning usare
|
|
||||||
classification: serviceClassificationEnum("classification").notNull().default("fisso"),
|
|
||||||
|
|
||||||
// Parametri specifici per tipo servizio
|
// Parametri specifici per tipo servizio
|
||||||
fixedPostHours: integer("fixed_post_hours"), // Ore presidio fisso (es. 8, 12)
|
fixedPostHours: integer("fixed_post_hours"), // Ore presidio fisso (es. 8, 12)
|
||||||
patrolPassages: integer("patrol_passages"), // Numero passaggi pattugliamento (es. 3, 5)
|
patrolPassages: integer("patrol_passages"), // Numero passaggi pattugliamento (es. 3, 5)
|
||||||
@ -208,35 +200,13 @@ export const serviceTypes = pgTable("service_types", {
|
|||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============= CUSTOMERS =============
|
|
||||||
|
|
||||||
export const customers = pgTable("customers", {
|
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
|
||||||
name: varchar("name").notNull(),
|
|
||||||
businessName: varchar("business_name"), // Ragione sociale
|
|
||||||
vatNumber: varchar("vat_number"), // Partita IVA
|
|
||||||
fiscalCode: varchar("fiscal_code"), // Codice fiscale
|
|
||||||
address: varchar("address"),
|
|
||||||
city: varchar("city"),
|
|
||||||
province: varchar("province"),
|
|
||||||
zipCode: varchar("zip_code"),
|
|
||||||
phone: varchar("phone"),
|
|
||||||
email: varchar("email"),
|
|
||||||
pec: varchar("pec"), // PEC (Posta Elettronica Certificata)
|
|
||||||
contactPerson: varchar("contact_person"), // Referente
|
|
||||||
notes: text("notes"),
|
|
||||||
isActive: boolean("is_active").default(true),
|
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============= SITES & CONTRACTS =============
|
// ============= SITES & CONTRACTS =============
|
||||||
|
|
||||||
export const sites = pgTable("sites", {
|
export const sites = pgTable("sites", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
address: varchar("address").notNull(),
|
address: varchar("address").notNull(),
|
||||||
customerId: varchar("customer_id").references(() => customers.id),
|
clientId: varchar("client_id").references(() => users.id),
|
||||||
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale
|
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale
|
||||||
|
|
||||||
// Service requirements
|
// Service requirements
|
||||||
@ -297,50 +267,6 @@ export const shiftAssignments = pgTable("shift_assignments", {
|
|||||||
// Actual check-in/out times (recorded when guard clocks in/out)
|
// Actual check-in/out times (recorded when guard clocks in/out)
|
||||||
checkInTime: timestamp("check_in_time"),
|
checkInTime: timestamp("check_in_time"),
|
||||||
checkOutTime: timestamp("check_out_time"),
|
checkOutTime: timestamp("check_out_time"),
|
||||||
|
|
||||||
// Dotazioni operative per questo turno specifico
|
|
||||||
isArmedOnDuty: boolean("is_armed_on_duty").default(false), // Guardia armata per questo turno
|
|
||||||
assignedVehicleId: varchar("assigned_vehicle_id").references(() => vehicles.id, { onDelete: "set null" }), // Automezzo assegnato
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============= PATROL ROUTES (TURNI PATTUGLIA) =============
|
|
||||||
|
|
||||||
export const patrolRoutes = pgTable("patrol_routes", {
|
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
|
||||||
guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }),
|
|
||||||
|
|
||||||
// Data e orari del turno pattuglia
|
|
||||||
shiftDate: date("shift_date").notNull(), // Data del turno
|
|
||||||
startTime: varchar("start_time").notNull(), // Orario inizio (HH:MM)
|
|
||||||
endTime: varchar("end_time").notNull(), // Orario fine (HH:MM)
|
|
||||||
|
|
||||||
status: shiftStatusEnum("status").notNull().default("planned"),
|
|
||||||
location: locationEnum("location").notNull(), // Sede di riferimento
|
|
||||||
|
|
||||||
// Dotazioni
|
|
||||||
vehicleId: varchar("vehicle_id").references(() => vehicles.id, { onDelete: "set null" }),
|
|
||||||
isArmedRoute: boolean("is_armed_route").default(false), // Percorso con guardia armata
|
|
||||||
|
|
||||||
notes: text("notes"),
|
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const patrolRouteStops = pgTable("patrol_route_stops", {
|
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
|
||||||
patrolRouteId: varchar("patrol_route_id").notNull().references(() => patrolRoutes.id, { onDelete: "cascade" }),
|
|
||||||
siteId: varchar("site_id").notNull().references(() => sites.id, { onDelete: "cascade" }),
|
|
||||||
|
|
||||||
sequenceOrder: integer("sequence_order").notNull(), // Ordine nel percorso (1, 2, 3...)
|
|
||||||
estimatedArrivalTime: varchar("estimated_arrival_time"), // Orario stimato arrivo (HH:MM)
|
|
||||||
actualArrivalTime: timestamp("actual_arrival_time"), // Orario effettivo arrivo
|
|
||||||
|
|
||||||
// Check completamento tappa
|
|
||||||
isCompleted: boolean("is_completed").default(false),
|
|
||||||
completedAt: timestamp("completed_at"),
|
|
||||||
|
|
||||||
notes: text("notes"), // Note specifiche per questa tappa
|
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============= CCNL SETTINGS =============
|
// ============= CCNL SETTINGS =============
|
||||||
@ -552,6 +478,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({
|
|||||||
fields: [users.id],
|
fields: [users.id],
|
||||||
references: [guards.userId],
|
references: [guards.userId],
|
||||||
}),
|
}),
|
||||||
|
managedSites: many(sites),
|
||||||
notifications: many(notifications),
|
notifications: many(notifications),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -562,7 +489,6 @@ export const guardsRelations = relations(guards, ({ one, many }) => ({
|
|||||||
}),
|
}),
|
||||||
certifications: many(certifications),
|
certifications: many(certifications),
|
||||||
shiftAssignments: many(shiftAssignments),
|
shiftAssignments: many(shiftAssignments),
|
||||||
patrolRoutes: many(patrolRoutes),
|
|
||||||
constraints: one(guardConstraints),
|
constraints: one(guardConstraints),
|
||||||
sitePreferences: many(sitePreferences),
|
sitePreferences: many(sitePreferences),
|
||||||
trainingCourses: many(trainingCourses),
|
trainingCourses: many(trainingCourses),
|
||||||
@ -584,17 +510,12 @@ export const certificationsRelations = relations(certifications, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const customersRelations = relations(customers, ({ many }) => ({
|
|
||||||
sites: many(sites),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const sitesRelations = relations(sites, ({ one, many }) => ({
|
export const sitesRelations = relations(sites, ({ one, many }) => ({
|
||||||
customer: one(customers, {
|
client: one(users, {
|
||||||
fields: [sites.customerId],
|
fields: [sites.clientId],
|
||||||
references: [customers.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
shifts: many(shifts),
|
shifts: many(shifts),
|
||||||
patrolRouteStops: many(patrolRouteStops),
|
|
||||||
preferences: many(sitePreferences),
|
preferences: many(sitePreferences),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -619,33 +540,6 @@ export const shiftAssignmentsRelations = relations(shiftAssignments, ({ one }) =
|
|||||||
fields: [shiftAssignments.guardId],
|
fields: [shiftAssignments.guardId],
|
||||||
references: [guards.id],
|
references: [guards.id],
|
||||||
}),
|
}),
|
||||||
assignedVehicle: one(vehicles, {
|
|
||||||
fields: [shiftAssignments.assignedVehicleId],
|
|
||||||
references: [vehicles.id],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const patrolRoutesRelations = relations(patrolRoutes, ({ one, many }) => ({
|
|
||||||
guard: one(guards, {
|
|
||||||
fields: [patrolRoutes.guardId],
|
|
||||||
references: [guards.id],
|
|
||||||
}),
|
|
||||||
vehicle: one(vehicles, {
|
|
||||||
fields: [patrolRoutes.vehicleId],
|
|
||||||
references: [vehicles.id],
|
|
||||||
}),
|
|
||||||
stops: many(patrolRouteStops),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const patrolRouteStopsRelations = relations(patrolRouteStops, ({ one }) => ({
|
|
||||||
patrolRoute: one(patrolRoutes, {
|
|
||||||
fields: [patrolRouteStops.patrolRouteId],
|
|
||||||
references: [patrolRoutes.id],
|
|
||||||
}),
|
|
||||||
site: one(sites, {
|
|
||||||
fields: [patrolRouteStops.siteId],
|
|
||||||
references: [sites.id],
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
@ -786,12 +680,6 @@ export const insertServiceTypeSchema = createInsertSchema(serviceTypes).omit({
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const insertCustomerSchema = createInsertSchema(customers).omit({
|
|
||||||
id: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const insertSiteSchema = createInsertSchema(sites).omit({
|
export const insertSiteSchema = createInsertSchema(sites).omit({
|
||||||
id: true,
|
id: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@ -804,17 +692,6 @@ export const insertShiftSchema = createInsertSchema(shifts).omit({
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const insertPatrolRouteSchema = createInsertSchema(patrolRoutes).omit({
|
|
||||||
id: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const insertPatrolRouteStopSchema = createInsertSchema(patrolRouteStops).omit({
|
|
||||||
id: true,
|
|
||||||
createdAt: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form schema that accepts datetime strings and transforms to Date
|
// Form schema that accepts datetime strings and transforms to Date
|
||||||
export const insertShiftFormSchema = z.object({
|
export const insertShiftFormSchema = z.object({
|
||||||
siteId: z.string().min(1, "Sito obbligatorio"),
|
siteId: z.string().min(1, "Sito obbligatorio"),
|
||||||
@ -917,9 +794,6 @@ export type Vehicle = typeof vehicles.$inferSelect;
|
|||||||
export type InsertServiceType = z.infer<typeof insertServiceTypeSchema>;
|
export type InsertServiceType = z.infer<typeof insertServiceTypeSchema>;
|
||||||
export type ServiceType = typeof serviceTypes.$inferSelect;
|
export type ServiceType = typeof serviceTypes.$inferSelect;
|
||||||
|
|
||||||
export type InsertCustomer = z.infer<typeof insertCustomerSchema>;
|
|
||||||
export type Customer = typeof customers.$inferSelect;
|
|
||||||
|
|
||||||
export type InsertSite = z.infer<typeof insertSiteSchema>;
|
export type InsertSite = z.infer<typeof insertSiteSchema>;
|
||||||
export type Site = typeof sites.$inferSelect;
|
export type Site = typeof sites.$inferSelect;
|
||||||
|
|
||||||
|
|||||||
232
version.json
232
version.json
@ -1,169 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.1",
|
"version": "1.0.33",
|
||||||
"lastUpdate": "2025-11-15T10:11:44.404Z",
|
"lastUpdate": "2025-10-22T08:52:21.600Z",
|
||||||
"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",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.48"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.47",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.47"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.46",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.46"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.45",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.45"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.44",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.44"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.43",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.43"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.42",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.42"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.41",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.41"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.40",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.39",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.39"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.38",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.38"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.37",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.37"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.36",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.36"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.35",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.35"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "1.0.34",
|
|
||||||
"date": "2025-10-23",
|
|
||||||
"type": "patch",
|
|
||||||
"description": "Deployment automatico v1.0.34"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "1.0.33",
|
"version": "1.0.33",
|
||||||
"date": "2025-10-22",
|
"date": "2025-10-22",
|
||||||
@ -301,6 +139,72 @@
|
|||||||
"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