import { useState } 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 } 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 { queryClient, apiRequest } from "@/lib/queryClient"; import { useToast } from "@/hooks/use-toast"; import type { GuardAvailability } 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; 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: usa timeZone UTC per evitare shift di +2 ore const formatTime = (dateString: string) => { const date = new Date(dateString); return date.toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" // Evita conversione timezone locale (+2h in Italia) }); }; // 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("roccapiemonte"); const [weekStart, setWeekStart] = useState(() => 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(""); const [startTime, setStartTime] = useState("06:00"); const [durationHours, setDurationHours] = useState(8); const [consecutiveDays, setConsecutiveDays] = useState(1); const [showOvertimeGuards, setShowOvertimeGuards] = useState(false); // Query per dati planning settimanale const { data: planningData, isLoading } = useQuery({ 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 } = useQuery({ 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 }); // Mutation per eliminare assegnazione guardia const deleteAssignmentMutation = useMutation({ mutationFn: async (assignmentId: string) => { return apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`, undefined); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] }); queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] }); 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 }) => { return apiRequest("POST", "/api/general-planning/assign-guard", data); }, onSuccess: () => { // Invalida cache planning generale queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] }); queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] }); toast({ title: "Guardia assegnata", description: "La guardia è stata assegnata con successo", }); // Reset form setSelectedGuardId(""); setSelectedCell(null); }, onError: (error: any) => { // Parse error message from API response let errorMessage = "Impossibile assegnare la guardia"; if (error.message) { // Error format from apiRequest: "STATUS_CODE: {json_body}" const match = error.message.match(/^\d+:\s*(.+)$/); if (match) { try { const parsed = JSON.parse(match[1]); errorMessage = parsed.message || errorMessage; } catch { errorMessage = match[1]; } } else { errorMessage = error.message; } } toast({ title: "Errore Assegnazione", description: errorMessage, variant: "destructive", }); }, }); // 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, }); }; // 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 = { 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 (
{/* Header */}

Planning Generale

Vista settimanale turni con calcolo automatico guardie mancanti

{/* Filtri e navigazione */}
{/* Selezione Sede */}
{/* Navigazione settimana */}
{/* Info settimana */} {planningData && (
{format(new Date(planningData.weekStart), "dd MMM", { locale: it })} -{" "} {format(new Date(planningData.weekEnd), "dd MMM yyyy", { locale: it })}
)}
{/* Summary Guardie Settimana */} {!isLoading && planningData?.summary && ( Riepilogo Guardie Settimana

Guardie Necessarie

{planningData.summary.totalGuardsNeeded}

Guardie Pianificate

{planningData.summary.totalGuardsAssigned}

0 ? 'bg-destructive/10 border-destructive/20' : 'bg-green-500/10 border-green-500/20'}`}>

Guardie Mancanti

0 ? 'text-destructive' : 'text-green-600 dark:text-green-500'}`}> {planningData.summary.totalGuardsMissing}

)} {/* Tabella Planning */} Planning Settimanale - {formatLocation(selectedLocation)} {isLoading ? (
) : planningData ? (
{planningData.days.map((day) => ( ))} {uniqueSites.map((site) => ( {planningData.days.map((day) => { const daySiteData = day.sites.find((s) => s.siteId === site.siteId); return ( ); })} ))}
Sito
{format(new Date(day.date), "EEEE", { locale: it })} {format(new Date(day.date), "dd/MM", { locale: it })}
{site.siteName} {site.serviceType}
daySiteData && handleCellClick(site.siteId, site.siteName, day.date, daySiteData)} > {daySiteData ? (
{/* Riepilogo guardie necessarie/assegnate/mancanti - SEMPRE VISIBILE */}
{daySiteData.missingGuards > 0 ? ( Mancano {daySiteData.missingGuards} {daySiteData.missingGuards === 1 ? "guardia" : "guardie"} ) : ( Copertura Completa )}
{daySiteData.guardsAssigned + daySiteData.missingGuards} necessarie · {daySiteData.guardsAssigned} assegnate
{/* Guardie assegnate */} {daySiteData.guards.length > 0 && (
Guardie:
{daySiteData.guards.map((guard, idx) => (
{guard.badgeNumber} {guard.hours}h
))}
)} {/* Veicoli */} {daySiteData.vehicles.length > 0 && (
Veicoli:
{daySiteData.vehicles.map((vehicle, idx) => (
{vehicle.licensePlate}
))}
)} {/* Info copertura - mostra solo se ci sono turni */} {daySiteData.shiftsCount > 0 && (
Turni: {daySiteData.shiftsCount}
Tot. ore: {daySiteData.totalShiftHours}h
)}
) : (
-
)}
{uniqueSites.length === 0 && (

Nessun sito attivo per la sede selezionata

)}
) : (

Errore nel caricamento dei dati

)}
{/* Dialog dettagli cella */} { setSelectedCell(null); setSelectedGuardId(""); setStartTime("06:00"); setDurationHours(8); setConsecutiveDays(1); }}> {selectedCell?.siteName} {selectedCell && format(new Date(selectedCell.date), "EEEE dd MMMM yyyy", { locale: it })} {selectedCell && (
{/* Info turni */}

Turni Pianificati

{selectedCell.data.shiftsCount}

Ore Totali

{selectedCell.data.totalShiftHours}h

{/* Veicoli */} {selectedCell.data.vehicles.length > 0 && (
Veicoli Assegnati ({selectedCell.data.vehicles.length})
{selectedCell.data.vehicles.map((vehicle, idx) => (

{vehicle.licensePlate}

{vehicle.brand} {vehicle.model}

))}
)} {/* Guardie mancanti */} {selectedCell.data.missingGuards > 0 && (
Attenzione: Guardie Mancanti

Servono ancora {selectedCell.data.missingGuards}{" "} {selectedCell.data.missingGuards === 1 ? "guardia" : "guardie"} per coprire completamente il servizio (calcolato su {selectedCell.data.totalShiftHours}h con max 9h per guardia e {selectedCell.data.minGuards} {selectedCell.data.minGuards === 1 ? "guardia minima" : "guardie minime"} contemporanee)

)} {/* No turni */} {selectedCell.data.shiftsCount === 0 && (

Nessun turno pianificato per questa data

)} {/* Form assegnazione guardia */}
{/* Mostra guardie già assegnate per questo giorno */} {selectedCell.data.guards.length > 0 && (

Guardie già assegnate per questa data:

{selectedCell.data.guards.map((guard, idx) => (
{guard.guardName} #{guard.badgeNumber}
{formatTime(guard.plannedStartTime)} - {formatTime(guard.plannedEndTime)} ({guard.hours}h)
))}
)}
Assegna Nuova Guardia
{/* Ora Inizio, Durata e Giorni */}
setStartTime(e.target.value)} disabled={assignGuardMutation.isPending} data-testid="input-start-time" />
setDurationHours(Math.max(1, Math.min(24, parseInt(e.target.value) || 8)))} disabled={assignGuardMutation.isPending} data-testid="input-duration" />
setConsecutiveDays(Math.max(1, Math.min(30, parseInt(e.target.value) || 1)))} disabled={assignGuardMutation.isPending} data-testid="input-consecutive-days" />
{/* Ora fine calcolata */}
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")}`; })()}
{/* 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 (
{!isLoadingGuards && hasOvertimeGuards && ( )}
{isLoadingGuards ? ( ) : ( <> {filteredGuards.length === 0 && !showOvertimeGuards && hasOvertimeGuards && (

ℹ️ Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle.

)} {filteredGuards.length > 0 && selectedGuardId && (
{(() => { const guard = availableGuards?.find(g => g.guardId === selectedGuardId); if (!guard) return null; return ( <>

Ore ordinarie: {guard.ordinaryHoursRemaining}h / 40h disponibili {guard.requiresOvertime && ` • Straordinario: ${guard.overtimeHoursRemaining}h / 8h`}

Ore assegnate: {guard.weeklyHoursAssigned}h / {guard.weeklyHoursMax}h (rimangono {guard.weeklyHoursRemaining}h)

{guard.nightHoursAssigned > 0 && (

Ore notturne: {guard.nightHoursAssigned}h / 48h settimanali

)} {guard.hasRestViolation && (

⚠️ Attenzione: riposo insufficiente dall'ultimo turno

)} {guard.conflicts && guard.conflicts.length > 0 && (

⚠️ 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(", ")}

)} {guard.unavailabilityReasons && guard.unavailabilityReasons.length > 0 && (

{guard.unavailabilityReasons.join(", ")}

)} ); })()}
)} )}
); })()} {/* Bottone assegna */}
)}
); }