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
1107 lines
49 KiB
TypeScript
1107 lines
49 KiB
TypeScript
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>
|
||
);
|
||
}
|