VigilanzaTurni/client/src/pages/general-planning.tsx
marco370 c2c35b62b5 Fix time display to prevent incorrect hour shifts
Correct the time formatting logic in the general planning component to explicitly use UTC, resolving an issue where shifts of +2 hours were incorrectly applied due to local timezone conversions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/qoWuIE4
2025-10-21 17:06:41 +00:00

835 lines
37 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 } 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<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 [startTime, setStartTime] = useState<string>("06:00");
const [durationHours, setDurationHours] = useState<number>(8);
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
const [showOvertimeGuards, setShowOvertimeGuards] = 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 } = 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
});
// 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<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 Generale
</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">
<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>
</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);
}}>
<DialogContent className="max-w-2xl max-h-[85vh] 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">
{/* Info turni */}
<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">{selectedCell.data.shiftsCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Ore Totali</p>
<p className="text-2xl font-bold">{selectedCell.data.totalShiftHours}h</p>
</div>
</div>
{/* Veicoli */}
{selectedCell.data.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 ({selectedCell.data.vehicles.length})
</div>
<div className="grid gap-2">
{selectedCell.data.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>
)}
{/* Guardie mancanti */}
{selectedCell.data.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">{selectedCell.data.missingGuards}</span>{" "}
{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)
</p>
</div>
)}
{/* No turni */}
{selectedCell.data.shiftsCount === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Nessun turno pianificato per questa data</p>
</div>
)}
{/* Form assegnazione guardia */}
<div className="border-t pt-4 space-y-4">
{/* Mostra guardie già assegnate per questo giorno */}
{selectedCell.data.guards.length > 0 && (
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Guardie già assegnate per questa data:
</p>
<div className="grid gap-2">
{selectedCell.data.guards.map((guard, idx) => (
<div key={idx} className="flex items-start justify-between gap-2 bg-background 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>
)}
<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>
);
})()}
{/* 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>
</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>
</div>
);
}