Implement new routes and UI components for guards to view fixed and mobile shifts, and for coordinators to view site-specific guard 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/WbUtQAg
275 lines
9.9 KiB
TypeScript
275 lines
9.9 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { MapPin, Shield, Car, Clock, User, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
|
|
import { it } from "date-fns/locale";
|
|
|
|
interface GuardAssignment {
|
|
guardId: string;
|
|
guardName: string;
|
|
badgeNumber: string;
|
|
plannedStartTime: string;
|
|
plannedEndTime: string;
|
|
armed: boolean;
|
|
vehicleId: string | null;
|
|
vehiclePlate: string | null;
|
|
}
|
|
|
|
interface SiteDayPlan {
|
|
date: string;
|
|
guards: GuardAssignment[];
|
|
}
|
|
|
|
interface Site {
|
|
id: string;
|
|
name: string;
|
|
address: string;
|
|
location: string;
|
|
}
|
|
|
|
export default function SitePlanningView() {
|
|
const [selectedSiteId, setSelectedSiteId] = useState<string>("");
|
|
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
|
|
const today = new Date();
|
|
return startOfWeek(today, { weekStartsOn: 1 });
|
|
});
|
|
|
|
// Query sites
|
|
const { data: sites } = useQuery<Site[]>({
|
|
queryKey: ["/api/sites"],
|
|
});
|
|
|
|
// Query site planning
|
|
const { data: sitePlanning, isLoading } = useQuery<SiteDayPlan[]>({
|
|
queryKey: ["/api/site-planning", selectedSiteId, currentWeekStart.toISOString()],
|
|
queryFn: async () => {
|
|
if (!selectedSiteId) return [];
|
|
const weekEnd = addDays(currentWeekStart, 6);
|
|
const response = await fetch(
|
|
`/api/site-planning/${selectedSiteId}?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
|
|
);
|
|
if (!response.ok) throw new Error("Failed to fetch site planning");
|
|
return response.json();
|
|
},
|
|
enabled: !!selectedSiteId,
|
|
});
|
|
|
|
// Naviga settimana precedente
|
|
const handlePreviousWeek = () => {
|
|
setCurrentWeekStart(addDays(currentWeekStart, -7));
|
|
};
|
|
|
|
// Naviga settimana successiva
|
|
const handleNextWeek = () => {
|
|
setCurrentWeekStart(addDays(currentWeekStart, 7));
|
|
};
|
|
|
|
// Raggruppa per giorno
|
|
const planningByDay = sitePlanning?.reduce((acc, day) => {
|
|
acc[day.date] = day.guards;
|
|
return acc;
|
|
}, {} as Record<string, GuardAssignment[]>) || {};
|
|
|
|
// Genera array di 7 giorni della settimana
|
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
|
|
|
|
const selectedSite = sites?.find(s => s.id === selectedSiteId);
|
|
|
|
const locationLabels: Record<string, string> = {
|
|
roccapiemonte: "Roccapiemonte",
|
|
milano: "Milano",
|
|
roma: "Roma",
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold" data-testid="title-site-planning-view">
|
|
Planning per Sito
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Visualizza tutti gli agenti assegnati a un sito con dotazioni
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Selettore sito */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Seleziona Sito</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<Select value={selectedSiteId} onValueChange={setSelectedSiteId}>
|
|
<SelectTrigger data-testid="select-site">
|
|
<SelectValue placeholder="Seleziona un sito..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sites?.map((site) => (
|
|
<SelectItem key={site.id} value={site.id} data-testid={`site-option-${site.id}`}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{site.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
({locationLabels[site.location] || site.location})
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{selectedSite && (
|
|
<div className="p-3 border rounded-lg bg-muted/20">
|
|
<p className="font-semibold">{selectedSite.name}</p>
|
|
<p className="text-sm text-muted-foreground">{selectedSite.address}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Navigazione settimana */}
|
|
{selectedSiteId && (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handlePreviousWeek}
|
|
data-testid="button-prev-week"
|
|
>
|
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
Settimana Precedente
|
|
</Button>
|
|
<CardTitle className="text-center">
|
|
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
|
|
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
|
|
</CardTitle>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleNextWeek}
|
|
data-testid="button-next-week"
|
|
>
|
|
Settimana Successiva
|
|
<ChevronRight className="h-4 w-4 ml-1" />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Loading state */}
|
|
{isLoading && (
|
|
<Card>
|
|
<CardContent className="py-12 text-center text-muted-foreground">
|
|
Caricamento planning sito...
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Griglia giorni settimana */}
|
|
{selectedSiteId && !isLoading && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{weekDays.map((day) => {
|
|
const dateStr = format(day, "yyyy-MM-dd");
|
|
const dayGuards = planningByDay[dateStr] || [];
|
|
|
|
return (
|
|
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-lg">
|
|
{format(day, "EEEE dd/MM", { locale: it })}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{dayGuards.length === 0
|
|
? "Nessun agente"
|
|
: `${dayGuards.length} agente${dayGuards.length > 1 ? "i" : ""}`}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{dayGuards.length === 0 ? (
|
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
|
Nessuna copertura
|
|
</div>
|
|
) : (
|
|
dayGuards.map((guard, index) => {
|
|
// Parsing sicuro orari
|
|
let startTime = "N/A";
|
|
let endTime = "N/A";
|
|
|
|
if (guard.plannedStartTime) {
|
|
const parsedStart = parseISO(guard.plannedStartTime);
|
|
if (isValid(parsedStart)) {
|
|
startTime = format(parsedStart, "HH:mm");
|
|
}
|
|
}
|
|
|
|
if (guard.plannedEndTime) {
|
|
const parsedEnd = parseISO(guard.plannedEndTime);
|
|
if (isValid(parsedEnd)) {
|
|
endTime = format(parsedEnd, "HH:mm");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={`${guard.guardId}-${index}`}
|
|
className="border rounded-lg p-3 space-y-2"
|
|
data-testid={`guard-assignment-${index}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<User className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-semibold text-sm">{guard.guardName}</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Matricola: {guard.badgeNumber}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-medium">
|
|
{startTime} - {endTime}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Dotazioni */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{guard.armed && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<Shield className="h-3 w-3 mr-1" />
|
|
Armato
|
|
</Badge>
|
|
)}
|
|
{guard.vehicleId && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<Car className="h-3 w-3 mr-1" />
|
|
{guard.vehiclePlate || "Automezzo"}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|