Compare commits

..

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

78 changed files with 421 additions and 13326 deletions

20
.replit
View File

@ -19,34 +19,14 @@ 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
[[ports]]
localPort = 41803
externalPort = 4200
[[ports]] [[ports]]
localPort = 42175 localPort = 42175
externalPort = 3002 externalPort = 3002
[[ports]]
localPort = 42187
externalPort = 6800
[[ports]]
localPort = 43169
externalPort = 5000
[[ports]] [[ports]]
localPort = 43267 localPort = 43267
externalPort = 3003 externalPort = 3003

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -1,271 +0,0 @@
Perfetto, Marco. Ti preparo un “task brief” chiaro e operativo da dare a Replit (o ad un dev) così implementano senza ambiguità. Puoi incollarlo così comè.
---
# Task: Vista “Pianificazione Operativa” con disponibilità per **data**
## Obiettivo
Realizzare nella pagina **Pianificazione Operativa**:
1. Un **selettore data** che filtra e calcola **disponibilità** di automezzi e agenti **alla data selezionata**.
2. Unarea centrale **divisa in due colonne**:
* **Sinistra**: automezzi (ordinati per disponibilità).
* **Destra**: agenti (ordinati per disponibilità + priorità CCNL).
3. Ogni card (mezzo/agente) mostra **stato**: “Disponibile” (in alto) oppure “Non disponibile: assegnato a Turno #ID/Servizio”.
4. **Pulsante “Assegna servizio”** su ciascuna card, che apre il flusso dassegnazione (mezzo/agente → servizio/turno alla data).
5. Per gli **agenti**: lordinamento “disponibili” deve privilegiare chi ha **meno ore già programmate** nel periodo di riferimento e **rispetta i vincoli CCNL**.
---
## Definizioni rapide
* **Data di riferimento D**: la data impostata dal selettore (default = oggi).
* **Periodo contabile**: ISO week della data D (lundom) **configurabile** (`settings.ccnl.reference_period = 'week'|'month'`); default = settimana corrente.
* **Turno**: intervallo con inizio/fine, collegato a un servizio e a risorse (agenti/mezzi).
* **Disponibilità**:
* Mezzo disponibile se **non assegnato** in alcun turno che **intersechi D** e non in manutenzione/fermo.
* Agente disponibile se **non in turno** in D e se lassegnazione proposta **rispetta i vincoli CCNL** (vedi sotto).
---
## Vincoli CCNL (parametrizzabili)
Creare un modulo `ccnlRules` con parametri in tabella/config:
```
max_hours_per_week: 48
max_hours_per_month: 190
min_rest_hours_between_shifts: 11
max_night_shifts_consecutive: 6
max_days_consecutive: 6
min_weekly_rest_hours: 24
overtime_threshold_week: 40
penalty_if_overtime: true
```
> NB: i valori sono **segnaposto**; leggere da `settings_ccnl` per cliente. Se non presenti, usare default.
**Funzione chiave**: `ccnlRules.isEligible(agentId, proposedShift, dateD) -> {eligible: boolean, reasons: string[]}`
Controlli minimi:
* Riposo minimo tra fine ultimo turno e inizio `proposedShift.start` ≥ `min_rest_hours_between_shifts`.
* Ore accumulate nel periodo di riferimento + ore del turno proposto ≤ limiti (`max_hours_per_week`/`month`).
* Vincoli su notturno/consecutività/giorni consecutivi/rest settimanale (se dati).
---
## Ordinamento
### Automezzi (colonna sinistra)
1. **Disponibili** in alto, poi **Non disponibili**.
2. Tra i disponibili: opzionale secondario per **priorità mezzo** (es. min km, categoria, stato batteria/serbatoio se disponibile).
3. Tra i non disponibili: ordina per **ora di fine del turno corrente** (prima quelli che si liberano prima).
### Agenti (colonna destra)
1. **Disponibili & CCNL-eligible** in alto.
2. Ordinare i disponibili per:
* **OreTotaliPeriodo (crescente)** = somma ore dei turni dellagente nel periodo di riferimento della data D.
* In caso di pari ore: meno straordinari > più straordinari; poi alfabetico.
3. Poi **Non disponibili** con motivazione (es. “Assegnato a Turno #123 08:0016:00”).
---
## UI/UX (wireframe funzionale)
* Header: `DatePicker [D]` + (opz) `Periodo: settimana/mese` (solo admin).
* Body: **Grid 2 colonne responsive** (>= md: 2 colonne; < md: stack).
* **Colonna Sinistra Automezzi**
* Card Mezzo: `targa | modello | stato`
* Badge: `Disponibile` / `Non disponibile Turno #ID hh:mmhh:mm (Servizio)`
* (se non disponibile) small link “vedi turno/servizio”
* CTA: **Assegna servizio**
* **Colonna Destra Agenti**
* Card Agente: `cognome nome | qualifica`
* Badge: `Disponibile` / `Non disponibile Turno #ID`
* Righe info: `Ore settimana: Xh`, `Straordinari: Yh`, eventuali warning CCNL (tooltip)
* CTA: **Assegna servizio**
* Click “Assegna servizio” → apre **modal**:
* Se chiamato da un **mezzo**: pre-seleziona mezzo, mostra servizi/turni disponibili in D, scegli agente.
* Se chiamato da un **agente**: pre-seleziona agente, mostra servizi/turni disponibili in D, scegli mezzo.
* **Validazione live CCNL**: mostra `✅ Conforme` o `❌ Motivi`.
---
## Modello dati (minimo)
```ts
// Tabella veicoli
Vehicle { id, plate, model, status: 'active'|'maintenance'|'out', priority?: number }
// Tabella agenti
Agent { id, first_name, last_name, qualification, enabled: boolean }
// Turni (calendar)
Shift {
id, service_id, start_at: datetime, end_at: datetime,
assigned_agent_id?: id, assigned_vehicle_id?: id
}
// Servizi
Service { id, name, site, notes }
// Parametri CCNL (per tenant/cliente)
SettingsCCNL { key, value }
```
---
## API (contratti)
* `GET /planning?date=YYYY-MM-DD`
* Ritorna:
```json
{
"date": "2025-10-17",
"vehicles": [
{ "id": 12, "plate": "AB123CD", "status": "available"|"busy",
"busy_shift": { "id": 77, "start_at": "...", "end_at": "...", "service_name": "..." } | null }
],
"agents": [
{ "id": 9, "name": "Rossi Mario",
"status": "available"|"busy",
"ccnl_eligible": true|false,
"hours_period": 28.0,
"overtime_hours": 3.0,
"busy_shift": { ... } | null,
"ccnl_notes": ["min rest ok", "within weekly cap"] }
]
}
```
* `POST /assign`
* Body:
```json
{ "date": "YYYY-MM-DD", "service_id": 10, "shift": {"start_at":"...","end_at":"..."}, "agent_id": 9, "vehicle_id": 12 }
```
* Validazioni: overlap, veicolo libero, `ccnlRules.isEligible(...)`.
* Risposte: `200 {ok:true, created_shift_id}` | `422 {ok:false, errors:[...]}`
* `GET /ccnl/preview?agent_id=&start_at=&end_at=`
* Ritorna esito eleggibilità e motivi.
---
## Logica (pseudocodice)
```pseudo
function getPlanning(date D):
period = resolvePeriod(D, settings.ccnl.reference_period)
shiftsOnD = shifts.where(overlaps(shift, D))
shiftsInPeriod = shifts.where(overlaps(shift, period))
vehicles = allVehicles.map(v):
s = find(shiftsOnD, assigned_vehicle_id == v.id)
if s exists or v.status == 'maintenance':
status = 'busy'
busy_shift = s
else status = 'available'
return { ... }
agents = allAgents.enabledOnly().map(a):
s = find(shiftsOnD, assigned_agent_id == a.id)
hours = sumHours(shiftsInPeriod, assigned_agent_id == a.id)
if s exists:
status='busy'; eligible=false
else:
eligible = ccnlRules.isEligible(a.id, proposedShiftForD?, D).eligible
status='available' if eligible else 'available' (ma con ccnl_eligible=false)
overtime = max(0, hours - settings.ccnl.overtime_threshold_week)
return { id:a.id, name, status, ccnl_eligible:eligible, hours_period:hours,
overtime_hours:overtime, busy_shift:s, ccnl_notes:... }
sort vehicles: available first, then by (busy_shift.end_at asc)
sort agents:
1) available & ccnl_eligible first
2) by hours_period asc
3) by overtime_hours asc
4) by last_name asc
return { date:D, vehicles, agents }
```
> `proposedShiftForD?`: se lassegnazione è per uno slot predefinito quel giorno (es. 0816), passarlo per validazione live; se non cè, `isEligible` valuta solo i vincoli “statici” (riposo/limiti alla data).
---
## Casi limite da coprire (test)
* Mezzo in **manutenzione** → sempre **Non disponibile** con label “Manutenzione”.
* Agente con turno che **sconfina** su D (es. notte 22:0006:00) → è **Non disponibile** in D.
* Overlap di turni → `POST /assign` deve restituire `422` con errore “overlap”.
* CCNL: riposo < 11h tra fine turno precedente e inizio proposto → `eligible=false` + motivo.
* Periodo mensile vs settimanale: calcolo ore coerente al setting.
* Ordinamento agenti: a pari ore e overtime, ordinamento alfabetico stabile.
* Fuso orario: usare TZ server/tenant, no ambiguità su DST.
---
## Accettazione (DoD)
* Selettore data funziona; ricarica lista con calcoli corretti.
* Colonne mezzi/agentI con **badge stato** e **CTA “Assegna servizio”** operative.
* Ordinamenti rispettati come da regole.
* API `GET /planning` e `POST /assign` implementate con validazioni CCNL.
* Copertura test unit ≥ 80% per `ccnlRules` e funzioni di calcolo ore/overlap.
* Performance: risposta `GET /planning` ≤ 300 ms con dataset medio (500 agenti, 200 mezzi, 2k turni/sett).
---
## Esempi (mock)
### GET /planning?date=2025-10-17
```json
{
"date": "2025-10-17",
"vehicles": [
{ "id": 12, "plate": "AB123CD", "status": "available", "busy_shift": null },
{ "id": 7, "plate": "ZZ987YY", "status": "busy",
"busy_shift": { "id": 331, "start_at":"2025-10-17T06:00:00Z","end_at":"2025-10-17T14:00:00Z","service_name":"Pattuglia 3"}}
],
"agents": [
{ "id": 9, "name": "Rossi Mario", "status":"available", "ccnl_eligible": true, "hours_period": 24, "overtime_hours": 0, "busy_shift": null, "ccnl_notes": ["rest ok", "within weekly cap"] },
{ "id": 4, "name": "Bianchi Luca", "status":"available", "ccnl_eligible": false, "hours_period": 38, "overtime_hours": 0, "busy_shift": null, "ccnl_notes": ["rest < 11h"] },
{ "id": 2, "name": "Verdi Anna", "status":"busy", "ccnl_eligible": false, "hours_period": 32, "overtime_hours": 0,
"busy_shift": { "id": 329, "start_at":"2025-10-17T08:00:00Z","end_at":"2025-10-17T16:00:00Z","service_name":"Portierato Ospedale"}}
]
}
```
---
## Note di implementazione
* **Timezone**: salvare in UTC, presentare in TZ tenant. Usare libreria affidabile per overlap con turni notturni.
* **Configurazione CCNL** per cliente/ente: tabella `settings_ccnl(tenant_id, key, value)` + cache.
* **Sicurezza**: endpoint protetti (ruolo operatore/dispatcher).
* **Accessibilità**: badge con testo + icona, contrasto AA.
* **Telemetry**: loggare motivi di `ineligible` per audit.
---
Se vuoi, alla prossima iterazione posso trasformare questo brief in:
* scheletro **API (Node/Express + Prisma)**,
* componenti **React/Next** per la UI (grid 2 colonne, card, modal assegnazione),
* modulo `ccnlRules` completo con test unit.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View File

@ -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>

View File

@ -22,15 +22,6 @@ import Vehicles from "@/pages/vehicles";
import Parameters from "@/pages/parameters"; import Parameters from "@/pages/parameters";
import Services from "@/pages/services"; import Services from "@/pages/services";
import Planning from "@/pages/planning"; import Planning from "@/pages/planning";
import OperationalPlanning from "@/pages/operational-planning";
import GeneralPlanning from "@/pages/general-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,20 +36,11 @@ 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} />
<Route path="/planning" component={Planning} /> <Route path="/planning" component={Planning} />
<Route path="/operational-planning" component={OperationalPlanning} />
<Route path="/general-planning" component={GeneralPlanning} />
<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} />

View File

@ -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,70 @@ const menuItems: MenuItem[] = [
roles: ["admin", "coordinator", "guard", "client"], roles: ["admin", "coordinator", "guard", "client"],
}, },
{ {
title: "Planning", title: "Turni",
icon: FolderKanban, url: "/shifts",
roles: ["admin", "coordinator"],
items: [
{
title: "Fissi",
url: "/general-planning",
icon: Calendar,
roles: ["admin", "coordinator"],
},
{
title: "Mobili",
url: "/planning-mobile",
icon: Navigation,
roles: ["admin", "coordinator"],
},
{
title: "Vista",
url: "/service-planning",
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Guardie Settimanale",
url: "/weekly-guards",
icon: Users,
roles: ["admin", "coordinator"],
},
],
},
{
title: "Scadenziario",
url: "/advanced-planning",
icon: Calendar, icon: Calendar,
roles: ["admin", "coordinator", "guard"],
},
{
title: "Pianificazione",
url: "/planning",
icon: ClipboardList,
roles: ["admin", "coordinator"], roles: ["admin", "coordinator"],
}, },
{ {
title: "Anagrafiche", title: "Gestione Pianificazioni",
icon: Building2, url: "/advanced-planning",
icon: ClipboardList,
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: "Guardie",
icon: Wrench, 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 +109,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 +130,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>

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,6 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { insertGuardSchema, insertCertificationSchema } from "@shared/schema"; import { insertGuardSchema, insertCertificationSchema } from "@shared/schema";
@ -32,12 +31,8 @@ export default function Guards() {
const form = useForm<InsertGuard>({ const form = useForm<InsertGuard>({
resolver: zodResolver(insertGuardSchema), resolver: zodResolver(insertGuardSchema),
defaultValues: { defaultValues: {
firstName: "",
lastName: "",
email: "",
badgeNumber: "", badgeNumber: "",
phoneNumber: "", phoneNumber: "",
location: "roccapiemonte",
isArmed: false, isArmed: false,
hasFireSafety: false, hasFireSafety: false,
hasFirstAid: false, hasFirstAid: false,
@ -49,12 +44,8 @@ export default function Guards() {
const editForm = useForm<InsertGuard>({ const editForm = useForm<InsertGuard>({
resolver: zodResolver(insertGuardSchema), resolver: zodResolver(insertGuardSchema),
defaultValues: { defaultValues: {
firstName: "",
lastName: "",
email: "",
badgeNumber: "", badgeNumber: "",
phoneNumber: "", phoneNumber: "",
location: "roccapiemonte",
isArmed: false, isArmed: false,
hasFireSafety: false, hasFireSafety: false,
hasFirstAid: false, hasFirstAid: false,
@ -120,12 +111,8 @@ export default function Guards() {
const openEditDialog = (guard: GuardWithCertifications) => { const openEditDialog = (guard: GuardWithCertifications) => {
setEditingGuard(guard); setEditingGuard(guard);
editForm.reset({ editForm.reset({
firstName: guard.firstName || "",
lastName: guard.lastName || "",
email: guard.email || "",
badgeNumber: guard.badgeNumber, badgeNumber: guard.badgeNumber,
phoneNumber: guard.phoneNumber || "", phoneNumber: guard.phoneNumber || "",
location: guard.location || "roccapiemonte",
isArmed: guard.isArmed, isArmed: guard.isArmed,
hasFireSafety: guard.hasFireSafety, hasFireSafety: guard.hasFireSafety,
hasFirstAid: guard.hasFirstAid, hasFirstAid: guard.hasFirstAid,
@ -159,50 +146,6 @@ export default function Guards() {
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input placeholder="Mario" {...field} data-testid="input-first-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Cognome</FormLabel>
<FormControl>
<Input placeholder="Rossi" {...field} data-testid="input-last-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="mario.rossi@esempio.it" type="email" {...field} value={field.value || ""} data-testid="input-email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="badgeNumber" name="badgeNumber"
@ -222,7 +165,7 @@ export default function Guards() {
name="phoneNumber" name="phoneNumber"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Cellulare</FormLabel> <FormLabel>Telefono</FormLabel>
<FormControl> <FormControl>
<Input placeholder="+39 123 456 7890" {...field} value={field.value || ""} data-testid="input-phone" /> <Input placeholder="+39 123 456 7890" {...field} value={field.value || ""} data-testid="input-phone" />
</FormControl> </FormControl>
@ -231,29 +174,6 @@ export default function Guards() {
)} )}
/> />
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede di Appartenenza</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger data-testid="select-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium">Competenze</p> <p className="text-sm font-medium">Competenze</p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -342,55 +262,11 @@ export default function Guards() {
<DialogHeader> <DialogHeader>
<DialogTitle>Modifica Guardia</DialogTitle> <DialogTitle>Modifica Guardia</DialogTitle>
<DialogDescription> <DialogDescription>
Modifica i dati della guardia {editingGuard?.firstName} {editingGuard?.lastName} Modifica i dati della guardia {editingGuard?.user?.firstName} {editingGuard?.user?.lastName}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...editForm}> <Form {...editForm}>
<form onSubmit={editForm.handleSubmit(onEditSubmit)} className="space-y-4"> <form onSubmit={editForm.handleSubmit(onEditSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input placeholder="Mario" {...field} data-testid="input-edit-first-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Cognome</FormLabel>
<FormControl>
<Input placeholder="Rossi" {...field} data-testid="input-edit-last-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={editForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="mario.rossi@esempio.it" type="email" {...field} value={field.value || ""} data-testid="input-edit-email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={editForm.control} control={editForm.control}
name="badgeNumber" name="badgeNumber"
@ -410,7 +286,7 @@ export default function Guards() {
name="phoneNumber" name="phoneNumber"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Cellulare</FormLabel> <FormLabel>Telefono</FormLabel>
<FormControl> <FormControl>
<Input placeholder="+39 123 456 7890" {...field} value={field.value || ""} data-testid="input-edit-phone" /> <Input placeholder="+39 123 456 7890" {...field} value={field.value || ""} data-testid="input-edit-phone" />
</FormControl> </FormControl>
@ -419,29 +295,6 @@ export default function Guards() {
)} )}
/> />
<FormField
control={editForm.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede di Appartenenza</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger data-testid="select-edit-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium">Competenze</p> <p className="text-sm font-medium">Competenze</p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -538,20 +391,15 @@ export default function Guards() {
<Avatar> <Avatar>
<AvatarImage src={guard.user?.profileImageUrl || undefined} /> <AvatarImage src={guard.user?.profileImageUrl || undefined} />
<AvatarFallback> <AvatarFallback>
{guard.firstName?.[0]}{guard.lastName?.[0]} {guard.user?.firstName?.[0]}{guard.user?.lastName?.[0]}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate"> <CardTitle className="text-lg truncate">
{guard.firstName} {guard.lastName} {guard.user?.firstName} {guard.user?.lastName}
</CardTitle> </CardTitle>
<CardDescription className="space-y-0.5"> <CardDescription className="font-mono text-xs">
<div className="font-mono text-xs">{guard.badgeNumber}</div> {guard.badgeNumber}
{guard.email && <div className="text-xs truncate">{guard.email}</div>}
{guard.phoneNumber && <div className="text-xs">{guard.phoneNumber}</div>}
<Badge variant="outline" className="text-xs mt-1">
{guard.location === "roccapiemonte" ? "Roccapiemonte" : guard.location === "milano" ? "Milano" : "Roma"}
</Badge>
</CardDescription> </CardDescription>
</div> </div>
<Button <Button

View File

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

View File

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

View File

@ -1,600 +0,0 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Calendar, AlertCircle, CheckCircle2, Clock, MapPin, Users, Shield, Car as CarIcon, Building2 } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { format } from "date-fns";
import { it } from "date-fns/locale";
import { useToast } from "@/hooks/use-toast";
import { useLocation } from "wouter";
interface Shift {
id: string;
startTime: string;
endTime: string;
assignedGuardsCount: number;
requiredGuards: number;
isCovered: boolean;
isPartial: boolean;
}
interface UncoveredSite {
id: string;
name: string;
address: string;
location: string;
shiftType: string;
minGuards: number;
requiresArmed: boolean;
requiresDriverLicense: boolean;
serviceStartTime: string | null;
serviceEndTime: string | null;
isCovered: boolean;
isPartiallyCovered: boolean;
totalAssignedGuards: number;
requiredGuards: number;
shiftsCount: number;
shifts: Shift[];
}
interface UncoveredSitesData {
date: string;
uncoveredSites: UncoveredSite[];
totalSites: number;
totalUncovered: number;
}
interface Vehicle {
id: string;
licensePlate: string;
brand: string;
model: string;
vehicleType: string;
location: string;
hasDriverLicense?: boolean;
isAvailable: boolean;
}
interface Guard {
id: string;
badgeNumber: string;
userId: string;
firstName?: string;
lastName?: string;
location: string;
isArmed: boolean;
hasDriverLicense: boolean;
isAvailable: boolean;
availability: {
weeklyHours: number;
remainingWeeklyHours: number;
consecutiveDaysWorked: number;
};
}
interface ResourcesData {
date: string;
vehicles: Vehicle[];
guards: Guard[];
}
const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma",
};
export default function OperationalPlanning() {
const { toast } = useToast();
const [location] = useLocation();
// Leggi parametri dalla URL
const searchParams = new URLSearchParams(location.split('?')[1] || '');
const urlDate = searchParams.get('date');
const urlLocation = searchParams.get('location');
const [selectedLocation, setSelectedLocation] = useState<string>(
urlLocation && ['roccapiemonte', 'milano', 'roma'].includes(urlLocation)
? urlLocation
: "roccapiemonte"
);
const [selectedDate, setSelectedDate] = useState<string>(
urlDate || format(new Date(), "yyyy-MM-dd")
);
const [selectedSite, setSelectedSite] = useState<UncoveredSite | null>(null);
const [selectedGuards, setSelectedGuards] = useState<string[]>([]);
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false);
// Aggiorna stato quando cambiano i parametri URL
useEffect(() => {
if (urlDate) setSelectedDate(urlDate);
if (urlLocation && ['roccapiemonte', 'milano', 'roma'].includes(urlLocation)) {
setSelectedLocation(urlLocation);
}
}, [urlDate, urlLocation]);
// Query per siti non coperti (filtrati per sede e data)
const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
queryKey: ['/api/operational-planning/uncovered-sites', selectedDate, selectedLocation],
queryFn: async ({ queryKey }) => {
const [, date, location] = queryKey;
const res = await fetch(`/api/operational-planning/uncovered-sites?date=${date}&location=${location}`, {
credentials: 'include'
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
},
enabled: !!selectedDate && !!selectedLocation,
});
// Query per risorse (veicoli e guardie) - solo quando c'è un sito selezionato
const { data: resourcesData, isLoading: isLoadingResources } = useQuery<ResourcesData>({
queryKey: ['/api/operational-planning/availability', selectedDate, selectedLocation, selectedSite?.id],
queryFn: async ({ queryKey }) => {
const [, date, location] = queryKey;
const res = await fetch(`/api/operational-planning/availability?date=${date}&location=${location}`, {
credentials: 'include'
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
},
enabled: !!selectedDate && !!selectedLocation && !!selectedSite,
});
const handleLocationChange = (location: string) => {
setSelectedLocation(location);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
};
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
};
const handleSelectSite = (site: UncoveredSite) => {
setSelectedSite(site);
setSelectedGuards([]);
setSelectedVehicle(null);
setCreateShiftDialogOpen(true);
};
const toggleGuardSelection = (guardId: string) => {
setSelectedGuards((prev) =>
prev.includes(guardId)
? prev.filter((id) => id !== guardId)
: [...prev, guardId]
);
};
// Filtra risorse per requisiti del sito
const filteredVehicles = resourcesData?.vehicles.filter((v) => {
if (!selectedSite) return false;
// Filtra per sede e disponibilità
if (v.location !== selectedSite.location) return false;
if (!v.isAvailable) return false;
return true;
}) || [];
const filteredGuards = resourcesData?.guards.filter((g) => {
if (!selectedSite) return false;
// Filtra per sede
if (g.location !== selectedSite.location) return false;
// Filtra per disponibilità
if (!g.isAvailable) return false;
// Filtra per requisiti
if (selectedSite.requiresArmed && !g.isArmed) return false;
if (selectedSite.requiresDriverLicense && !g.hasDriverLicense) return false;
return true;
}) || [];
// Mutation per creare turno
const createShiftMutation = useMutation({
mutationFn: async (data: any) => {
return apiRequest("POST", "/api/shifts", data);
},
onSuccess: () => {
// Invalida tutte le query che iniziano con /api/operational-planning/uncovered-sites
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0]?.toString().startsWith('/api/operational-planning/uncovered-sites') || false
});
toast({
title: "Turno creato",
description: "Il turno è stato creato con successo.",
});
setCreateShiftDialogOpen(false);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
},
onError: (error: any) => {
toast({
title: "Errore",
description: error.message || "Impossibile creare il turno.",
variant: "destructive",
});
},
});
const handleCreateShift = () => {
if (!selectedSite) return;
// Valida che ci siano abbastanza guardie selezionate
if (selectedGuards.length < selectedSite.minGuards) {
toast({
title: "Guardie insufficienti",
description: `Seleziona almeno ${selectedSite.minGuards} guardie per questo sito.`,
variant: "destructive",
});
return;
}
// TODO: Qui bisognerà chiedere l'orario del turno
// Per ora creiamo un turno di default basato su serviceStartTime/serviceEndTime del sito
const today = selectedDate;
const startTime = selectedSite.serviceStartTime || "08:00";
const endTime = selectedSite.serviceEndTime || "16:00";
const shiftData = {
siteId: selectedSite.id,
startTime: new Date(`${today}T${startTime}:00.000Z`),
endTime: new Date(`${today}T${endTime}:00.000Z`),
status: "planned",
vehicleId: selectedVehicle || null,
guardIds: selectedGuards,
};
createShiftMutation.mutate(shiftData);
};
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">
<Calendar className="h-8 w-8" />
Pianificazione Operativa
</h1>
<p className="text-muted-foreground mt-1">
Assegna turni ai siti non coperti
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Seleziona Sede e Data
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-4 items-end">
<div className="flex-1 max-w-xs">
<Label htmlFor="planning-location">Sede</Label>
<Select value={selectedLocation} onValueChange={handleLocationChange}>
<SelectTrigger id="planning-location" data-testid="select-planning-location" className="mt-1">
<Building2 className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 max-w-xs">
<Label htmlFor="planning-date">Data</Label>
<Input
id="planning-date"
data-testid="input-planning-date"
type="date"
value={selectedDate}
onChange={handleDateChange}
className="mt-1"
/>
</div>
</div>
{uncoveredData && (
<div className="mt-4 flex items-center gap-4 text-sm">
<p className="text-muted-foreground">
{format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })}
</p>
<Badge variant="outline" className="gap-1">
<AlertCircle className="h-3 w-3" />
{uncoveredData.totalUncovered} siti da coprire
</Badge>
</div>
)}
</CardContent>
</Card>
{/* Lista siti non coperti */}
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
) : uncoveredData && uncoveredData.uncoveredSites.length > 0 ? (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Siti Non Coperti</h2>
{uncoveredData.uncoveredSites.map((site) => (
<Card key={site.id} data-testid={`site-card-${site.id}`}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
{site.name}
{site.isPartiallyCovered ? (
<Badge variant="secondary" className="gap-1">
<AlertCircle className="h-3 w-3" />
Parzialmente Coperto
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
Non Coperto
</Badge>
)}
</CardTitle>
<CardDescription className="mt-2 space-y-1">
<div className="flex items-center gap-1 text-sm">
<MapPin className="h-3 w-3" />
{site.address} - {site.location}
</div>
{site.serviceStartTime && site.serviceEndTime && (
<div className="flex items-center gap-1 text-sm">
<Clock className="h-3 w-3" />
Orario: {site.serviceStartTime} - {site.serviceEndTime}
</div>
)}
</CardDescription>
</div>
<Button
onClick={() => handleSelectSite(site)}
data-testid={`button-select-site-${site.id}`}
>
Assegna Turno
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">Turni Pianificati</p>
<p className="text-lg font-semibold">{site.shiftsCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Guardie Richieste</p>
<p className="text-lg font-semibold">{site.requiredGuards}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Guardie Assegnate</p>
<p className="text-lg font-semibold">{site.totalAssignedGuards}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Requisiti</p>
<div className="flex gap-1 mt-1">
{site.requiresArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{site.requiresDriverLicense && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
Patente
</Badge>
)}
</div>
</div>
</div>
{site.shifts.length > 0 && (
<div className="mt-4 pt-4 border-t">
<p className="text-sm font-medium mb-2">Dettagli Turni:</p>
<div className="space-y-2">
{site.shifts.map((shift) => (
<div key={shift.id} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
{format(new Date(shift.startTime), "HH:mm")} - {format(new Date(shift.endTime), "HH:mm")}
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4" />
{shift.assignedGuardsCount}/{shift.requiredGuards}
{shift.isCovered ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : shift.isPartial ? (
<AlertCircle className="h-4 w-4 text-orange-500" />
) : (
<AlertCircle className="h-4 w-4 text-red-500" />
)}
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="py-12 text-center">
<CheckCircle2 className="h-12 w-12 mx-auto text-green-500 mb-4" />
<h3 className="text-lg font-semibold mb-2">Tutti i siti sono coperti!</h3>
<p className="text-muted-foreground">
Non ci sono siti che richiedono assegnazioni per questa data.
</p>
</CardContent>
</Card>
)}
{/* Dialog per assegnare risorse e creare turno */}
<Dialog open={createShiftDialogOpen} onOpenChange={setCreateShiftDialogOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Assegna Risorse - {selectedSite?.name}</DialogTitle>
<DialogDescription>
Seleziona le guardie e il veicolo per creare il turno
</DialogDescription>
</DialogHeader>
{isLoadingResources ? (
<div className="space-y-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
) : (
<div className="space-y-6">
{/* Veicoli Disponibili */}
<div>
<h3 className="font-semibold mb-3 flex items-center gap-2">
<CarIcon className="h-5 w-5" />
Veicoli Disponibili ({filteredVehicles.length})
</h3>
{filteredVehicles.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{filteredVehicles.map((vehicle) => (
<Card
key={vehicle.id}
className={`cursor-pointer transition-colors ${
selectedVehicle === vehicle.id ? "border-primary bg-primary/5" : ""
}`}
onClick={() => setSelectedVehicle(vehicle.id)}
data-testid={`vehicle-card-${vehicle.id}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{vehicle.licensePlate}</p>
<p className="text-sm text-muted-foreground">
{vehicle.brand} {vehicle.model}
</p>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedVehicle === vehicle.id}
onCheckedChange={() => setSelectedVehicle(vehicle.id)}
/>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Nessun veicolo disponibile per questa sede</p>
)}
</div>
{/* Guardie Disponibili */}
<div>
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Users className="h-5 w-5" />
Guardie Disponibili ({filteredGuards.length})
<Badge variant="outline" className="ml-auto">
Selezionate: {selectedGuards.length}/{selectedSite?.minGuards || 0}
</Badge>
</h3>
{filteredGuards.length > 0 ? (
<div className="space-y-2">
{filteredGuards.map((guard) => (
<Card
key={guard.id}
className={`cursor-pointer transition-colors ${
selectedGuards.includes(guard.id) ? "border-primary bg-primary/5" : ""
}`}
onClick={() => toggleGuardSelection(guard.id)}
data-testid={`guard-card-${guard.id}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">
{guard.firstName} {guard.lastName} - #{guard.badgeNumber}
</p>
{guard.isArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{guard.hasDriverLicense && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
Patente
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
Ore sett.: {guard.availability.weeklyHours}h | Rimaste: {guard.availability.remainingWeeklyHours}h
</p>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedGuards.includes(guard.id)}
onCheckedChange={() => toggleGuardSelection(guard.id)}
/>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Nessuna guardia disponibile che soddisfa i requisiti del sito
</p>
)}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setCreateShiftDialogOpen(false);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
}}
data-testid="button-cancel-shift"
>
Annulla
</Button>
<Button
onClick={handleCreateShift}
disabled={
!selectedSite ||
selectedGuards.length < (selectedSite?.minGuards || 0) ||
createShiftMutation.isPending
}
data-testid="button-create-shift"
>
{createShiftMutation.isPending ? "Creazione..." : "Crea Turno"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,619 +1,227 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ShiftWithDetails, Guard } from "@shared/schema";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { BarChart3, Users, Clock, Calendar, TrendingUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { differenceInHours, format, startOfMonth, endOfMonth } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { Download, Users, Building2, Clock, TrendingUp } from "lucide-react";
import { format } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
type Location = "roccapiemonte" | "milano" | "roma";
interface GuardReport {
guardId: string;
guardName: string;
badgeNumber: string;
ordinaryHours: number;
overtimeHours: number;
totalHours: number;
mealVouchers: number;
workingDays: number;
}
interface SiteReport {
siteId: string;
siteName: string;
serviceTypes: {
name: string;
hours: number;
shifts: number;
}[];
totalHours: 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 { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
const [selectedMonth, setSelectedMonth] = useState<string>(format(new Date(), "yyyy-MM")); queryKey: ["/api/shifts"],
// Query per report guardie
const { data: guardReport, isLoading: isLoadingGuards } = useQuery<{
month: string;
location: string;
guards: GuardReport[];
summary: {
totalGuards: number;
totalOrdinaryHours: number;
totalOvertimeHours: number;
totalHours: number;
totalMealVouchers: number;
};
}>({
queryKey: ["/api/reports/monthly-guard-hours", selectedMonth, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/reports/monthly-guard-hours?month=${selectedMonth}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch guard report");
return response.json();
},
}); });
// Query per report siti const { data: guards, isLoading: guardsLoading } = useQuery<Guard[]>({
const { data: siteReport, isLoading: isLoadingSites } = useQuery<{ queryKey: ["/api/guards"],
month: string;
location: string;
sites: SiteReport[];
summary: {
totalSites: number;
totalHours: number;
totalShifts: number;
};
}>({
queryKey: ["/api/reports/billable-site-hours", selectedMonth, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/reports/billable-site-hours?month=${selectedMonth}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch site report");
return response.json();
},
}); });
// Query per report clienti const isLoading = shiftsLoading || guardsLoading;
const { data: customerReport, isLoading: isLoadingCustomers } = useQuery<{
month: string; // Calculate statistics
location: string; const completedShifts = shifts?.filter(s => s.status === "completed") || [];
customers: CustomerReport[]; const totalHours = completedShifts.reduce((acc, shift) => {
summary: { return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
totalCustomers: number; }, 0);
totalHours: number;
totalShifts: number; // Hours per guard
totalPatrolPassages: number; const hoursPerGuard: Record<string, { name: string; hours: number }> = {};
totalInspections: number; completedShifts.forEach(shift => {
totalInterventions: number; shift.assignments.forEach(assignment => {
}; const guardId = assignment.guardId;
}>({ const guardName = `${assignment.guard.user?.firstName || ""} ${assignment.guard.user?.lastName || ""}`.trim();
queryKey: ["/api/reports/customer-billing", selectedMonth, selectedLocation], const hours = differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
queryFn: async () => {
const response = await fetch(`/api/reports/customer-billing?month=${selectedMonth}&location=${selectedLocation}`); if (!hoursPerGuard[guardId]) {
if (!response.ok) throw new Error("Failed to fetch customer report"); hoursPerGuard[guardId] = { name: guardName, hours: 0 };
return response.json(); }
}, hoursPerGuard[guardId].hours += hours;
});
}); });
// Genera mesi disponibili (ultimi 12 mesi) const guardStats = Object.values(hoursPerGuard).sort((a, b) => b.hours - a.hours);
const availableMonths = Array.from({ length: 12 }, (_, i) => {
const date = new Date(); // Monthly statistics
date.setMonth(date.getMonth() - i); const currentMonth = new Date();
return format(date, "yyyy-MM"); const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const monthlyShifts = completedShifts.filter(s => {
const shiftDate = new Date(s.startTime);
return shiftDate >= monthStart && shiftDate <= monthEnd;
}); });
// Export CSV guardie const monthlyHours = monthlyShifts.reduce((acc, shift) => {
const exportGuardsCSV = () => { return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
if (!guardReport?.guards) return; }, 0);
const headers = "Guardia,Badge,Ore Ordinarie,Ore Straordinarie,Ore Totali,Buoni Pasto,Giorni Lavorativi\n";
const rows = guardReport.guards.map(g =>
`"${g.guardName}",${g.badgeNumber},${g.ordinaryHours},${g.overtimeHours},${g.totalHours},${g.mealVouchers},${g.workingDays}`
).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 = `ore_guardie_${selectedMonth}_${selectedLocation}.csv`;
a.click();
};
// Export CSV siti
const exportSitesCSV = () => {
if (!siteReport?.sites) return;
const headers = "Sito,Tipologia Servizio,Ore,Turni\n";
const rows = siteReport.sites.flatMap(s =>
s.serviceTypes.map(st =>
`"${s.siteName}","${st.name}",${st.hours},${st.shifts}`
)
).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 = `ore_siti_${selectedMonth}_${selectedLocation}.csv`;
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="space-y-6">
{/* Header */}
<div> <div>
<h1 className="text-3xl font-bold">Report e Export</h1> <h1 className="text-3xl font-semibold mb-2">Report e Statistiche</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Ore lavorate, buoni pasto e fatturazione Ore lavorate e copertura servizi
</p> </p>
</div> </div>
{/* Filtri */} {isLoading ? (
<Card> <div className="grid gap-4 md:grid-cols-3">
<CardContent className="pt-6"> <Skeleton className="h-32" />
<div className="flex flex-wrap items-center gap-4"> <Skeleton className="h-32" />
<div className="flex-1 min-w-[200px]"> <Skeleton className="h-32" />
<label className="text-sm font-medium mb-2 block">Sede</label> </div>
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}> ) : (
<SelectTrigger data-testid="select-location"> <>
<SelectValue /> {/* Summary Cards */}
</SelectTrigger> <div className="grid gap-4 md:grid-cols-3">
<SelectContent> <Card>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem> <CardHeader className="pb-3">
<SelectItem value="milano">Milano</SelectItem> <CardDescription className="flex items-center gap-2">
<SelectItem value="roma">Roma</SelectItem> <Clock className="h-4 w-4" />
</SelectContent> Ore Totali Lavorate
</Select> </CardDescription>
</div> </CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-total-hours">
{totalHours}h
</p>
<p className="text-xs text-muted-foreground mt-1">
{completedShifts.length} turni completati
</p>
</CardContent>
</Card>
<div className="flex-1 min-w-[200px]"> <Card>
<label className="text-sm font-medium mb-2 block">Mese</label> <CardHeader className="pb-3">
<Select value={selectedMonth} onValueChange={setSelectedMonth}> <CardDescription className="flex items-center gap-2">
<SelectTrigger data-testid="select-month"> <Calendar className="h-4 w-4" />
<SelectValue /> Ore Mese Corrente
</SelectTrigger> </CardDescription>
<SelectContent> </CardHeader>
{availableMonths.map(month => { <CardContent>
const [year, monthNum] = month.split("-"); <p className="text-3xl font-semibold" data-testid="text-monthly-hours">
const date = new Date(parseInt(year), parseInt(monthNum) - 1); {monthlyHours}h
return ( </p>
<SelectItem key={month} value={month}> <p className="text-xs text-muted-foreground mt-1">
{format(date, "MMMM yyyy", { locale: it })} {format(currentMonth, "MMMM yyyy", { locale: it })}
</SelectItem> </p>
); </CardContent>
})} </Card>
</SelectContent>
</Select> <Card>
</div> <CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Users className="h-4 w-4" />
Guardie Attive
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-active-guards">
{guardStats.length}
</p>
<p className="text-xs text-muted-foreground mt-1">
Con turni completati
</p>
</CardContent>
</Card>
</div> </div>
</CardContent>
</Card>
{/* Tabs Report */} {/* Hours per Guard */}
<Tabs defaultValue="guards" className="space-y-6"> <Card>
<TabsList className="grid w-full max-w-[600px] grid-cols-3"> <CardHeader>
<TabsTrigger value="guards" data-testid="tab-guard-report"> <CardTitle className="flex items-center gap-2">
<Users className="h-4 w-4 mr-2" /> <BarChart3 className="h-5 w-5" />
Report Guardie Ore per Guardia
</TabsTrigger> </CardTitle>
<TabsTrigger value="sites" data-testid="tab-site-report"> <CardDescription>
<Building2 className="h-4 w-4 mr-2" /> Ore totali lavorate per ogni guardia
Report Siti </CardDescription>
</TabsTrigger> </CardHeader>
<TabsTrigger value="customers" data-testid="tab-customer-report"> <CardContent>
<Building2 className="h-4 w-4 mr-2" /> {guardStats.length > 0 ? (
Report Clienti <div className="space-y-3">
</TabsTrigger> {guardStats.map((stat, index) => (
</TabsList> <div
key={index}
{/* Tab Report Guardie */} className="flex items-center gap-4"
<TabsContent value="guards" className="space-y-4"> data-testid={`guard-stat-${index}`}
{isLoadingGuards ? ( >
<div className="space-y-4"> <div className="flex-1 min-w-0">
<Skeleton className="h-32 w-full" /> <p className="font-medium truncate">{stat.name}</p>
<Skeleton className="h-64 w-full" /> <div className="mt-1 h-2 bg-secondary rounded-full overflow-hidden">
</div> <div
) : guardReport ? ( className="h-full bg-primary"
<> style={{
{/* Summary cards */} width: `${(stat.hours / (guardStats[0]?.hours || 1)) * 100}%`,
<div className="grid gap-4 md:grid-cols-4"> }}
<Card> />
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Users className="h-4 w-4" />
Guardie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalGuards}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Ordinarie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalOrdinaryHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Ore Straordinarie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalOvertimeHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Buoni Pasto</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalMealVouchers}</p>
</CardContent>
</Card>
</div>
{/* Tabella guardie */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Dettaglio Ore per Guardia</CardTitle>
<CardDescription>Ordinarie, straordinarie e buoni pasto</CardDescription>
</div>
<Button onClick={exportGuardsCSV} data-testid="button-export-guards">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader>
<CardContent>
{guardReport.guards.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-3 font-medium">Guardia</th>
<th className="text-left p-3 font-medium">Badge</th>
<th className="text-right p-3 font-medium">Ore Ord.</th>
<th className="text-right p-3 font-medium">Ore Strao.</th>
<th className="text-right p-3 font-medium">Totale</th>
<th className="text-center p-3 font-medium">Buoni Pasto</th>
<th className="text-center p-3 font-medium">Giorni</th>
</tr>
</thead>
<tbody>
{guardReport.guards.map((guard) => (
<tr key={guard.guardId} className="border-b hover:bg-muted/50" data-testid={`guard-row-${guard.guardId}`}>
<td className="p-3">{guard.guardName}</td>
<td className="p-3"><Badge variant="outline">{guard.badgeNumber}</Badge></td>
<td className="p-3 text-right font-mono">{guard.ordinaryHours}h</td>
<td className="p-3 text-right font-mono text-orange-600 dark:text-orange-500">
{guard.overtimeHours > 0 ? `${guard.overtimeHours}h` : "-"}
</td>
<td className="p-3 text-right font-mono font-semibold">{guard.totalHours}h</td>
<td className="p-3 text-center">{guard.mealVouchers}</td>
<td className="p-3 text-center text-muted-foreground">{guard.workingDays}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-center text-muted-foreground py-8">Nessuna guardia con ore lavorate</p>
)}
</CardContent>
</Card>
</>
) : null}
</TabsContent>
{/* Tab Report Siti */}
<TabsContent value="sites" className="space-y-4">
{isLoadingSites ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : siteReport ? (
<>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
Siti Attivi
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalSites}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Fatturabili
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Turni Totali</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalShifts}</p>
</CardContent>
</Card>
</div>
{/* Tabella siti */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Ore Fatturabili per Sito</CardTitle>
<CardDescription>Raggruppate per tipologia servizio</CardDescription>
</div>
<Button onClick={exportSitesCSV} data-testid="button-export-sites">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader>
<CardContent>
{siteReport.sites.length > 0 ? (
<div className="space-y-4">
{siteReport.sites.map((site) => (
<div key={site.siteId} className="border rounded-md p-4" data-testid={`site-report-${site.siteId}`}>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-lg">{site.siteName}</h3>
<div className="flex items-center gap-2">
<Badge>{site.totalHours}h totali</Badge>
<Badge variant="outline">{site.totalShifts} turni</Badge>
</div>
</div>
<div className="space-y-2">
{site.serviceTypes.map((st, idx) => (
<div key={idx} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
<span>{st.name}</span>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">{st.shifts} turni</span>
<span className="font-mono font-semibold">{st.hours}h</span>
</div>
</div>
))}
</div>
</div> </div>
))} </div>
<div className="text-right">
<p className="text-lg font-semibold font-mono">{stat.hours}h</p>
</div>
</div> </div>
) : ( ))}
<p className="text-center text-muted-foreground py-8">Nessun sito con ore fatturabili</p> </div>
)} ) : (
</CardContent> <div className="text-center py-8">
</Card> <BarChart3 className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
</> <p className="text-sm text-muted-foreground">
) : null} Nessun dato disponibile
</TabsContent> </p>
</div>
)}
</CardContent>
</Card>
{/* Tab Report Clienti */} {/* Recent Shifts Summary */}
<TabsContent value="customers" className="space-y-4"> <Card>
{isLoadingCustomers ? ( <CardHeader>
<div className="space-y-4"> <CardTitle className="flex items-center gap-2">
<Skeleton className="h-32 w-full" /> <TrendingUp className="h-5 w-5" />
<Skeleton className="h-64 w-full" /> Turni Recenti
</div> </CardTitle>
) : customerReport ? ( <CardDescription>
<> Ultimi turni completati
{/* Summary cards */} </CardDescription>
<div className="grid gap-4 md:grid-cols-5"> </CardHeader>
<Card> <CardContent>
<CardHeader className="pb-3"> {completedShifts.length > 0 ? (
<CardDescription className="flex items-center gap-2"> <div className="space-y-3">
<Building2 className="h-4 w-4" /> {completedShifts.slice(0, 5).map((shift) => (
Clienti <div
</CardDescription> key={shift.id}
</CardHeader> className="flex items-center justify-between p-3 rounded-md border"
<CardContent> data-testid={`recent-shift-${shift.id}`}
<p className="text-2xl font-semibold">{customerReport.summary.totalCustomers}</p> >
</CardContent> <div className="flex-1 min-w-0">
</Card> <p className="font-medium truncate">{shift.site.name}</p>
<p className="text-sm text-muted-foreground">
<Card> {format(new Date(shift.startTime), "dd MMM yyyy", { locale: it })}
<CardHeader className="pb-3"> </p>
<CardDescription className="flex items-center gap-2"> </div>
<Clock className="h-4 w-4" /> <div className="text-right">
Ore Totali <p className="font-mono text-sm">
</CardDescription> {differenceInHours(new Date(shift.endTime), new Date(shift.startTime))}h
</CardHeader> </p>
<CardContent> <p className="text-xs text-muted-foreground">
<p className="text-2xl font-semibold">{customerReport.summary.totalHours}h</p> {shift.assignments.length} guardie
</CardContent> </p>
</Card> </div>
<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> </div>
<Button onClick={exportCustomersCSV} data-testid="button-export-customers"> ))}
<Download className="h-4 w-4 mr-2" /> </div>
Export CSV ) : (
</Button> <div className="text-center py-8">
</div> <Calendar className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
</CardHeader> <p className="text-sm text-muted-foreground">
<CardContent> Nessun turno completato
{customerReport.customers.length > 0 ? ( </p>
<div className="space-y-6"> </div>
{customerReport.customers.map((customer) => ( )}
<div key={customer.customerId} className="border-2 rounded-lg p-4" data-testid={`customer-report-${customer.customerId}`}> </CardContent>
{/* Header Cliente */} </Card>
<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>
</div> </div>
); );
} }

View File

@ -1,451 +0,0 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { format, addWeeks, addDays, startOfWeek } from "date-fns";
import { it } from "date-fns/locale";
import { ChevronLeft, ChevronRight, Users, Building2, Navigation, Shield, Car as CarIcon, MapPin } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type Location = "roccapiemonte" | "milano" | "roma";
interface FixedShiftDetail {
shiftId: string;
date: string;
from: string;
to: string;
siteName: string;
siteAddress: string;
siteId: string;
isArmed: boolean;
vehicle?: {
licensePlate: string;
brand: string;
model: string;
};
hours: number;
}
interface FixedGuardSchedule {
guardId: string;
guardName: string;
badgeNumber: string;
shifts: FixedShiftDetail[];
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 {
siteId: string;
siteName: string;
location: string;
shifts: {
shiftId: string;
date: string;
from: string;
to: string;
guards: {
guardName: string;
badgeNumber: string;
hours: number;
isArmed: boolean;
}[];
vehicle?: {
licensePlate: string;
brand: string;
model: string;
};
totalGuards: number;
totalHours: number;
}[];
totalShifts: number;
totalHours: number;
}
export default function ServicePlanning() {
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
const [weekStart, setWeekStart] = useState<Date>(startOfWeek(new Date(), { weekStartsOn: 1 }));
const [viewMode, setViewMode] = useState<"guard-fixed" | "guard-mobile" | "site">("guard-fixed");
const weekStartStr = format(weekStart, "yyyy-MM-dd");
// Query per vista Agenti Fissi
const { data: fixedGuardSchedules, isLoading: isLoadingFixedGuards } = useQuery<FixedGuardSchedule[]>({
queryKey: ["/api/service-planning/guards-fixed", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/guards-fixed?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch fixed guard schedules");
return response.json();
},
enabled: viewMode === "guard-fixed",
});
// Query per vista Agenti Mobili
const { data: mobileGuardSchedules, isLoading: isLoadingMobileGuards } = useQuery<MobileGuardSchedule[]>({
queryKey: ["/api/service-planning/guards-mobile", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/guards-mobile?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch mobile guard schedules");
return response.json();
},
enabled: viewMode === "guard-mobile",
});
// Query per vista Siti
const { data: siteSchedules, isLoading: isLoadingSites } = useQuery<SiteSchedule[]>({
queryKey: ["/api/service-planning/by-site", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/by-site?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch site schedules");
return response.json();
},
enabled: viewMode === "site",
});
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1));
return (
<div className="h-full overflow-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Planning di Servizio</h1>
<p className="text-muted-foreground">
Visualizza orari e dotazioni per agente fisso, agente mobile o per sito
</p>
</div>
</div>
{/* Controlli */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-4">
{/* Selezione sede */}
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
<SelectTrigger data-testid="select-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
{/* Navigazione settimana */}
<div className="flex-1 min-w-[300px]">
<label className="text-sm font-medium mb-2 block">Settimana</label>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={goToPreviousWeek} data-testid="button-prev-week">
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex-1 text-center font-medium">
{format(weekStart, "d MMM", { locale: it })} - {format(addDays(weekStart, 6), "d MMM yyyy", { locale: it })}
</div>
<Button variant="outline" size="icon" onClick={goToNextWeek} data-testid="button-next-week">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Tabs per vista */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard-fixed" | "guard-mobile" | "site")}>
<TabsList className="grid w-full max-w-2xl grid-cols-3">
<TabsTrigger value="guard-fixed" data-testid="tab-guard-fixed-view">
<Users className="h-4 w-4 mr-2" />
Agenti Fissi
</TabsTrigger>
<TabsTrigger value="guard-mobile" data-testid="tab-guard-mobile-view">
<Navigation className="h-4 w-4 mr-2" />
Agenti Mobili
</TabsTrigger>
<TabsTrigger value="site" data-testid="tab-site-view">
<Building2 className="h-4 w-4 mr-2" />
Vista Sito
</TabsTrigger>
</TabsList>
{/* Vista Agenti Fissi */}
<TabsContent value="guard-fixed" className="space-y-4 mt-6">
{isLoadingFixedGuards ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : fixedGuardSchedules && fixedGuardSchedules.length > 0 ? (
<div className="grid gap-4">
{fixedGuardSchedules.map((guard) => (
<Card key={guard.guardId} data-testid={`card-guard-fixed-${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.totalHours}h totali</Badge>
</div>
</CardHeader>
<CardContent>
{guard.shifts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nessun turno fisso assegnato</p>
) : (
<div className="space-y-3">
{guard.shifts.map((shift) => (
<div
key={shift.shiftId}
className="p-3 rounded-md bg-muted/50 space-y-2"
data-testid={`shift-${shift.shiftId}`}
>
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<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">
{route.startTime} - {route.endTime}
</div>
</div>
<div className="flex gap-2">
{route.isArmedRoute && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{route.vehicle && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
{route.vehicle.licensePlate}
</Badge>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-1">
<Navigation className="h-4 w-4" />
Percorso ({route.stops.length} {route.stops.length === 1 ? 'tappa' : 'tappe'}):
</div>
<div className="space-y-1 pl-5">
{route.stops.map((stop) => (
<div key={stop.siteId} className="text-sm text-muted-foreground flex items-start gap-2">
<Badge variant="secondary" className="text-xs">
{stop.sequenceOrder}
</Badge>
<div className="flex-1">
<div className="font-medium text-foreground">{stop.siteName}</div>
<div className="text-xs flex items-center gap-1">
<MapPin className="h-3 w-3" />
{stop.siteAddress}
</div>
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Nessun agente con percorsi pattuglia assegnati</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Vista Sito */}
<TabsContent value="site" className="space-y-4 mt-6">
{isLoadingSites ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : siteSchedules && siteSchedules.length > 0 ? (
<div className="grid gap-4">
{siteSchedules.map((site) => (
<Card key={site.siteId} data-testid={`card-site-${site.siteId}`}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{site.siteName}</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="outline">{site.totalShifts} turni</Badge>
<Badge>{site.totalHours}h totali</Badge>
</div>
</div>
</CardHeader>
<CardContent>
{site.shifts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nessun turno programmato</p>
) : (
<div className="space-y-3">
{site.shifts.map((shift) => (
<div
key={shift.shiftId}
className="p-3 rounded-md bg-muted/50 space-y-2"
data-testid={`shift-${shift.shiftId}`}
>
<div className="flex items-center justify-between">
<div className="font-medium">
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} {shift.from} - {shift.to}
</div>
<div className="flex gap-1">
<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 className="space-y-1">
{shift.guards.map((guard, idx) => (
<div key={idx} className="text-sm text-muted-foreground flex items-center justify-between">
<span>{guard.guardName} ({guard.badgeNumber}) - {guard.hours}h</span>
{guard.isArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Nessun sito con turni programmati</p>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -58,7 +58,6 @@ const getColorClasses = (color: string) => {
}; };
type SiteForm = z.infer<typeof insertSiteSchema>; type SiteForm = z.infer<typeof insertSiteSchema>;
type ServiceTypeForm = z.infer<typeof insertServiceTypeSchema>;
export default function Services() { export default function Services() {
const { toast } = useToast(); const { toast } = useToast();
@ -67,12 +66,6 @@ export default function Services() {
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<Site | null>(null); const [selectedSite, setSelectedSite] = useState<Site | null>(null);
// Service Type management states
const [manageTypesDialogOpen, setManageTypesDialogOpen] = useState(false);
const [createTypeDialogOpen, setCreateTypeDialogOpen] = useState(false);
const [editTypeDialogOpen, setEditTypeDialogOpen] = useState(false);
const [selectedType, setSelectedType] = useState<ServiceType | null>(null);
const { data: sites = [], isLoading: isLoadingSites } = useQuery<Site[]>({ const { data: sites = [], isLoading: isLoadingSites } = useQuery<Site[]>({
queryKey: ["/api/sites"], queryKey: ["/api/sites"],
}); });
@ -157,92 +150,6 @@ export default function Services() {
}, },
}); });
// Service Type Forms
const createTypeForm = useForm<ServiceTypeForm>({
resolver: zodResolver(insertServiceTypeSchema),
defaultValues: {
code: "",
label: "",
description: "",
icon: "Building2",
color: "blue",
classification: "fisso", // ✅ NUOVO: Discriminante Planning Fissi/Mobile
isActive: true,
},
});
const editTypeForm = useForm<ServiceTypeForm>({
resolver: zodResolver(insertServiceTypeSchema),
defaultValues: {
code: "",
label: "",
description: "",
icon: "Building2",
color: "blue",
classification: "fisso", // ✅ NUOVO: Discriminante Planning Fissi/Mobile
isActive: true,
},
});
// Service Type Mutations
const createTypeMutation = useMutation({
mutationFn: async (data: ServiceTypeForm) => {
return apiRequest("POST", "/api/service-types", data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/service-types"] });
toast({
title: "Tipologia creata",
description: "La tipologia di servizio è stata aggiunta con successo.",
});
setCreateTypeDialogOpen(false);
createTypeForm.reset();
},
onError: (error: any) => {
toast({
title: "Errore",
description: error.message || "Impossibile creare la tipologia.",
variant: "destructive",
});
},
});
const updateTypeMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: ServiceTypeForm }) => {
return apiRequest("PATCH", `/api/service-types/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/service-types"] });
toast({
title: "Tipologia aggiornata",
description: "Le modifiche sono state salvate con successo.",
});
setEditTypeDialogOpen(false);
setSelectedType(null);
},
onError: (error: any) => {
toast({
title: "Errore",
description: error.message || "Impossibile aggiornare la tipologia.",
variant: "destructive",
});
},
});
const handleEditType = (type: ServiceType) => {
setSelectedType(type);
editTypeForm.reset({
code: type.code,
label: type.label,
description: type.description,
icon: type.icon,
color: type.color,
classification: type.classification, // ✅ NUOVO: includi classification
isActive: type.isActive,
});
setEditTypeDialogOpen(true);
};
const handleCreateSite = (serviceType: string) => { const handleCreateSite = (serviceType: string) => {
createForm.reset({ createForm.reset({
name: "", name: "",
@ -269,8 +176,6 @@ export default function Services() {
minGuards: site.minGuards, minGuards: site.minGuards,
requiresArmed: site.requiresArmed || false, requiresArmed: site.requiresArmed || false,
requiresDriverLicense: site.requiresDriverLicense || false, requiresDriverLicense: site.requiresDriverLicense || false,
serviceStartTime: site.serviceStartTime || "",
serviceEndTime: site.serviceEndTime || "",
isActive: site.isActive, isActive: site.isActive,
}); });
setEditDialogOpen(true); setEditDialogOpen(true);
@ -301,13 +206,6 @@ export default function Services() {
Panoramica tipologie di servizio e relative configurazioni Panoramica tipologie di servizio e relative configurazioni
</p> </p>
</div> </div>
<Button
onClick={() => setManageTypesDialogOpen(true)}
data-testid="button-manage-service-types"
>
<Plus className="h-4 w-4 mr-2" />
Gestisci Tipologie
</Button>
</div> </div>
{isLoading ? ( {isLoading ? (
@ -552,45 +450,6 @@ export default function Services() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={createForm.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={createForm.control} control={createForm.control}
@ -777,45 +636,6 @@ export default function Services() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={editForm.control} control={editForm.control}
@ -892,509 +712,6 @@ export default function Services() {
</Form> </Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Manage Service Types Dialog */}
<Dialog open={manageTypesDialogOpen} onOpenChange={setManageTypesDialogOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gestione Tipologie Servizio</DialogTitle>
<DialogDescription>
Configura le tipologie di servizio disponibili nel sistema
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Button
onClick={() => setCreateTypeDialogOpen(true)}
className="w-full"
data-testid="button-add-service-type"
>
<Plus className="h-4 w-4 mr-2" />
Aggiungi Nuova Tipologia
</Button>
<div className="space-y-3">
{serviceTypes.map((type) => {
const Icon = getIconComponent(type.icon);
return (
<Card key={type.id} className={!type.isActive ? "opacity-50" : ""}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div className={`p-2 rounded-lg ${getColorClasses(type.color)}`}>
<Icon className="h-5 w-5" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{type.label}</h3>
<Badge variant="outline" className="text-xs">
{type.code}
</Badge>
{!type.isActive && (
<Badge variant="secondary" className="text-xs">
Disattivato
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{type.description}
</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
onClick={() => handleEditType(type)}
data-testid={`button-edit-service-type-${type.code}`}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
</DialogContent>
</Dialog>
{/* Create Service Type Dialog */}
<Dialog open={createTypeDialogOpen} onOpenChange={setCreateTypeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nuova Tipologia Servizio</DialogTitle>
<DialogDescription>
Crea una nuova tipologia di servizio
</DialogDescription>
</DialogHeader>
<Form {...createTypeForm}>
<form
onSubmit={createTypeForm.handleSubmit((data) => createTypeMutation.mutate(data))}
className="space-y-4"
>
<FormField
control={createTypeForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>Codice*</FormLabel>
<FormControl>
<Input {...field} placeholder="es: fixed_post" data-testid="input-type-code" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createTypeForm.control}
name="label"
render={({ field }) => (
<FormItem>
<FormLabel>Nome*</FormLabel>
<FormControl>
<Input {...field} placeholder="es: Presidio Fisso" data-testid="input-type-label" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createTypeForm.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Descrizione*</FormLabel>
<FormControl>
<Textarea {...field} value={field.value || ""} placeholder="Descrizione del servizio" data-testid="input-type-description" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={createTypeForm.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>Icona*</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-type-icon">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Building2">Building2</SelectItem>
<SelectItem value="Shield">Shield</SelectItem>
<SelectItem value="Eye">Eye</SelectItem>
<SelectItem value="Zap">Zap</SelectItem>
<SelectItem value="Car">Car</SelectItem>
<SelectItem value="Users">Users</SelectItem>
<SelectItem value="MapPin">MapPin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createTypeForm.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>Colore*</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-type-color">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="blue">Blu</SelectItem>
<SelectItem value="green">Verde</SelectItem>
<SelectItem value="purple">Viola</SelectItem>
<SelectItem value="orange">Arancione</SelectItem>
<SelectItem value="red">Rosso</SelectItem>
<SelectItem value="yellow">Giallo</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* ✅ NUOVO: Classification (Fisso/Mobile) */}
<FormField
control={createTypeForm.control}
name="classification"
render={({ field }) => (
<FormItem>
<FormLabel>Tipo Pianificazione*</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-type-classification">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fisso">Fisso (Planning Fissi)</SelectItem>
<SelectItem value="mobile">Mobile (Planning Mobile)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createTypeForm.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex items-center justify-between p-3 border rounded-lg">
<FormLabel>Tipologia Attiva</FormLabel>
<FormControl>
<Switch
checked={field.value ?? true}
onCheckedChange={field.onChange}
data-testid="switch-type-active"
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateTypeDialogOpen(false)}
data-testid="button-type-cancel"
>
Annulla
</Button>
<Button
type="submit"
disabled={createTypeMutation.isPending}
data-testid="button-save-type"
>
{createTypeMutation.isPending ? "Salvataggio..." : "Crea Tipologia"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Edit Service Type Dialog */}
<Dialog open={editTypeDialogOpen} onOpenChange={setEditTypeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Modifica Tipologia Servizio</DialogTitle>
<DialogDescription>
Modifica le informazioni della tipologia
</DialogDescription>
</DialogHeader>
<Form {...editTypeForm}>
<form
onSubmit={editTypeForm.handleSubmit((data) =>
selectedType && updateTypeMutation.mutate({ id: selectedType.id, data })
)}
className="space-y-4"
>
<FormField
control={editTypeForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>Codice*</FormLabel>
<FormControl>
<Input {...field} data-testid="input-edit-type-code" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.control}
name="label"
render={({ field }) => (
<FormItem>
<FormLabel>Nome*</FormLabel>
<FormControl>
<Input {...field} data-testid="input-edit-type-label" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Descrizione*</FormLabel>
<FormControl>
<Textarea {...field} value={field.value || ""} data-testid="input-edit-type-description" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editTypeForm.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>Icona*</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger data-testid="select-edit-type-icon">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Building2">Building2</SelectItem>
<SelectItem value="Shield">Shield</SelectItem>
<SelectItem value="Eye">Eye</SelectItem>
<SelectItem value="Zap">Zap</SelectItem>
<SelectItem value="Car">Car</SelectItem>
<SelectItem value="Users">Users</SelectItem>
<SelectItem value="MapPin">MapPin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>Colore*</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger data-testid="select-edit-type-color">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="blue">Blu</SelectItem>
<SelectItem value="green">Verde</SelectItem>
<SelectItem value="purple">Viola</SelectItem>
<SelectItem value="orange">Arancione</SelectItem>
<SelectItem value="red">Rosso</SelectItem>
<SelectItem value="yellow">Giallo</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* ✅ NUOVO: Classification (Fisso/Mobile) */}
<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>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editTypeForm.control}
name="fixedPostHours"
render={({ field }) => (
<FormItem>
<FormLabel>Ore Presidio Fisso</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 8, 12"
data-testid="input-edit-fixed-post-hours"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.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-edit-patrol-passages"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.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-edit-inspection-frequency"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.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-edit-response-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField
control={editTypeForm.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex items-center justify-between p-3 border rounded-lg">
<FormLabel>Tipologia Attiva</FormLabel>
<FormControl>
<Switch
checked={field.value ?? true}
onCheckedChange={field.onChange}
data-testid="switch-edit-type-active"
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setEditTypeDialogOpen(false);
setSelectedType(null);
}}
data-testid="button-edit-type-cancel"
>
Annulla
</Button>
<Button
type="submit"
disabled={updateTypeMutation.isPending}
data-testid="button-update-type"
>
{updateTypeMutation.isPending ? "Salvataggio..." : "Aggiorna Tipologia"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

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

View File

@ -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";
@ -11,56 +11,38 @@ import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { insertSiteSchema } from "@shared/schema"; import { insertSiteSchema } from "@shared/schema";
import { Plus, MapPin, Shield, Users, Pencil, Building2 } from "lucide-react"; import { Plus, MapPin, Shield, Users, Pencil } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { StatusBadge } from "@/components/status-badge"; 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 locationLabels: Record<string, string> = { const shiftTypeLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte", fixed_post: "Presidio Fisso",
milano: "Milano", patrol: "Pattugliamento",
roma: "Roma", night_inspection: "Ispettorato Notturno",
quick_response: "Pronto Intervento",
}; };
export default function Sites() { 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,
contractReference: "",
contractStartDate: undefined,
contractEndDate: undefined,
serviceStartTime: "",
serviceEndTime: "",
latitude: undefined,
longitude: undefined,
isActive: true, isActive: true,
}, },
}); });
@ -70,19 +52,10 @@ 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,
contractReference: "",
contractStartDate: undefined,
contractEndDate: undefined,
serviceStartTime: "",
serviceEndTime: "",
latitude: undefined,
longitude: undefined,
isActive: true, isActive: true,
}, },
}); });
@ -131,82 +104,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,50 +118,15 @@ 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 || "", shiftType: site.shiftType,
longitude: site.longitude || "",
customerId: site.customerId ?? undefined,
location: site.location,
serviceTypeId: site.serviceTypeId ?? undefined,
minGuards: site.minGuards, minGuards: site.minGuards,
requiresArmed: site.requiresArmed, requiresArmed: site.requiresArmed,
requiresDriverLicense: site.requiresDriverLicense, requiresDriverLicense: site.requiresDriverLicense,
contractReference: site.contractReference || "",
contractStartDate: site.contractStartDate || undefined,
contractEndDate: site.contractEndDate || undefined,
serviceStartTime: site.serviceStartTime || "",
serviceEndTime: site.serviceEndTime || "",
isActive: site.isActive, isActive: site.isActive,
}); });
}; };
// Funzione per determinare lo stato del contratto
const getContractStatus = (site: Site): "active" | "expiring" | "expired" | "none" => {
if (!site.contractStartDate || !site.contractEndDate) return "none";
const today = new Date();
const startDate = new Date(site.contractStartDate);
const endDate = new Date(site.contractEndDate);
if (today < startDate) return "none"; // Contratto non ancora iniziato
if (today > endDate) return "expired";
// Calcola i giorni rimanenti
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 30) return "expiring"; // In scadenza se mancano 30 giorni o meno
return "active";
};
const contractStatusLabels = {
active: { label: "Contratto Attivo", variant: "default" as const },
expiring: { label: "In Scadenza", variant: "outline" as const },
expired: { label: "Scaduto", variant: "destructive" as const },
none: { label: "Nessun Contratto", variant: "secondary" as const },
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -281,7 +143,7 @@ export default function Sites() {
Aggiungi Sito Aggiungi Sito
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Nuovo Sito</DialogTitle> <DialogTitle>Nuovo Sito</DialogTitle>
<DialogDescription> <DialogDescription>
@ -318,193 +180,23 @@ 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 <FormField
control={form.control} control={form.control}
name="customerId" name="shiftType"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Cliente (opzionale)</FormLabel> <FormLabel>Tipologia Servizio</FormLabel>
<Select onValueChange={(value) => field.onChange(value || undefined)} value={field.value ?? undefined}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger data-testid="select-customer"> <SelectTrigger data-testid="select-shift-type">
<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
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede Gestionale</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "roccapiemonte"}>
<FormControl>
<SelectTrigger data-testid="select-location">
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Dati Contrattuali</p>
<FormField
control={form.control}
name="contractReference"
render={({ field }) => (
<FormItem>
<FormLabel>Riferimento Contratto</FormLabel>
<FormControl>
<Input placeholder="CT-2025-001" {...field} value={field.value || ""} data-testid="input-contract-reference" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="contractStartDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Inizio Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-contract-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contractEndDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Fine Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-contract-end-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name="serviceTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Tipologia Servizio (opzionale)</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? undefined}>
<FormControl>
<SelectTrigger data-testid="select-service-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 />
@ -561,49 +253,6 @@ export default function Sites() {
/> />
</div> </div>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Orari Servizio</p>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button <Button
type="button" type="button"
@ -631,7 +280,7 @@ export default function Sites() {
{/* Edit Site Dialog */} {/* Edit Site Dialog */}
<Dialog open={!!editingSite} onOpenChange={(open) => !open && setEditingSite(null)}> <Dialog open={!!editingSite} onOpenChange={(open) => !open && setEditingSite(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Modifica Sito</DialogTitle> <DialogTitle>Modifica Sito</DialogTitle>
<DialogDescription> <DialogDescription>
@ -668,193 +317,23 @@ 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 <FormField
control={editForm.control} control={editForm.control}
name="customerId" name="shiftType"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Cliente (opzionale)</FormLabel> <FormLabel>Tipologia Servizio</FormLabel>
<Select onValueChange={(value) => field.onChange(value || undefined)} value={field.value ?? undefined}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger data-testid="select-edit-customer"> <SelectTrigger data-testid="select-edit-shift-type">
<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
control={editForm.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede Gestionale</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "roccapiemonte"}>
<FormControl>
<SelectTrigger data-testid="select-edit-location">
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Dati Contrattuali</p>
<FormField
control={editForm.control}
name="contractReference"
render={({ field }) => (
<FormItem>
<FormLabel>Riferimento Contratto</FormLabel>
<FormControl>
<Input placeholder="CT-2025-001" {...field} value={field.value || ""} data-testid="input-edit-contract-reference" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="contractStartDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Inizio Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-edit-contract-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="contractEndDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Fine Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-edit-contract-end-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField
control={editForm.control}
name="serviceTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Tipologia Servizio (opzionale)</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? undefined}>
<FormControl>
<SelectTrigger data-testid="select-edit-service-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 />
@ -924,49 +403,6 @@ export default function Sites() {
/> />
</div> </div>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Orari Servizio</p>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button <Button
type="button" type="button"
@ -1005,15 +441,9 @@ export default function Sites() {
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{site.name}</CardTitle> <CardTitle className="text-lg truncate">{site.name}</CardTitle>
<CardDescription className="text-xs mt-1 space-y-0.5"> <CardDescription className="text-xs mt-1">
<div> <MapPin className="h-3 w-3 inline mr-1" />
<MapPin className="h-3 w-3 inline mr-1" /> {site.address}
{site.address}
</div>
<div>
<Building2 className="h-3 w-3 inline mr-1" />
Sede: {locationLabels[site.location]}
</div>
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1032,33 +462,12 @@ export default function Sites() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex flex-wrap gap-2"> <div>
{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 statusInfo = contractStatusLabels[status];
return (
<Badge variant={statusInfo.variant} data-testid={`badge-contract-status-${site.id}`}>
{statusInfo.label}
</Badge>
);
})()}
</div> </div>
{site.contractReference && (
<div className="text-xs text-muted-foreground">
Contratto: {site.contractReference}
{site.contractEndDate && ` • Scade: ${new Date(site.contractEndDate).toLocaleDateString('it-IT')}`}
</div>
)}
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />

View File

@ -1,448 +0,0 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Users, Clock, MapPin, Navigation, ExternalLink } from "lucide-react";
import { format, parseISO, addDays, startOfWeek, addWeeks } from "date-fns";
import { it } from "date-fns/locale";
import { Link } from "wouter";
type AbsenceType = "sick_leave" | "vacation" | "personal_leave" | "injury";
interface GuardScheduleData {
guard: {
id: string;
firstName: string;
lastName: string;
badgeNumber: string;
};
fixedShifts: Array<{
assignmentId: string;
shiftId: string;
plannedStartTime: Date;
plannedEndTime: Date;
siteName: string;
siteId: string;
}>;
mobileShifts: Array<{
routeId: string;
shiftDate: string;
startTime: string;
endTime: string;
}>;
absences: Array<{
id: string;
type: AbsenceType;
startDate: string;
endDate: string;
}>;
}
interface WeeklyScheduleResponse {
weekStart: string;
weekEnd: string;
location: string;
guards: GuardScheduleData[];
}
const ABSENCE_LABELS: Record<AbsenceType, string> = {
sick_leave: "Malattia",
vacation: "Ferie",
personal_leave: "Permesso",
injury: "Infortunio",
};
type DialogData = {
type: "fixed" | "mobile";
guardName: string;
date: string;
data: any;
} | null;
export default function WeeklyGuards() {
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 }) // Inizia lunedì
);
const [dialogData, setDialogData] = useState<DialogData>(null);
const { data: scheduleData, isLoading, error } = useQuery<WeeklyScheduleResponse>({
queryKey: ["/api/weekly-guards-schedule", selectedLocation, format(currentWeekStart, "yyyy-MM-dd")],
queryFn: async () => {
const startDate = format(currentWeekStart, "yyyy-MM-dd");
const response = await fetch(
`/api/weekly-guards-schedule?location=${selectedLocation}&startDate=${startDate}`
);
if (!response.ok) {
throw new Error("Failed to fetch weekly schedule");
}
return response.json();
},
enabled: !!selectedLocation,
});
// Helper per ottenere i giorni della settimana
const getWeekDays = () => {
const days = [];
for (let i = 0; i < 7; i++) {
days.push(addDays(currentWeekStart, i));
}
return days;
};
const weekDays = getWeekDays();
// Helper per trovare l'attività di una guardia in un giorno specifico
const getDayActivity = (guardData: GuardScheduleData, date: Date) => {
const dateStr = format(date, "yyyy-MM-dd");
// Controlla assenze
const absence = guardData.absences.find(abs => {
const startDate = abs.startDate;
const endDate = abs.endDate;
return dateStr >= startDate && dateStr <= endDate;
});
if (absence) {
return {
type: "absence" as const,
label: ABSENCE_LABELS[absence.type],
data: absence,
};
}
// Controlla turni fissi
const fixedShift = guardData.fixedShifts.find(shift => {
const shiftDate = format(new Date(shift.plannedStartTime), "yyyy-MM-dd");
return shiftDate === dateStr;
});
if (fixedShift) {
const startTime = format(new Date(fixedShift.plannedStartTime), "HH:mm");
const endTime = format(new Date(fixedShift.plannedEndTime), "HH:mm");
return {
type: "fixed" as const,
label: `${fixedShift.siteName} ${startTime}-${endTime}`,
data: fixedShift,
};
}
// Controlla turni mobili
const mobileShift = guardData.mobileShifts.find(shift => shift.shiftDate === dateStr);
if (mobileShift) {
return {
type: "mobile" as const,
label: `Pattuglia ${mobileShift.startTime}-${mobileShift.endTime}`,
data: mobileShift,
};
}
return null;
};
const handlePreviousWeek = () => {
setCurrentWeekStart(prev => addWeeks(prev, -1));
};
const handleNextWeek = () => {
setCurrentWeekStart(prev => addWeeks(prev, 1));
};
const handleCellClick = (guardData: GuardScheduleData, activity: ReturnType<typeof getDayActivity>, date: Date) => {
if (!activity || activity.type === "absence") return;
const guardName = `${guardData.guard.lastName} ${guardData.guard.firstName}`;
const dateStr = format(date, "EEEE dd MMMM yyyy", { locale: it });
setDialogData({
type: activity.type,
guardName,
date: dateStr,
data: activity.data,
});
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Users className="h-8 w-8 text-primary" />
Guardie Settimanale
</h1>
<p className="text-muted-foreground mt-1">
Vista riepilogativa delle assegnazioni settimanali per sede
</p>
</div>
</div>
{/* Filtri */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Filtri Visualizzazione
</CardTitle>
<CardDescription>
Seleziona sede e settimana per visualizzare le assegnazioni
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select
value={selectedLocation}
onValueChange={setSelectedLocation}
data-testid="select-location"
>
<SelectTrigger>
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handlePreviousWeek}
data-testid="button-previous-week"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="px-4 py-2 border rounded-md bg-muted min-w-[280px] text-center">
<span className="font-medium">
{format(currentWeekStart, "d MMM", { locale: it })} - {format(addDays(currentWeekStart, 6), "d MMM yyyy", { locale: it })}
</span>
</div>
<Button
variant="outline"
size="icon"
onClick={handleNextWeek}
data-testid="button-next-week"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Griglia Settimanale */}
{isLoading ? (
<Card>
<CardContent className="p-6">
<p className="text-center text-muted-foreground">Caricamento...</p>
</CardContent>
</Card>
) : error ? (
<Card>
<CardContent className="p-6">
<p className="text-center text-destructive">
Errore nel caricamento della pianificazione. Riprova più tardi.
</p>
</CardContent>
</Card>
) : scheduleData && scheduleData.guards.length > 0 ? (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b bg-muted/50">
<th className="sticky left-0 z-10 bg-muted/50 text-left p-3 font-medium min-w-[180px]">
Guardia
</th>
{weekDays.map((day, index) => (
<th key={index} className="text-center p-3 font-medium min-w-[200px]">
<div>{format(day, "EEE", { locale: it })}</div>
<div className="text-xs text-muted-foreground font-normal">
{format(day, "dd/MM")}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{scheduleData.guards.map((guardData) => (
<tr
key={guardData.guard.id}
className="border-b hover:bg-muted/30"
data-testid={`row-guard-${guardData.guard.id}`}
>
<td className="sticky left-0 z-10 bg-background p-3 font-medium border-r">
<div className="flex flex-col">
<span className="text-sm">
{guardData.guard.lastName} {guardData.guard.firstName}
</span>
<span className="text-xs text-muted-foreground">
#{guardData.guard.badgeNumber}
</span>
</div>
</td>
{weekDays.map((day, dayIndex) => {
const activity = getDayActivity(guardData, day);
return (
<td
key={dayIndex}
className="p-2 text-center align-middle"
>
{activity ? (
activity.type === "absence" ? (
<div
className="text-xs px-2 py-1.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-300"
data-testid={`cell-absence-${guardData.guard.id}-${dayIndex}`}
>
{activity.label}
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full h-auto text-xs px-2 py-1.5 whitespace-normal hover-elevate"
onClick={() => handleCellClick(guardData, activity, day)}
data-testid={`button-shift-${guardData.guard.id}-${dayIndex}`}
>
{activity.label}
</Button>
)
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-6">
<p className="text-center text-muted-foreground">
Nessuna guardia trovata per la sede selezionata
</p>
</CardContent>
</Card>
)}
{/* Dialog Dettaglio Turno */}
<Dialog open={!!dialogData} onOpenChange={() => setDialogData(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{dialogData?.type === "fixed" ? (
<>
<MapPin className="h-5 w-5" />
Turno Fisso - {dialogData?.guardName}
</>
) : (
<>
<Navigation className="h-5 w-5" />
Turno Mobile - {dialogData?.guardName}
</>
)}
</DialogTitle>
<DialogDescription>
{dialogData?.date}
</DialogDescription>
</DialogHeader>
{dialogData && (
<div className="space-y-4">
{dialogData.type === "fixed" ? (
// Dettagli turno fisso
<div className="space-y-3">
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Sito</span>
<span className="text-sm font-medium">{dialogData.data.siteName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Orario</span>
<div className="flex items-center gap-1 text-sm font-medium">
<Clock className="h-3 w-3" />
{format(new Date(dialogData.data.plannedStartTime), "HH:mm")} - {format(new Date(dialogData.data.plannedEndTime), "HH:mm")}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Durata</span>
<span className="text-sm font-bold">
{Math.round((new Date(dialogData.data.plannedEndTime).getTime() - new Date(dialogData.data.plannedStartTime).getTime()) / (1000 * 60 * 60))}h
</span>
</div>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
<p className="text-sm text-muted-foreground">
Per modificare questo turno, vai alla pagina <Link href="/general-planning" className="text-primary font-medium hover:underline">Planning Fissi</Link>
</p>
</div>
</div>
) : (
// Dettagli turno mobile
<div className="space-y-3">
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tipo</span>
<Badge variant="outline">Pattuglia</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Orario</span>
<div className="flex items-center gap-1 text-sm font-medium">
<Clock className="h-3 w-3" />
{dialogData.data.startTime} - {dialogData.data.endTime}
</div>
</div>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
<p className="text-sm text-muted-foreground">
Per visualizzare il percorso completo e modificare il turno, vai alla pagina <Link href="/planning-mobile" className="text-primary font-medium hover:underline">Planning Mobile</Link>
</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogData(null)}>
Chiudi
</Button>
{dialogData?.type === "fixed" ? (
<Link href="/general-planning">
<Button data-testid="button-goto-planning-fissi">
<ExternalLink className="h-4 w-4 mr-2" />
Vai a Planning Fissi
</Button>
</Link>
) : (
<Link href="/planning-mobile">
<Button data-testid="button-goto-planning-mobile">
<ExternalLink className="h-4 w-4 mr-2" />
Vai a Planning Mobile
</Button>
</Link>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

105
package-lock.json generated
View File

@ -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",

View File

@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@ -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,27 @@ 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 ### API Endpoints
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch. Comprehensive RESTful API endpoints are provided for Authentication, Users, Guards, Sites, Shifts, and Notifications, supporting full CRUD operations with role-based access control.
- **Service Type Classification**: Classifies services as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules.
- **Planning Fissi**: Weekly planning grid for fixed posts, enabling shift creation with guard availability checks. Includes weekly shift duplication feature with confirmation dialog and automatic navigation. ### Frontend Routes
- **Planning Mobile**: Guard-centric interface for mobile services, displaying guard availability and hours, with an interactive Leaflet map showing sites. Features include: Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/notifications`, and `/users`, with access controlled by user roles.
- **Smart Site Assignment Indicators**: Sites already in patrol routes display "Assegnato a [Guard Name]" button with scroll-to functionality; unassigned sites show "Non assegnato" text
- **Drag-and-Drop Reordering**: Interactive drag-and-drop using @dnd-kit library for patrol route stops with visual feedback and automatic sequenceOrder persistence
- **Route Optimization**: OSRM API integration with TSP (Traveling Salesman Problem) nearest neighbor algorithm; displays total distance (km) and estimated travel time in dedicated dialog
- **Patrol Sequence List View**: Daily view of planned patrol routes with stops visualization
- **Custom Shift Timing**: Configurable start time and duration for each patrol route (replaces hardcoded 08:00-20:00)
- **Shift Overlap Validation**: POST /api/patrol-routes/check-overlaps endpoint verifies:
- No conflicts with existing fixed post shifts (shift_assignments)
- No conflicts with other mobile patrol routes
- Weekly hours compliance with contract parameters (maxHoursPerWeek + maxOvertimePerWeek)
- **Force-Save Dialog**: Interactive conflict resolution when saving patrol routes with overlaps or contractual limit violations; shows detailed conflict information and allows coordinator override
- **Multi-Day Duplication**: Duplication dialog supports "numero giorni consecutivi" field to create patrol sequences across N consecutive days; includes overlap validation (conservative approach: blocks entire operation if any day has conflicts)
- **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export.
- **Dashboard Operativa**: Live KPIs and real-time shift status.
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
- **Gestione Siti/Commesse**: Sites are associated with service types, including schedule, contract management, and location assignment. Automatic geocoding is supported.
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses.
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Includes patrol route persistence and exclusivity constraints between fixed and mobile shifts.
- **Guard Planning Views**: Dedicated views for guards to see their fixed post shifts and mobile patrol routes.
- **Site Planning View**: Coordinators can view all guards assigned to a specific site over a week.
- **Shift Duplication Features**:
- **Weekly Copy (Planning Fissi)**: POST /api/shift-assignments/copy-week endpoint duplicates all shifts and assignments from selected week to next week (+7 days) with atomic transaction. Frontend includes confirmation dialog with week details and success feedback.
- **Patrol Sequence Duplication (Planning Mobili)**: POST /api/patrol-routes/duplicate endpoint with dual behavior: UPDATE when target date = source date (modifies guard), CREATE when different date (duplicates route with all stops). Frontend shows daily sequence list with duplication dialog (date picker defaulting to next day, guard selector pre-filled but changeable).
- **Guardie Settimanale**: Compact weekly schedule view showing all guards' assignments across the week in a grid format. Features include:
- **Weekly Grid View**: Guard names in first column, 7 daily columns (Mon-Sun) with compact cell display
- **Multi-Source Aggregation**: GET /api/weekly-guards-schedule endpoint aggregates fixed shifts, patrol routes, and absences by location and week
- **Compact Cell Format**: Fixed posts show "Site Name HH:mm-HH:mm", mobile patrols show "Pattuglia HH:mm-HH:mm", absences show status (Ferie/Malattia/Permesso/Riposo)
- **Read-Only Dialogs**: Clicking cells opens appropriate dialog (fixed shift details or mobile patrol info) with navigation links to Planning Fissi/Mobile for edits
- **Location and Week Filters**: Dropdown for branch selection, week navigation with prev/next buttons displaying "Settimana dal DD MMM al DD MMM YYYY"
### User Roles ### User Roles
- **Admin**: Full access. - **Admin**: Full access 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 (fixed post, patrol, night inspection, quick response) and minimum requirements (guard count, armed, driver's license).
- **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 +59,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.

View File

@ -1,346 +0,0 @@
import { storage } from "./storage";
import { db } from "./db";
import { shifts, shiftAssignments, guards } from "@shared/schema";
import { eq, and, gte, lte, between, sql } from "drizzle-orm";
import { differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, differenceInDays } from "date-fns";
export interface CcnlRule {
key: string;
value: string | number | boolean;
description?: string;
}
export interface CcnlValidationResult {
isValid: boolean;
violations: CcnlViolation[];
warnings: CcnlWarning[];
}
export interface CcnlViolation {
type: "max_hours_exceeded" | "insufficient_rest" | "consecutive_limit_exceeded" | "overtime_violation";
severity: "error" | "warning";
message: string;
details?: any;
}
export interface CcnlWarning {
type: string;
message: string;
details?: any;
}
/**
* Carica tutte le impostazioni CCNL dal database e le converte in un oggetto chiave-valore
*/
export async function loadCcnlSettings(): Promise<Record<string, any>> {
const settings = await storage.getAllCcnlSettings();
const result: Record<string, any> = {};
for (const setting of settings) {
// Converti i valori stringa nei tipi appropriati
if (setting.value === "true" || setting.value === "false") {
result[setting.key] = setting.value === "true";
} else if (!isNaN(Number(setting.value))) {
result[setting.key] = Number(setting.value);
} else {
result[setting.key] = setting.value;
}
}
return result;
}
/**
* Ottiene un singolo valore CCNL con tipo convertito
*/
export async function getCcnlValue<T = any>(key: string): Promise<T | undefined> {
const setting = await storage.getCcnlSetting(key);
if (!setting) return undefined;
if (setting.value === "true" || setting.value === "false") {
return (setting.value === "true") as T;
} else if (!isNaN(Number(setting.value))) {
return Number(setting.value) as T;
} else {
return setting.value as T;
}
}
/**
* Valida se un turno rispetta le regole CCNL per un dato agente
*/
export async function validateShiftForGuard(
guardId: string,
shiftStartTime: Date,
shiftEndTime: Date,
excludeShiftId?: string
): Promise<CcnlValidationResult> {
const violations: CcnlViolation[] = [];
const warnings: CcnlWarning[] = [];
const ccnlSettings = await loadCcnlSettings();
// Calcola le ore del turno proposto (con precisione decimale)
const shiftHours = differenceInMinutes(shiftEndTime, shiftStartTime) / 60;
// 1. Verifica ore minime di riposo tra turni
const minRestHours = ccnlSettings.min_rest_hours_between_shifts || 11;
const lastShift = await getLastShiftBeforeDate(guardId, shiftStartTime, excludeShiftId);
if (lastShift) {
const restHours = differenceInMinutes(shiftStartTime, new Date(lastShift.endTime)) / 60;
if (restHours < minRestHours) {
violations.push({
type: "insufficient_rest",
severity: "error",
message: `Riposo insufficiente: ${restHours.toFixed(1)} ore (minimo: ${minRestHours} ore)`,
details: { restHours, minRestHours, lastShiftEnd: lastShift.endTime }
});
}
}
// 2. Verifica ore settimanali massime
const maxHoursPerWeek = ccnlSettings.max_hours_per_week || 48;
const weekStart = startOfWeek(shiftStartTime, { weekStartsOn: 1 });
const weekEnd = endOfWeek(shiftStartTime, { weekStartsOn: 1 });
const weeklyHours = await getGuardHoursInPeriod(guardId, weekStart, weekEnd, excludeShiftId);
if (weeklyHours + shiftHours > maxHoursPerWeek) {
violations.push({
type: "max_hours_exceeded",
severity: "error",
message: `Superamento ore settimanali: ${weeklyHours + shiftHours} ore (massimo: ${maxHoursPerWeek} ore)`,
details: { currentWeekHours: weeklyHours, shiftHours, maxHoursPerWeek }
});
} else if (weeklyHours + shiftHours > maxHoursPerWeek * 0.9) {
warnings.push({
type: "approaching_weekly_limit",
message: `Vicino al limite settimanale: ${weeklyHours + shiftHours} ore su ${maxHoursPerWeek} ore`,
details: { currentWeekHours: weeklyHours, shiftHours, maxHoursPerWeek }
});
}
// 3. Verifica ore mensili massime
const maxHoursPerMonth = ccnlSettings.max_hours_per_month || 190;
const monthStart = startOfMonth(shiftStartTime);
const monthEnd = endOfMonth(shiftStartTime);
const monthlyHours = await getGuardHoursInPeriod(guardId, monthStart, monthEnd, excludeShiftId);
if (monthlyHours + shiftHours > maxHoursPerMonth) {
violations.push({
type: "max_hours_exceeded",
severity: "error",
message: `Superamento ore mensili: ${monthlyHours + shiftHours} ore (massimo: ${maxHoursPerMonth} ore)`,
details: { currentMonthHours: monthlyHours, shiftHours, maxHoursPerMonth }
});
} else if (monthlyHours + shiftHours > maxHoursPerMonth * 0.9) {
warnings.push({
type: "approaching_monthly_limit",
message: `Vicino al limite mensile: ${monthlyHours + shiftHours} ore su ${maxHoursPerMonth} ore`,
details: { currentMonthHours: monthlyHours, shiftHours, maxHoursPerMonth }
});
}
// 4. Verifica giorni consecutivi
const maxDaysConsecutive = ccnlSettings.max_days_consecutive || 6;
const consecutiveDays = await getConsecutiveDaysWorked(guardId, shiftStartTime, excludeShiftId);
if (consecutiveDays >= maxDaysConsecutive) {
violations.push({
type: "consecutive_limit_exceeded",
severity: "error",
message: `Superamento giorni consecutivi: ${consecutiveDays + 1} giorni (massimo: ${maxDaysConsecutive} giorni)`,
details: { consecutiveDays: consecutiveDays + 1, maxDaysConsecutive }
});
}
// 5. Verifica straordinario settimanale
const overtimeThreshold = ccnlSettings.overtime_threshold_week || 40;
if (weeklyHours + shiftHours > overtimeThreshold && ccnlSettings.penalty_if_overtime === true) {
const overtimeHours = (weeklyHours + shiftHours) - overtimeThreshold;
warnings.push({
type: "overtime_warning",
message: `Straordinario settimanale: ${overtimeHours.toFixed(1)} ore oltre ${overtimeThreshold} ore`,
details: { overtimeHours, overtimeThreshold }
});
}
return {
isValid: violations.filter(v => v.severity === "error").length === 0,
violations,
warnings
};
}
/**
* Ottiene l'ultimo turno dell'agente prima di una certa data
*/
async function getLastShiftBeforeDate(guardId: string, beforeDate: Date, excludeShiftId?: string): Promise<any> {
const query = db
.select()
.from(shifts)
.innerJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
eq(shiftAssignments.guardId, guardId),
lte(shifts.endTime, beforeDate),
excludeShiftId ? sql`${shifts.id} != ${excludeShiftId}` : sql`true`
)
)
.orderBy(sql`${shifts.endTime} DESC`)
.limit(1);
const results = await query;
return results[0]?.shifts;
}
/**
* Calcola le ore totali lavorate da un agente in un periodo
*/
async function getGuardHoursInPeriod(
guardId: string,
startDate: Date,
endDate: Date,
excludeShiftId?: string
): Promise<number> {
const query = db
.select()
.from(shifts)
.innerJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
eq(shiftAssignments.guardId, guardId),
gte(shifts.startTime, startDate),
lte(shifts.endTime, endDate),
excludeShiftId ? sql`${shifts.id} != ${excludeShiftId}` : sql`true`
)
);
const results = await query;
let totalHours = 0;
for (const result of results) {
const shiftMinutes = differenceInMinutes(
new Date(result.shifts.endTime),
new Date(result.shifts.startTime)
);
totalHours += shiftMinutes / 60;
}
return totalHours;
}
/**
* Conta i giorni consecutivi lavorati dall'agente fino a una certa data
*/
async function getConsecutiveDaysWorked(
guardId: string,
upToDate: Date,
excludeShiftId?: string
): Promise<number> {
// Ottieni tutti i turni dell'agente negli ultimi 14 giorni
const twoWeeksAgo = new Date(upToDate);
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
const query = db
.select()
.from(shifts)
.innerJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
eq(shiftAssignments.guardId, guardId),
gte(shifts.startTime, twoWeeksAgo),
lte(shifts.startTime, upToDate),
excludeShiftId ? sql`${shifts.id} != ${excludeShiftId}` : sql`true`
)
)
.orderBy(sql`${shifts.startTime} DESC`);
const results = await query;
if (results.length === 0) return 0;
// Conta i giorni consecutivi partendo dalla data più recente
let consecutiveDays = 0;
let currentDate = new Date(upToDate);
currentDate.setHours(0, 0, 0, 0);
const workedDates = new Set(
results.map((r: any) => {
const d = new Date(r.shifts.startTime);
d.setHours(0, 0, 0, 0);
return d.getTime();
})
);
while (workedDates.has(currentDate.getTime())) {
consecutiveDays++;
currentDate.setDate(currentDate.getDate() - 1);
}
return consecutiveDays;
}
/**
* Valida un batch di turni per più agenti
*/
export async function validateMultipleShifts(
assignments: Array<{ guardId: string; shiftStartTime: Date; shiftEndTime: Date }>
): Promise<Record<string, CcnlValidationResult>> {
const results: Record<string, CcnlValidationResult> = {};
for (const assignment of assignments) {
results[assignment.guardId] = await validateShiftForGuard(
assignment.guardId,
assignment.shiftStartTime,
assignment.shiftEndTime
);
}
return results;
}
/**
* Ottiene un report sulla disponibilità dell'agente in un periodo
*/
export async function getGuardAvailabilityReport(
guardId: string,
startDate: Date,
endDate: Date
): Promise<{
totalHours: number;
weeklyHours: Record<string, number>;
remainingWeeklyHours: number;
remainingMonthlyHours: number;
consecutiveDaysWorked: number;
lastShiftEnd?: Date;
}> {
const ccnlSettings = await loadCcnlSettings();
const maxHoursPerWeek = ccnlSettings.max_hours_per_week || 48;
const maxHoursPerMonth = ccnlSettings.max_hours_per_month || 190;
const totalHours = await getGuardHoursInPeriod(guardId, startDate, endDate);
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
const currentWeekHours = await getGuardHoursInPeriod(guardId, weekStart, weekEnd);
const monthStart = startOfMonth(new Date());
const monthEnd = endOfMonth(new Date());
const currentMonthHours = await getGuardHoursInPeriod(guardId, monthStart, monthEnd);
const consecutiveDaysWorked = await getConsecutiveDaysWorked(guardId, new Date());
const lastShift = await getLastShiftBeforeDate(guardId, new Date());
return {
totalHours,
weeklyHours: {
current: currentWeekHours,
},
remainingWeeklyHours: Math.max(0, maxHoursPerWeek - currentWeekHours),
remainingMonthlyHours: Math.max(0, maxHoursPerMonth - currentMonthHours),
consecutiveDaysWorked,
lastShiftEnd: lastShift ? new Date(lastShift.endTime) : undefined,
};
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { db } from "./db"; import { db } from "./db";
import { users, guards, sites, vehicles, contractParameters, serviceTypes, ccnlSettings } from "@shared/schema"; import { users, guards, sites, vehicles, contractParameters, serviceTypes } from "@shared/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
@ -53,29 +53,6 @@ async function seed() {
} }
} }
// Create CCNL Settings
console.log("⚙️ Creazione impostazioni CCNL...");
const defaultCcnlSettings = [
{ key: "max_hours_per_week", value: "48", description: "Massimo ore lavorative settimanali" },
{ key: "max_hours_per_month", value: "190", description: "Massimo ore lavorative mensili" },
{ key: "min_rest_hours_between_shifts", value: "11", description: "Ore minime di riposo tra turni" },
{ key: "max_night_shifts_consecutive", value: "6", description: "Massimo turni notturni consecutivi" },
{ key: "max_days_consecutive", value: "6", description: "Massimo giorni lavorativi consecutivi" },
{ key: "min_weekly_rest_hours", value: "24", description: "Ore minime di riposo settimanale" },
{ key: "overtime_threshold_week", value: "40", description: "Soglia ore per straordinario settimanale" },
{ key: "penalty_if_overtime", value: "true", description: "Penalità se supera straordinario" },
];
for (const setting of defaultCcnlSettings) {
const existing = await db.select().from(ccnlSettings).where(eq(ccnlSettings.key, setting.key)).limit(1);
if (existing.length === 0) {
await db.insert(ccnlSettings).values(setting);
console.log(` + Creata impostazione: ${setting.key}`);
} else {
console.log(` ✓ Impostazione esistente: ${setting.key}`);
}
}
// Create CCNL contract parameters // Create CCNL contract parameters
console.log("📋 Creazione parametri contrattuali CCNL..."); console.log("📋 Creazione parametri contrattuali CCNL...");
const existingParams = await db.select().from(contractParameters).limit(1); const existingParams = await db.select().from(contractParameters).limit(1);

View File

@ -4,7 +4,6 @@ import {
guards, guards,
certifications, certifications,
vehicles, vehicles,
customers,
sites, sites,
shifts, shifts,
shiftAssignments, shiftAssignments,
@ -18,7 +17,6 @@ import {
absenceAffectedShifts, absenceAffectedShifts,
contractParameters, contractParameters,
serviceTypes, serviceTypes,
ccnlSettings,
type User, type User,
type UpsertUser, type UpsertUser,
type Guard, type Guard,
@ -27,8 +25,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,
@ -55,13 +51,9 @@ import {
type InsertContractParameters, type InsertContractParameters,
type ServiceType, type ServiceType,
type InsertServiceType, type InsertServiceType,
type CcnlSetting,
type InsertCcnlSetting,
type GuardAvailability,
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, and, gte, lte, desc, or, sql as rawSql } from "drizzle-orm"; import { eq, and, gte, lte, desc } from "drizzle-orm";
import { addDays, differenceInHours, parseISO, formatISO } from "date-fns";
export interface IStorage { export interface IStorage {
// User operations (Replit Auth required) // User operations (Replit Auth required)
@ -88,13 +80,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>;
@ -159,23 +144,6 @@ export interface IStorage {
getContractParameters(): Promise<ContractParameters | undefined>; getContractParameters(): Promise<ContractParameters | undefined>;
createContractParameters(params: InsertContractParameters): Promise<ContractParameters>; createContractParameters(params: InsertContractParameters): Promise<ContractParameters>;
updateContractParameters(id: string, params: Partial<InsertContractParameters>): Promise<ContractParameters | undefined>; updateContractParameters(id: string, params: Partial<InsertContractParameters>): Promise<ContractParameters | undefined>;
// CCNL Settings operations
getAllCcnlSettings(): Promise<CcnlSetting[]>;
getCcnlSetting(key: string): Promise<CcnlSetting | undefined>;
upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting>;
deleteCcnlSetting(key: string): Promise<void>;
// General Planning operations
getGuardsAvailability(
siteId: string,
location: string,
plannedStart: Date,
plannedEnd: Date
): Promise<GuardAvailability[]>;
// Shift Assignment operations with time slot management
deleteShiftAssignment(id: string): Promise<void>;
} }
export class DatabaseStorage implements IStorage { export class DatabaseStorage implements IStorage {
@ -186,31 +154,22 @@ export class DatabaseStorage implements IStorage {
} }
async upsertUser(userData: UpsertUser): Promise<User> { async upsertUser(userData: UpsertUser): Promise<User> {
// Handle conflicts on both id (primary key) and email (unique constraint) // Check if user already exists by email (unique constraint)
// Check if user exists by id or email first
const existingUser = await db const existingUser = await db
.select() .select()
.from(users) .from(users)
.where( .where(eq(users.email, userData.email || ''))
userData.id
? or(eq(users.id, userData.id), eq(users.email, userData.email || ''))
: eq(users.email, userData.email || '')
)
.limit(1); .limit(1);
if (existingUser.length > 0) { if (existingUser.length > 0) {
// Update existing user - NEVER change the ID (it's a primary key) // Update existing user
const [updated] = await db const [updated] = await db
.update(users) .update(users)
.set({ .set({
...(userData.email && { email: userData.email }), ...userData,
...(userData.firstName && { firstName: userData.firstName }),
...(userData.lastName && { lastName: userData.lastName }),
...(userData.profileImageUrl && { profileImageUrl: userData.profileImageUrl }),
...(userData.role && { role: userData.role }),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(users.id, existingUser[0].id)) .where(eq(users.email, userData.email || ''))
.returning(); .returning();
return updated; return updated;
} else { } else {
@ -352,35 +311,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);
@ -684,251 +614,6 @@ export class DatabaseStorage implements IStorage {
.returning(); .returning();
return updated; return updated;
} }
// CCNL Settings operations
async getAllCcnlSettings(): Promise<CcnlSetting[]> {
return await db.select().from(ccnlSettings);
}
async getCcnlSetting(key: string): Promise<CcnlSetting | undefined> {
const [setting] = await db.select().from(ccnlSettings).where(eq(ccnlSettings.key, key));
return setting;
}
async upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting> {
const existing = await this.getCcnlSetting(setting.key);
if (existing) {
const [updated] = await db
.update(ccnlSettings)
.set({ ...setting, updatedAt: new Date() })
.where(eq(ccnlSettings.key, setting.key))
.returning();
return updated;
} else {
const [newSetting] = await db.insert(ccnlSettings).values(setting).returning();
return newSetting;
}
}
async deleteCcnlSetting(key: string): Promise<void> {
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
}
// General Planning operations with time slot conflict detection
async getGuardsAvailability(
siteId: string,
location: string,
plannedStart: Date,
plannedEnd: Date
): Promise<GuardAvailability[]> {
// Helper: Check if two time ranges overlap
const hasOverlap = (start1: Date, end1: Date, start2: Date, end2: Date): boolean => {
return start1 < end2 && end1 > start2;
};
// Helper: Calculate night hours (22:00-06:00)
const calculateNightHours = (start: Date, end: Date): number => {
let nightHours = 0;
const current = new Date(start);
while (current < end) {
const hour = current.getUTCHours();
if (hour >= 22 || hour < 6) {
nightHours += 1;
}
current.setHours(current.getHours() + 1);
}
return nightHours;
};
// Calculate week boundaries for weekly hours calculation
const weekStart = new Date(plannedStart);
weekStart.setDate(plannedStart.getDate() - plannedStart.getDay() + (plannedStart.getDay() === 0 ? -6 : 1));
weekStart.setHours(0, 0, 0, 0);
const weekEnd = addDays(weekStart, 6);
weekEnd.setHours(23, 59, 59, 999);
// Get contract parameters
let contractParams = await this.getContractParameters();
if (!contractParams) {
contractParams = await this.createContractParameters({
contractType: "CCNL_VIGILANZA_2024",
});
}
const maxOrdinaryHours = contractParams.maxHoursPerWeek || 40; // 40h
const maxOvertimeHours = contractParams.maxOvertimePerWeek || 8; // 8h
const maxTotalHours = maxOrdinaryHours + maxOvertimeHours; // 48h
const maxNightHours = contractParams.maxNightHoursPerWeek || 48; // 48h
const minDailyRest = contractParams.minDailyRestHours || 11; // 11h
// Get site to check requirements
const site = await this.getSite(siteId);
if (!site) {
return [];
}
// Get all guards from the same location
const allGuards = await db
.select()
.from(guards)
.where(eq(guards.location, location as any));
// Filter guards by site requirements
const eligibleGuards = allGuards.filter((guard: Guard) => {
if (site.requiresArmed && !guard.isArmed) return false;
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
return true;
});
const requestedHours = differenceInHours(plannedEnd, plannedStart);
const requestedNightHours = calculateNightHours(plannedStart, plannedEnd);
// Analyze each guard's availability
const guardsWithAvailability: GuardAvailability[] = [];
for (const guard of eligibleGuards) {
// Get all shift assignments for this guard in the week (for weekly hours)
const weeklyAssignments = await db
.select({
id: shiftAssignments.id,
shiftId: shiftAssignments.shiftId,
plannedStartTime: shiftAssignments.plannedStartTime,
plannedEndTime: shiftAssignments.plannedEndTime,
})
.from(shiftAssignments)
.where(
and(
eq(shiftAssignments.guardId, guard.id),
gte(shiftAssignments.plannedStartTime, weekStart),
lte(shiftAssignments.plannedStartTime, weekEnd)
)
)
.orderBy(desc(shiftAssignments.plannedEndTime));
// Calculate total weekly hours and night hours assigned
let weeklyHoursAssigned = 0;
let nightHoursAssigned = 0;
let lastShiftEnd: Date | null = null;
for (const assignment of weeklyAssignments) {
const hours = differenceInHours(assignment.plannedEndTime, assignment.plannedStartTime);
weeklyHoursAssigned += hours;
nightHoursAssigned += calculateNightHours(assignment.plannedStartTime, assignment.plannedEndTime);
// Track last shift end for rest calculation
if (!lastShiftEnd || assignment.plannedEndTime > lastShiftEnd) {
lastShiftEnd = assignment.plannedEndTime;
}
}
// Calculate ordinary and overtime hours
const ordinaryHoursAssigned = Math.min(weeklyHoursAssigned, maxOrdinaryHours);
const overtimeHoursAssigned = Math.max(0, weeklyHoursAssigned - maxOrdinaryHours);
const ordinaryHoursRemaining = Math.max(0, maxOrdinaryHours - weeklyHoursAssigned);
const overtimeHoursRemaining = Math.max(0, maxOvertimeHours - overtimeHoursAssigned);
const weeklyHoursRemaining = ordinaryHoursRemaining + overtimeHoursRemaining;
// Check if shift requires overtime
const requiresOvertime = requestedHours > ordinaryHoursRemaining;
// Check for time conflicts with the requested slot
const conflicts = [];
const reasons: string[] = [];
for (const assignment of weeklyAssignments) {
if (hasOverlap(plannedStart, plannedEnd, assignment.plannedStartTime, assignment.plannedEndTime)) {
// Get site name for conflict
const [shift] = await db
.select({ siteName: sites.name })
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.where(eq(shifts.id, assignment.shiftId));
conflicts.push({
from: assignment.plannedStartTime,
to: assignment.plannedEndTime,
siteName: shift?.siteName || 'Sito sconosciuto',
shiftId: assignment.shiftId,
});
}
}
// Check rest violation (11h between shifts)
let hasRestViolation = false;
if (lastShiftEnd) {
const hoursSinceLastShift = differenceInHours(plannedStart, lastShiftEnd);
if (hoursSinceLastShift < minDailyRest) {
hasRestViolation = true;
reasons.push(`Riposo insufficiente (${Math.max(0, hoursSinceLastShift).toFixed(1)}h dall'ultimo turno, minimo ${minDailyRest}h)`);
}
}
// Determine availability
let isAvailable = true;
// EXCLUDE guards already assigned on same day/time
if (conflicts.length > 0) {
isAvailable = false;
reasons.push(`Già assegnata in ${conflicts.length} turno/i nello stesso orario`);
}
// Check if enough hours available (total)
if (weeklyHoursRemaining < requestedHours) {
isAvailable = false;
reasons.push(`Ore settimanali insufficienti (${Math.max(0, weeklyHoursRemaining)}h disponibili, ${requestedHours}h richieste)`);
}
// Check night hours limit
if (nightHoursAssigned + requestedNightHours > maxNightHours) {
isAvailable = false;
reasons.push(`Ore notturne esaurite (${nightHoursAssigned}h lavorate, max ${maxNightHours}h/settimana)`);
}
// Rest violation makes guard unavailable
if (hasRestViolation) {
isAvailable = false;
}
// Build guard name from new fields
const guardName = guard.firstName && guard.lastName
? `${guard.firstName} ${guard.lastName}`
: guard.badgeNumber;
guardsWithAvailability.push({
guardId: guard.id,
guardName,
badgeNumber: guard.badgeNumber,
weeklyHoursRemaining,
weeklyHoursAssigned,
weeklyHoursMax: maxTotalHours,
ordinaryHoursRemaining,
overtimeHoursRemaining,
nightHoursAssigned,
requiresOvertime,
hasRestViolation,
lastShiftEnd,
isAvailable,
conflicts,
unavailabilityReasons: reasons,
});
}
// Sort: available with ordinary hours first, then overtime, then unavailable
guardsWithAvailability.sort((a, b) => {
if (a.isAvailable && !b.isAvailable) return -1;
if (!a.isAvailable && b.isAvailable) return 1;
if (a.isAvailable && b.isAvailable) {
if (!a.requiresOvertime && b.requiresOvertime) return -1;
if (a.requiresOvertime && !b.requiresOvertime) return 1;
}
return b.weeklyHoursRemaining - a.weeklyHoursRemaining;
});
return guardsWithAvailability;
}
} }
export const storage = new DatabaseStorage(); export const storage = new DatabaseStorage();

View File

@ -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
@ -127,11 +122,6 @@ export const users = pgTable("users", {
export const guards = pgTable("guards", { export const guards = pgTable("guards", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").references(() => users.id), userId: varchar("user_id").references(() => users.id),
// Anagrafica
firstName: varchar("first_name").notNull(),
lastName: varchar("last_name").notNull(),
email: varchar("email"),
badgeNumber: varchar("badge_number").notNull().unique(), badgeNumber: varchar("badge_number").notNull().unique(),
phoneNumber: varchar("phone_number"), phoneNumber: varchar("phone_number"),
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede di appartenenza location: locationEnum("location").notNull().default("roccapiemonte"), // Sede di appartenenza
@ -193,38 +183,6 @@ export const serviceTypes = pgTable("service_types", {
description: text("description"), // Descrizione dettagliata description: text("description"), // Descrizione dettagliata
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
fixedPostHours: integer("fixed_post_hours"), // Ore presidio fisso (es. 8, 12)
patrolPassages: integer("patrol_passages"), // Numero passaggi pattugliamento (es. 3, 5)
inspectionFrequency: integer("inspection_frequency"), // Frequenza ispezioni in minuti
responseTimeMinutes: integer("response_time_minutes"), // Tempo risposta pronto intervento
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_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), isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
@ -236,25 +194,15 @@ 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
serviceTypeId: varchar("service_type_id").references(() => serviceTypes.id), shiftType: shiftTypeEnum("shift_type").notNull(),
shiftType: shiftTypeEnum("shift_type"), // Optional - can be derived from service type
minGuards: integer("min_guards").notNull().default(1), minGuards: integer("min_guards").notNull().default(1),
requiresArmed: boolean("requires_armed").default(false), requiresArmed: boolean("requires_armed").default(false),
requiresDriverLicense: boolean("requires_driver_license").default(false), requiresDriverLicense: boolean("requires_driver_license").default(false),
// Orari servizio (formato HH:MM, es. "08:00", "20:00")
serviceStartTime: varchar("service_start_time"), // Orario inizio servizio
serviceEndTime: varchar("service_end_time"), // Orario fine servizio
// Dati contrattuali
contractReference: varchar("contract_reference"), // Riferimento/numero contratto
contractStartDate: date("contract_start_date"), // Data inizio contratto
contractEndDate: date("contract_end_date"), // Data fine contratto
// Coordinates for geofencing (future use) // Coordinates for geofencing (future use)
latitude: varchar("latitude"), latitude: varchar("latitude"),
longitude: varchar("longitude"), longitude: varchar("longitude"),
@ -274,9 +222,6 @@ export const shifts = pgTable("shifts", {
endTime: timestamp("end_time").notNull(), endTime: timestamp("end_time").notNull(),
status: shiftStatusEnum("status").notNull().default("planned"), status: shiftStatusEnum("status").notNull().default("planned"),
// Veicolo assegnato al turno (opzionale)
vehicleId: varchar("vehicle_id").references(() => vehicles.id, { onDelete: "set null" }),
notes: text("notes"), notes: text("notes"),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
@ -287,71 +232,12 @@ export const shiftAssignments = pgTable("shift_assignments", {
shiftId: varchar("shift_id").notNull().references(() => shifts.id, { onDelete: "cascade" }), shiftId: varchar("shift_id").notNull().references(() => shifts.id, { onDelete: "cascade" }),
guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }), guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }),
// Planned shift times (when the guard is scheduled to work)
plannedStartTime: timestamp("planned_start_time").notNull(),
plannedEndTime: timestamp("planned_end_time").notNull(),
assignedAt: timestamp("assigned_at").defaultNow(), assignedAt: timestamp("assigned_at").defaultNow(),
confirmedAt: timestamp("confirmed_at"), confirmedAt: timestamp("confirmed_at"),
// Actual check-in/out times (recorded when guard clocks in/out) // Actual check-in/out times
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 =============
export const ccnlSettings = pgTable("ccnl_settings", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
key: varchar("key").notNull().unique(),
value: varchar("value").notNull(),
description: text("description"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}); });
// ============= NOTIFICATIONS ============= // ============= NOTIFICATIONS =============
@ -552,6 +438,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 +449,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 +470,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),
})); }));
@ -603,10 +484,6 @@ export const shiftsRelations = relations(shifts, ({ one, many }) => ({
fields: [shifts.siteId], fields: [shifts.siteId],
references: [sites.id], references: [sites.id],
}), }),
vehicle: one(vehicles, {
fields: [shifts.vehicleId],
references: [vehicles.id],
}),
assignments: many(shiftAssignments), assignments: many(shiftAssignments),
})); }));
@ -619,33 +496,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 }) => ({
@ -759,13 +609,6 @@ export const insertGuardSchema = createInsertSchema(guards).omit({
id: true, id: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}).extend({
firstName: z.string().min(1, "Nome obbligatorio"),
lastName: z.string().min(1, "Cognome obbligatorio"),
email: z.string().email("Email non valida").optional().or(z.literal("")),
badgeNumber: z.string().min(1, "Matricola obbligatoria"),
phoneNumber: z.string().optional().or(z.literal("")),
location: z.enum(["roccapiemonte", "milano", "roma"]),
}); });
export const insertCertificationSchema = createInsertSchema(certifications).omit({ export const insertCertificationSchema = createInsertSchema(certifications).omit({
@ -786,12 +629,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 +641,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"),
@ -827,30 +653,9 @@ export const insertShiftFormSchema = z.object({
status: z.enum(["planned", "active", "completed", "cancelled"]).default("planned"), status: z.enum(["planned", "active", "completed", "cancelled"]).default("planned"),
}); });
export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments) export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments).omit({
.omit({
id: true,
assignedAt: true,
})
.extend({
plannedStartTime: z.union([z.string(), z.date()], {
required_error: "Orario inizio richiesto",
invalid_type_error: "Orario inizio non valido",
}).transform((val) => new Date(val)),
plannedEndTime: z.union([z.string(), z.date()], {
required_error: "Orario fine richiesto",
invalid_type_error: "Orario fine non valido",
}).transform((val) => new Date(val)),
})
.refine((data) => data.plannedEndTime > data.plannedStartTime, {
message: "L'orario di fine deve essere successivo all'orario di inizio",
path: ["plannedEndTime"],
});
export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({
id: true, id: true,
createdAt: true, assignedAt: true,
updatedAt: true,
}); });
export const insertNotificationSchema = createInsertSchema(notifications).omit({ export const insertNotificationSchema = createInsertSchema(notifications).omit({
@ -917,9 +722,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;
@ -929,9 +731,6 @@ export type Shift = typeof shifts.$inferSelect;
export type InsertShiftAssignment = z.infer<typeof insertShiftAssignmentSchema>; export type InsertShiftAssignment = z.infer<typeof insertShiftAssignmentSchema>;
export type ShiftAssignment = typeof shiftAssignments.$inferSelect; export type ShiftAssignment = typeof shiftAssignments.$inferSelect;
export type InsertCcnlSetting = z.infer<typeof insertCcnlSettingSchema>;
export type CcnlSetting = typeof ccnlSettings.$inferSelect;
export type InsertNotification = z.infer<typeof insertNotificationSchema>; export type InsertNotification = z.infer<typeof insertNotificationSchema>;
export type Notification = typeof notifications.$inferSelect; export type Notification = typeof notifications.$inferSelect;
@ -994,46 +793,3 @@ export type AbsenceWithDetails = Absence & {
shift: Shift; shift: Shift;
})[]; })[];
}; };
// ============= DTOs FOR GENERAL PLANNING =============
// DTO per conflitto orario guardia
export const guardConflictSchema = z.object({
from: z.date(),
to: z.date(),
siteName: z.string(),
shiftId: z.string(),
});
// DTO per disponibilità guardia con controllo conflitti orari
export const guardAvailabilitySchema = z.object({
guardId: z.string(),
guardName: z.string(),
badgeNumber: z.string(),
weeklyHoursRemaining: z.number(),
weeklyHoursAssigned: z.number(),
weeklyHoursMax: z.number(),
ordinaryHoursRemaining: z.number(), // Ore ordinarie disponibili (max 40h)
overtimeHoursRemaining: z.number(), // Ore straordinario disponibili (max 8h)
nightHoursAssigned: z.number(), // Ore notturne lavorate (22:00-06:00)
requiresOvertime: z.boolean(), // True se richiede straordinario
hasRestViolation: z.boolean(), // True se viola riposo obbligatorio
lastShiftEnd: z.date().nullable(), // Fine ultimo turno (per calcolo riposo)
isAvailable: z.boolean(),
conflicts: z.array(guardConflictSchema),
unavailabilityReasons: z.array(z.string()),
});
export type GuardConflict = z.infer<typeof guardConflictSchema>;
export type GuardAvailability = z.infer<typeof guardAvailabilitySchema>;
// DTO per creazione turno multi-giorno dal Planning Generale
export const createMultiDayShiftSchema = z.object({
siteId: z.string(),
startDate: z.string(), // YYYY-MM-DD
days: z.number().min(1).max(7),
guardId: z.string(),
shiftType: z.enum(["fixed_post", "patrol", "night_inspection", "quick_response"]).optional(),
});
export type CreateMultiDayShiftRequest = z.infer<typeof createMultiDayShiftSchema>;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@ -1,306 +1,78 @@
{ {
"version": "1.1.1", "version": "1.0.11",
"lastUpdate": "2025-11-15T10:11:44.404Z", "lastUpdate": "2025-10-17T09:32:00.721Z",
"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",
"date": "2025-10-22",
"type": "patch",
"description": "Deployment automatico v1.0.33"
},
{
"version": "1.0.32",
"date": "2025-10-22",
"type": "patch",
"description": "Deployment automatico v1.0.32"
},
{
"version": "1.0.31",
"date": "2025-10-22",
"type": "patch",
"description": "Deployment automatico v1.0.31"
},
{
"version": "1.0.30",
"date": "2025-10-22",
"type": "patch",
"description": "Deployment automatico v1.0.30"
},
{
"version": "1.0.29",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.29"
},
{
"version": "1.0.28",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.28"
},
{
"version": "1.0.27",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.27"
},
{
"version": "1.0.26",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.26"
},
{
"version": "1.0.25",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.25"
},
{
"version": "1.0.24",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.24"
},
{
"version": "1.0.23",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.23"
},
{
"version": "1.0.22",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.22"
},
{
"version": "1.0.21",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.21"
},
{
"version": "1.0.20",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.20"
},
{
"version": "1.0.19",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.19"
},
{
"version": "1.0.18",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.18"
},
{
"version": "1.0.17",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.17"
},
{
"version": "1.0.16",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.16"
},
{
"version": "1.0.15",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.15"
},
{
"version": "1.0.14",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.14"
},
{
"version": "1.0.13",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.13"
},
{
"version": "1.0.12",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.12"
},
{ {
"version": "1.0.11", "version": "1.0.11",
"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"
} }
] ]
} }