VigilanzaTurni/client/src/pages/general-planning.tsx
marco370 0a72b413fa Add functionality to duplicate weekly schedules and patrol routes
Introduces a dialog to copy weekly schedules to the next week and duplicates patrol routes with specified guards and dates, updating the client-side UI and API interactions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EDxr1e6
2025-10-24 15:46:11 +00:00

1107 lines
49 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { format, startOfWeek, addWeeks } from "date-fns";
import { it } from "date-fns/locale";
import { useLocation } from "wouter";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus, Trash2, Clock, Copy } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import type { GuardAvailability, Vehicle as VehicleDb } from "@shared/schema";
interface GuardWithHours {
assignmentId: string;
guardId: string;
guardName: string;
badgeNumber: string;
hours: number;
plannedStartTime: string;
plannedEndTime: string;
}
interface Vehicle {
vehicleId: string;
licensePlate: string;
brand: string;
model: string;
}
interface SiteData {
siteId: string;
siteName: string;
serviceType: string;
serviceStartTime: string;
serviceEndTime: string;
serviceHours: number;
minGuards: number;
guards: GuardWithHours[];
vehicles: Vehicle[];
totalShiftHours: number;
guardsAssigned: number;
missingGuards: number;
shiftsCount: number;
}
interface DayData {
date: string;
dayOfWeek: string;
sites: SiteData[];
}
interface GeneralPlanningResponse {
weekStart: string;
weekEnd: string;
location: string;
days: DayData[];
summary: {
totalGuardsNeeded: number;
totalGuardsAssigned: number;
totalGuardsMissing: number;
};
}
// Helper per formattare orario in formato italiano 24h (HH:MM)
// IMPORTANTE: Gli orari nel DB sono UTC, visualizzali in timezone Europe/Rome
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome" // Converti da UTC a Italy time
});
};
// Helper per formattare data in formato italiano (gg/mm/aaaa)
const formatDateIT = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("it-IT");
};
export default function GeneralPlanning() {
const [, navigate] = useLocation();
const { toast } = useToast();
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
const [selectedCell, setSelectedCell] = useState<{ siteId: string; siteName: string; date: string; data: SiteData } | null>(null);
// Form state per assegnazione guardia
const [selectedGuardId, setSelectedGuardId] = useState<string>("");
const [selectedVehicleId, setSelectedVehicleId] = useState<string>("none");
const [startTime, setStartTime] = useState<string>("06:00");
const [durationHours, setDurationHours] = useState<number>(8);
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
const [ccnlConfirmation, setCcnlConfirmation] = useState<{ message: string; data: any } | null>(null);
const [showCopyWeekConfirmation, setShowCopyWeekConfirmation] = useState<boolean>(false);
// Query per dati planning settimanale
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
queryKey: ["/api/general-planning", format(weekStart, "yyyy-MM-dd"), selectedLocation],
queryFn: async () => {
const response = await fetch(
`/api/general-planning?weekStart=${format(weekStart, "yyyy-MM-dd")}&location=${selectedLocation}`
);
if (!response.ok) throw new Error("Failed to fetch general planning");
return response.json();
},
});
// Calcola start e end time per la query availability
const getTimeSlot = () => {
if (!selectedCell) return { start: "", end: "" };
const [hours, minutes] = startTime.split(":").map(Number);
const startDateTime = new Date(selectedCell.date);
startDateTime.setHours(hours, minutes, 0, 0);
const endDateTime = new Date(startDateTime);
endDateTime.setHours(startDateTime.getHours() + durationHours);
return {
start: startDateTime.toISOString(),
end: endDateTime.toISOString()
};
};
// Query per guardie disponibili (solo quando dialog è aperto)
const { data: availableGuards, isLoading: isLoadingGuards, refetch: refetchGuards } = useQuery<GuardAvailability[]>({
queryKey: ["/api/guards/availability", selectedCell?.siteId, selectedLocation, startTime, durationHours],
queryFn: async () => {
if (!selectedCell) return [];
const { start, end } = getTimeSlot();
const response = await fetch(
`/api/guards/availability?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&siteId=${selectedCell.siteId}&location=${selectedLocation}`
);
if (!response.ok) throw new Error("Failed to fetch guards availability");
return response.json();
},
enabled: !!selectedCell, // Query attiva solo se dialog è aperto
staleTime: 0, // Dati sempre considerati stale, refetch ad ogni apertura dialog
});
// Query per veicoli disponibili (solo quando dialog è aperto)
const { data: availableVehicles, isLoading: isLoadingVehicles } = useQuery<VehicleDb[]>({
queryKey: ["/api/vehicles/available", selectedLocation],
queryFn: async () => {
if (!selectedCell) return [];
const response = await fetch(`/api/vehicles/available?location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch available vehicles");
return response.json();
},
enabled: !!selectedCell,
staleTime: 0,
});
// Calcola dati aggiornati della cella selezionata (per auto-refresh dialog)
const currentCellData = (() => {
if (!selectedCell || !planningData) return selectedCell?.data;
// Trova i dati freschi da planningData
const day = planningData.days.find(d => d.date === selectedCell.date);
if (!day) return selectedCell.data;
const updatedSite = day.sites.find(s => s.siteId === selectedCell.siteId);
return updatedSite || selectedCell.data;
})();
// Mutation per eliminare assegnazione guardia
const deleteAssignmentMutation = useMutation({
mutationFn: async (assignmentId: string) => {
return apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`, undefined);
},
onSuccess: async () => {
// Invalida e refetch planning generale per aggiornare dialog
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
await queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
await queryClient.refetchQueries({ queryKey: ["/api/general-planning"] });
toast({
title: "Guardia rimossa",
description: "L'assegnazione è stata eliminata con successo",
});
},
onError: (error: any) => {
toast({
title: "Errore",
description: "Impossibile eliminare l'assegnazione",
variant: "destructive",
});
},
});
// Mutation per assegnare guardia con orari (anche multi-giorno)
const assignGuardMutation = useMutation({
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number; vehicleId?: string; force?: boolean }) => {
return apiRequest("POST", "/api/general-planning/assign-guard", data);
},
onSuccess: async () => {
// Invalida cache planning generale, guardie e veicoli
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
await queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
await queryClient.invalidateQueries({ queryKey: ["/api/vehicles/available"] });
// Refetch immediatamente guardie disponibili per aggiornare lista
await refetchGuards();
toast({
title: "Guardia assegnata",
description: "La guardia è stata assegnata con successo",
});
// Reset form (NON chiudere dialog per vedere lista aggiornata)
setSelectedGuardId("");
setSelectedVehicleId("");
setCcnlConfirmation(null); // Reset dialog conferma se aperto
},
onError: (error: any, variables) => {
// Parse error message from API response
let errorMessage = "Impossibile assegnare la guardia";
let errorType = "";
if (error.message) {
// Error format from apiRequest: "STATUS_CODE: {json_body}"
const match = error.message.match(/^(\d+):\s*(.+)$/);
if (match) {
const statusCode = match[1];
try {
const parsed = JSON.parse(match[2]);
errorMessage = parsed.message || errorMessage;
errorType = parsed.type || "";
// Se è un errore CCNL (409 con tipo CCNL_VIOLATION), mostra dialog conferma
if (statusCode === "409" && errorType === "CCNL_VIOLATION") {
setCcnlConfirmation({
message: errorMessage,
data: variables
});
return; // Non mostrare toast, mostra dialog
}
} catch {
errorMessage = match[2];
}
} else {
errorMessage = error.message;
}
}
toast({
title: "Errore Assegnazione",
description: errorMessage,
variant: "destructive",
});
},
});
// Mutation per copiare turni settimanali
const copyWeekMutation = useMutation({
mutationFn: async () => {
return apiRequest("POST", "/api/shift-assignments/copy-week", {
weekStart: format(weekStart, "yyyy-MM-dd"),
location: selectedLocation,
});
},
onSuccess: async (response: any) => {
const data = await response.json();
toast({
title: "Settimana copiata!",
description: `${data.copiedShifts} turni e ${data.copiedAssignments} assegnazioni copiate nella settimana successiva`,
});
// Invalida cache e naviga alla settimana successiva
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
setWeekStart(addWeeks(weekStart, 1)); // Naviga alla settimana copiata
setShowCopyWeekConfirmation(false);
},
onError: (error: any) => {
let errorMessage = "Impossibile copiare la settimana";
if (error.message) {
const match = error.message.match(/^(\d+):\s*(.+)$/);
if (match) {
try {
const parsed = JSON.parse(match[2]);
errorMessage = parsed.message || errorMessage;
} catch {
errorMessage = match[2];
}
} else {
errorMessage = error.message;
}
}
toast({
title: "Errore Copia Settimana",
description: errorMessage,
variant: "destructive",
});
setShowCopyWeekConfirmation(false);
},
});
// Handler per submit form assegnazione guardia
const handleAssignGuard = () => {
if (!selectedCell || !selectedGuardId) return;
assignGuardMutation.mutate({
siteId: selectedCell.siteId,
date: selectedCell.date,
guardId: selectedGuardId,
startTime,
durationHours,
consecutiveDays,
...(selectedVehicleId && selectedVehicleId !== "none" && { vehicleId: selectedVehicleId }),
});
};
// Navigazione settimana
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1));
const goToCurrentWeek = () => setWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
// Formatta nome sede
const formatLocation = (loc: string) => {
const locations: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma",
};
return locations[loc] || loc;
};
// Raggruppa siti unici da tutti i giorni
const allSites = planningData?.days.flatMap(day => day.sites) || [];
const uniqueSites = Array.from(
new Map(allSites.map(site => [site.siteId, site])).values()
);
// Handler per aprire dialog cella
const handleCellClick = (siteId: string, siteName: string, date: string, data: SiteData) => {
setSelectedCell({ siteId, siteName, date, data });
};
// Naviga a pianificazione operativa con parametri
const navigateToOperationalPlanning = () => {
if (selectedCell) {
// Encode parameters nella URL
navigate(`/operational-planning?date=${selectedCell.date}&location=${selectedLocation}`);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
Planning Fissi
</h1>
<p className="text-muted-foreground">
Vista settimanale turni con calcolo automatico guardie mancanti
</p>
</div>
</div>
{/* Filtri e navigazione */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
{/* Selezione Sede */}
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<Select value={selectedLocation} onValueChange={setSelectedLocation}>
<SelectTrigger className="w-[200px]" 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 items-center gap-2 flex-wrap">
<Button
variant="outline"
size="icon"
onClick={goToPreviousWeek}
data-testid="button-previous-week"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={goToCurrentWeek}
data-testid="button-current-week"
>
<Calendar className="h-4 w-4 mr-2" />
Settimana Corrente
</Button>
<Button
variant="outline"
size="icon"
onClick={goToNextWeek}
data-testid="button-next-week"
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="default"
onClick={() => setShowCopyWeekConfirmation(true)}
disabled={isLoading || !planningData || copyWeekMutation.isPending}
data-testid="button-copy-week"
>
<Copy className="h-4 w-4 mr-2" />
{copyWeekMutation.isPending ? "Copia in corso..." : "Copia Turno Settimanale"}
</Button>
</div>
{/* Info settimana */}
{planningData && (
<div className="text-sm text-muted-foreground">
{format(new Date(planningData.weekStart), "dd MMM", { locale: it })} -{" "}
{format(new Date(planningData.weekEnd), "dd MMM yyyy", { locale: it })}
</div>
)}
</div>
</CardContent>
</Card>
{/* Summary Guardie Settimana */}
{!isLoading && planningData?.summary && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Riepilogo Guardie Settimana
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-primary/10 rounded-lg border border-primary/20">
<p className="text-sm text-muted-foreground mb-2">Guardie Necessarie</p>
<p className="text-4xl font-bold text-primary">{planningData.summary.totalGuardsNeeded}</p>
</div>
<div className="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/20">
<p className="text-sm text-muted-foreground mb-2">Guardie Pianificate</p>
<p className="text-4xl font-bold text-green-600 dark:text-green-500">{planningData.summary.totalGuardsAssigned}</p>
</div>
<div className={`text-center p-4 rounded-lg border ${planningData.summary.totalGuardsMissing > 0 ? 'bg-destructive/10 border-destructive/20' : 'bg-green-500/10 border-green-500/20'}`}>
<p className="text-sm text-muted-foreground mb-2">Guardie Mancanti</p>
<p className={`text-4xl font-bold ${planningData.summary.totalGuardsMissing > 0 ? 'text-destructive' : 'text-green-600 dark:text-green-500'}`}>
{planningData.summary.totalGuardsMissing}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Tabella Planning */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Planning Settimanale - {formatLocation(selectedLocation)}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : planningData ? (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b">
<th className="sticky left-0 bg-background p-3 text-left font-semibold min-w-[200px] border-r">
Sito
</th>
{planningData.days.map((day) => (
<th
key={day.date}
className="p-3 text-center font-semibold min-w-[200px] border-r"
>
<div className="flex flex-col">
<span className="text-sm capitalize">
{format(new Date(day.date), "EEEE", { locale: it })}
</span>
<span className="text-xs text-muted-foreground">
{format(new Date(day.date), "dd/MM", { locale: it })}
</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{uniqueSites.map((site) => (
<tr key={site.siteId} className="border-b hover-elevate">
<td className="sticky left-0 bg-background p-3 border-r font-medium">
<div className="flex flex-col gap-1">
<span className="text-sm">{site.siteName}</span>
<Badge variant="outline" className="text-xs w-fit">
{site.serviceType}
</Badge>
</div>
</td>
{planningData.days.map((day) => {
const daySiteData = day.sites.find((s) => s.siteId === site.siteId);
return (
<td
key={day.date}
className="p-2 border-r hover:bg-accent/5 cursor-pointer"
data-testid={`cell-${site.siteId}-${day.date}`}
onClick={() => daySiteData && handleCellClick(site.siteId, site.siteName, day.date, daySiteData)}
>
{daySiteData ? (
<div className="space-y-2 text-xs">
{/* Riepilogo guardie necessarie/assegnate/mancanti - SEMPRE VISIBILE */}
<div className="pb-2 border-b">
{daySiteData.missingGuards > 0 ? (
<Badge variant="destructive" className="w-full justify-center gap-1">
<AlertTriangle className="h-3 w-3" />
Mancano {daySiteData.missingGuards} {daySiteData.missingGuards === 1 ? "guardia" : "guardie"}
</Badge>
) : (
<Badge variant="default" className="w-full justify-center gap-1 bg-green-600 hover:bg-green-700">
<CheckCircle2 className="h-3 w-3" />
Copertura Completa
</Badge>
)}
<div className="text-xs text-muted-foreground mt-1 text-center">
{daySiteData.guardsAssigned + daySiteData.missingGuards} necessarie · {daySiteData.guardsAssigned} assegnate
</div>
</div>
{/* Guardie assegnate */}
{daySiteData.guards.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-muted-foreground mb-1">
<Users className="h-3 w-3" />
<span className="font-medium">Guardie:</span>
</div>
{daySiteData.guards.map((guard, idx) => (
<div key={idx} className="flex items-center justify-between gap-1 pl-4">
<span className="truncate">{guard.badgeNumber}</span>
<Badge variant="secondary" className="text-xs">
{guard.hours}h
</Badge>
</div>
))}
</div>
)}
{/* Veicoli */}
{daySiteData.vehicles.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-muted-foreground mb-1">
<Car className="h-3 w-3" />
<span className="font-medium">Veicoli:</span>
</div>
{daySiteData.vehicles.map((vehicle, idx) => (
<div key={idx} className="pl-4 truncate">
{vehicle.licensePlate}
</div>
))}
</div>
)}
{/* Info copertura - mostra solo se ci sono turni */}
{daySiteData.shiftsCount > 0 && (
<div className="text-xs text-muted-foreground pt-1 border-t">
<div>Turni: {daySiteData.shiftsCount}</div>
<div>Tot. ore: {daySiteData.totalShiftHours}h</div>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<span className="text-xs">-</span>
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
{uniqueSites.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Nessun sito attivo per la sede selezionata</p>
</div>
)}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<p>Errore nel caricamento dei dati</p>
</div>
)}
</CardContent>
</Card>
{/* Dialog dettagli cella */}
<Dialog open={!!selectedCell} onOpenChange={() => {
setSelectedCell(null);
setSelectedGuardId("");
setStartTime("06:00");
setDurationHours(8);
setConsecutiveDays(1);
setSelectedVehicleId("");
}}>
<DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
{selectedCell?.siteName}
</DialogTitle>
<DialogDescription>
{selectedCell && format(new Date(selectedCell.date), "EEEE dd MMMM yyyy", { locale: it })}
</DialogDescription>
</DialogHeader>
{selectedCell && (
<div className="space-y-4 overflow-y-auto pr-2">
{/* Form assegnazione guardia - SEMPRE IN ALTO E VISIBILE */}
<div className="bg-accent/5 border border-accent/20 rounded-lg p-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold">
<Plus className="h-4 w-4" />
Assegna Nuova Guardia
</div>
<div className="grid gap-4">
{/* Ora Inizio, Durata e Giorni */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="start-time">Ora Inizio</Label>
<Input
id="start-time"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
disabled={assignGuardMutation.isPending}
data-testid="input-start-time"
/>
</div>
<div className="space-y-2">
<Label htmlFor="duration">Durata (ore)</Label>
<Input
id="duration"
type="number"
min={1}
max={24}
value={durationHours}
onChange={(e) => setDurationHours(Math.max(1, Math.min(24, parseInt(e.target.value) || 8)))}
disabled={assignGuardMutation.isPending}
data-testid="input-duration"
/>
</div>
<div className="space-y-2">
<Label htmlFor="consecutive-days">Giorni</Label>
<Input
id="consecutive-days"
type="number"
min={1}
max={30}
value={consecutiveDays}
onChange={(e) => setConsecutiveDays(Math.max(1, Math.min(30, parseInt(e.target.value) || 1)))}
disabled={assignGuardMutation.isPending}
data-testid="input-consecutive-days"
/>
</div>
</div>
{/* Ora fine calcolata */}
<div className="text-xs text-muted-foreground">
Ora fine: {(() => {
const [hours, minutes] = startTime.split(":").map(Number);
const endHour = (hours + durationHours) % 24;
return `${String(endHour).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
})()}
</div>
{/* Select guardia disponibile */}
{(() => {
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
const filteredGuards = availableGuards?.filter(g =>
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
) || [];
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime && g.isAvailable) || false;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="guard-select">Guardia Disponibile</Label>
{!isLoadingGuards && hasOvertimeGuards && (
<Button
variant="outline"
size="sm"
onClick={() => setShowOvertimeGuards(!showOvertimeGuards)}
className="h-7 text-xs"
data-testid="button-toggle-overtime"
>
{showOvertimeGuards ? "Nascondi" : "Mostra"} Straordinario
</Button>
)}
</div>
{isLoadingGuards ? (
<Skeleton className="h-10 w-full" />
) : (
<>
<Select
value={selectedGuardId}
onValueChange={setSelectedGuardId}
disabled={assignGuardMutation.isPending}
>
<SelectTrigger id="guard-select" data-testid="select-guard">
<SelectValue placeholder="Seleziona guardia..." />
</SelectTrigger>
<SelectContent>
{filteredGuards.length > 0 ? (
filteredGuards.map((guard) => (
<SelectItem key={guard.guardId} value={guard.guardId}>
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
{guard.requiresOvertime && " 🔸"}
</SelectItem>
))
) : (
<SelectItem value="no-guards" disabled>
{showOvertimeGuards
? "Nessuna guardia disponibile"
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
</SelectItem>
)}
</SelectContent>
</Select>
{filteredGuards.length === 0 && !showOvertimeGuards && hasOvertimeGuards && (
<p className="text-xs text-muted-foreground">
Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
</p>
)}
{filteredGuards.length > 0 && selectedGuardId && (
<div className="text-xs space-y-1">
{(() => {
const guard = availableGuards?.find(g => g.guardId === selectedGuardId);
if (!guard) return null;
return (
<>
<p className="text-muted-foreground">
Ore ordinarie: {guard.ordinaryHoursRemaining}h / 40h disponibili
{guard.requiresOvertime && ` • Straordinario: ${guard.overtimeHoursRemaining}h / 8h`}
</p>
<p className="text-muted-foreground">
Ore assegnate: {guard.weeklyHoursAssigned}h / {guard.weeklyHoursMax}h (rimangono {guard.weeklyHoursRemaining}h)
</p>
{guard.nightHoursAssigned > 0 && (
<p className="text-muted-foreground">
Ore notturne: {guard.nightHoursAssigned}h / 48h settimanali
</p>
)}
{guard.hasRestViolation && (
<p className="text-yellow-600 dark:text-yellow-500 font-medium">
Attenzione: riposo insufficiente dall'ultimo turno
</p>
)}
{guard.conflicts && guard.conflicts.length > 0 && (
<p className="text-destructive font-medium">
⚠️ Conflitto: {guard.conflicts.map((c: any) =>
`${c.siteName} (${new Date(c.from).toLocaleTimeString('it-IT', {hour: '2-digit', minute:'2-digit'})} - ${new Date(c.to).toLocaleTimeString('it-IT', {hour: '2-digit', minute:'2-digit'})})`
).join(", ")}
</p>
)}
{guard.unavailabilityReasons && guard.unavailabilityReasons.length > 0 && (
<p className="text-yellow-600 dark:text-yellow-500">
{guard.unavailabilityReasons.join(", ")}
</p>
)}
</>
);
})()}
</div>
)}
</>
)}
</div>
);
})()}
{/* Select veicolo (opzionale) */}
<div className="space-y-2">
<Label htmlFor="vehicle-select">Veicolo (opzionale)</Label>
{isLoadingVehicles ? (
<Skeleton className="h-10 w-full" />
) : (
<Select
value={selectedVehicleId}
onValueChange={setSelectedVehicleId}
disabled={assignGuardMutation.isPending}
>
<SelectTrigger id="vehicle-select" data-testid="select-vehicle">
<SelectValue placeholder="Nessun veicolo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Nessun veicolo</SelectItem>
{availableVehicles && availableVehicles.length > 0 ? (
availableVehicles.map((vehicle) => (
<SelectItem key={vehicle.id} value={vehicle.id}>
{vehicle.licensePlate} - {vehicle.brand} {vehicle.model}
</SelectItem>
))
) : (
<SelectItem value="no-vehicles" disabled>
Nessun veicolo disponibile
</SelectItem>
)}
</SelectContent>
</Select>
)}
</div>
{/* Bottone assegna */}
<Button
onClick={handleAssignGuard}
disabled={!selectedGuardId || assignGuardMutation.isPending || (availableGuards && availableGuards.length === 0)}
data-testid="button-assign-guard"
className="w-full"
>
{assignGuardMutation.isPending ? (
"Assegnazione in corso..."
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Assegna Guardia
</>
)}
</Button>
</div>
</div>
{/* Guardie già assegnate - fuori dal form box per evitare di nascondere il form */}
{currentCellData && currentCellData.guards.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Users className="h-4 w-4" />
Guardie Già Assegnate ({currentCellData.guards.length})
</h3>
<div className="grid gap-2">
{currentCellData.guards.map((guard, idx) => (
<div key={idx} className="flex items-start justify-between gap-2 bg-muted/30 p-2.5 rounded border">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{guard.guardName}</span>
<Badge variant="outline" className="text-xs">#{guard.badgeNumber}</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{formatTime(guard.plannedStartTime)} - {formatTime(guard.plannedEndTime)}</span>
<span className="font-medium">({guard.hours}h)</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm(`Confermi di voler rimuovere ${guard.guardName} da questo turno?`)) {
deleteAssignmentMutation.mutate(guard.assignmentId);
}
}}
disabled={deleteAssignmentMutation.isPending}
data-testid={`button-delete-assignment-${guard.guardId}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Separator */}
<div className="border-t" />
{/* Info turni esistenti */}
<div className="space-y-4">
<h3 className="font-semibold text-sm">Informazioni Servizio</h3>
{/* Tipo servizio e orario */}
{currentCellData && (
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tipo Servizio</span>
<Badge variant="outline">{currentCellData.serviceType}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Orario Servizio</span>
<span className="text-sm font-medium">{currentCellData.serviceStartTime} - {currentCellData.serviceEndTime}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Ore Richieste</span>
<span className="text-sm font-bold">{currentCellData.serviceHours}h</span>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Turni Pianificati</p>
<p className="text-2xl font-bold">{currentCellData?.shiftsCount || 0}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Ore Assegnate</p>
<p className="text-2xl font-bold">{currentCellData?.totalShiftHours || 0}h</p>
</div>
</div>
{/* Guardie mancanti */}
{currentCellData && currentCellData.missingGuards > 0 && (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex items-center gap-2 text-destructive font-semibold mb-2">
<AlertTriangle className="h-5 w-5" />
Attenzione: Guardie Mancanti
</div>
<p className="text-sm">
Servono ancora <span className="font-bold">{currentCellData.missingGuards}</span>{" "}
{currentCellData.missingGuards === 1 ? "guardia" : "guardie"} per coprire completamente il servizio
(calcolato su {currentCellData.totalShiftHours}h con max 9h per guardia e {currentCellData.minGuards} {currentCellData.minGuards === 1 ? "guardia minima" : "guardie minime"} contemporanee)
</p>
</div>
)}
{/* Veicoli */}
{currentCellData && currentCellData.vehicles.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<Car className="h-4 w-4" />
Veicoli Assegnati ({currentCellData.vehicles.length})
</div>
<div className="grid gap-2">
{currentCellData.vehicles.map((vehicle, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 bg-accent/10 rounded-md">
<p className="font-medium">{vehicle.licensePlate}</p>
<p className="text-sm text-muted-foreground">
{vehicle.brand} {vehicle.model}
</p>
</div>
))}
</div>
</div>
)}
{/* No turni */}
{selectedCell.data.shiftsCount === 0 && (
<div className="text-center py-6 text-muted-foreground">
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p className="text-sm">Nessun turno pianificato per questa data</p>
<p className="text-xs mt-1">Usa il modulo sopra per assegnare la prima guardia</p>
</div>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedCell(null)}>
Chiudi
</Button>
<Button onClick={navigateToOperationalPlanning} data-testid="button-edit-planning">
<Edit className="h-4 w-4 mr-2" />
Modifica in Pianificazione Operativa
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog conferma forzatura CCNL */}
<AlertDialog open={!!ccnlConfirmation} onOpenChange={() => setCcnlConfirmation(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-600" />
Superamento Limite CCNL
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p className="text-foreground font-medium">
{ccnlConfirmation?.message}
</p>
<p className="text-sm">
Vuoi forzare comunque l'assegnazione? L'operazione verrà registrata e potrai consultarla nei report.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-cancel-force">Annulla</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (ccnlConfirmation) {
assignGuardMutation.mutate({
...ccnlConfirmation.data,
force: true
});
}
}}
data-testid="button-confirm-force"
className="bg-yellow-600 hover:bg-yellow-700"
>
Forza Assegnazione
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Dialog conferma copia settimana */}
<AlertDialog open={showCopyWeekConfirmation} onOpenChange={setShowCopyWeekConfirmation}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
Copia Turno Settimanale
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p className="text-foreground font-medium">
Vuoi copiare tutti i turni della settimana corrente nella settimana successiva?
</p>
{planningData && (
<div className="space-y-2 bg-muted/30 p-3 rounded-md">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Settimana corrente:</span>
<span className="font-medium">
{format(new Date(planningData.weekStart), "dd MMM", { locale: it })} -{" "}
{format(new Date(planningData.weekEnd), "dd MMM yyyy", { locale: it })}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Verrà copiata in:</span>
<span className="font-medium">
{format(addWeeks(new Date(planningData.weekStart), 1), "dd MMM", { locale: it })} -{" "}
{format(addWeeks(new Date(planningData.weekEnd), 1), "dd MMM yyyy", { locale: it })}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Sede:</span>
<span className="font-medium">{formatLocation(selectedLocation)}</span>
</div>
</div>
)}
<p className="text-sm text-muted-foreground">
Tutti i turni e le assegnazioni guardie verranno duplicati con le stesse caratteristiche (orari, dotazioni, veicoli).
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-cancel-copy-week">Annulla</AlertDialogCancel>
<AlertDialogAction
onClick={() => copyWeekMutation.mutate()}
data-testid="button-confirm-copy-week"
disabled={copyWeekMutation.isPending}
>
{copyWeekMutation.isPending ? "Copia in corso..." : "Conferma Copia"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}