VigilanzaTurni/client/src/pages/general-planning.tsx
marco370 fad541525b Add ability to view and edit shift details for each location
Integrates a dialog modal for detailed shift information, enabling navigation to operational planning and allowing users to view and potentially edit shift assignments.

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

434 lines
18 KiB
TypeScript

import { useState } from "react";
import { useQuery } 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 { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit } 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";
interface GuardWithHours {
guardId: string;
guardName: string;
badgeNumber: string;
hours: number;
}
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[];
}
export default function GeneralPlanning() {
const [, navigate] = useLocation();
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);
// 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();
},
});
// 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>
{/* 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 && daySiteData.shiftsCount > 0 ? (
<div className="space-y-2 text-xs">
{/* 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>
)}
{/* Guardie mancanti */}
{daySiteData.missingGuards > 0 && (
<div className="pt-2 border-t">
<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>
</div>
)}
{/* Info copertura */}
<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)}>
<DialogContent className="max-w-2xl">
<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">
{/* 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>
{/* Guardie assegnate */}
{selectedCell.data.guards.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4" />
Guardie Assegnate ({selectedCell.data.guards.length})
</div>
<div className="grid gap-2">
{selectedCell.data.guards.map((guard, idx) => (
<div key={idx} className="flex items-center justify-between p-2 bg-accent/10 rounded-md">
<div>
<p className="font-medium">{guard.guardName}</p>
<p className="text-xs text-muted-foreground">{guard.badgeNumber}</p>
</div>
<Badge variant="secondary">{guard.hours}h</Badge>
</div>
))}
</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>
)}
</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>
);
}