Implement the "Service Planning" page with backend API routes and frontend components for displaying guard and site schedules. 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/KiuJzNf
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { format, addWeeks, addDays, startOfWeek } from "date-fns";
|
|
import { it } from "date-fns/locale";
|
|
import { ChevronLeft, ChevronRight, Users, Building2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
type Location = "roccapiemonte" | "milano" | "roma";
|
|
|
|
interface ShiftDetail {
|
|
shiftId: string;
|
|
date: string;
|
|
from: string;
|
|
to: string;
|
|
siteName: string;
|
|
siteId: string;
|
|
vehicle?: {
|
|
licensePlate: string;
|
|
brand: string;
|
|
model: string;
|
|
};
|
|
hours: number;
|
|
}
|
|
|
|
interface GuardSchedule {
|
|
guardId: string;
|
|
guardName: string;
|
|
badgeNumber: string;
|
|
shifts: ShiftDetail[];
|
|
totalHours: number;
|
|
}
|
|
|
|
interface SiteSchedule {
|
|
siteId: string;
|
|
siteName: string;
|
|
location: string;
|
|
shifts: {
|
|
shiftId: string;
|
|
date: string;
|
|
from: string;
|
|
to: string;
|
|
guards: {
|
|
guardName: string;
|
|
badgeNumber: string;
|
|
hours: number;
|
|
}[];
|
|
vehicle?: {
|
|
licensePlate: string;
|
|
brand: string;
|
|
model: string;
|
|
};
|
|
totalGuards: number;
|
|
totalHours: number;
|
|
}[];
|
|
totalShifts: number;
|
|
totalHours: number;
|
|
}
|
|
|
|
export default function ServicePlanning() {
|
|
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
|
const [weekStart, setWeekStart] = useState<Date>(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
|
const [viewMode, setViewMode] = useState<"guard" | "site">("guard");
|
|
|
|
const weekStartStr = format(weekStart, "yyyy-MM-dd");
|
|
const weekEndStr = format(addDays(weekStart, 6), "yyyy-MM-dd");
|
|
|
|
// Query per vista Guardie
|
|
const { data: guardSchedules, isLoading: isLoadingGuards } = useQuery<GuardSchedule[]>({
|
|
queryKey: ["/api/service-planning/by-guard", weekStartStr, selectedLocation],
|
|
queryFn: async () => {
|
|
const response = await fetch(`/api/service-planning/by-guard?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
|
if (!response.ok) throw new Error("Failed to fetch guard schedules");
|
|
return response.json();
|
|
},
|
|
enabled: viewMode === "guard",
|
|
});
|
|
|
|
// Query per vista Siti
|
|
const { data: siteSchedules, isLoading: isLoadingSites } = useQuery<SiteSchedule[]>({
|
|
queryKey: ["/api/service-planning/by-site", weekStartStr, selectedLocation],
|
|
queryFn: async () => {
|
|
const response = await fetch(`/api/service-planning/by-site?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
|
if (!response.ok) throw new Error("Failed to fetch site schedules");
|
|
return response.json();
|
|
},
|
|
enabled: viewMode === "site",
|
|
});
|
|
|
|
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
|
|
const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1));
|
|
|
|
return (
|
|
<div className="h-full overflow-auto p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Planning di Servizio</h1>
|
|
<p className="text-muted-foreground">
|
|
Visualizza orari e dotazioni per guardia o sito
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controlli */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
{/* Selezione sede */}
|
|
<div className="flex-1 min-w-[200px]">
|
|
<label className="text-sm font-medium mb-2 block">Sede</label>
|
|
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
|
|
<SelectTrigger 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-1 min-w-[300px]">
|
|
<label className="text-sm font-medium mb-2 block">Settimana</label>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="icon" onClick={goToPreviousWeek} data-testid="button-prev-week">
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div className="flex-1 text-center font-medium">
|
|
{format(weekStart, "d MMM", { locale: it })} - {format(addDays(weekStart, 6), "d MMM yyyy", { locale: it })}
|
|
</div>
|
|
<Button variant="outline" size="icon" onClick={goToNextWeek} data-testid="button-next-week">
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Tabs per vista */}
|
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard" | "site")}>
|
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
|
<TabsTrigger value="guard" data-testid="tab-guard-view">
|
|
<Users className="h-4 w-4 mr-2" />
|
|
Vista Agente
|
|
</TabsTrigger>
|
|
<TabsTrigger value="site" data-testid="tab-site-view">
|
|
<Building2 className="h-4 w-4 mr-2" />
|
|
Vista Sito
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Vista Agente */}
|
|
<TabsContent value="guard" className="space-y-4 mt-6">
|
|
{isLoadingGuards ? (
|
|
<div className="space-y-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-32 w-full" />
|
|
))}
|
|
</div>
|
|
) : guardSchedules && guardSchedules.length > 0 ? (
|
|
<div className="grid gap-4">
|
|
{guardSchedules.map((guard) => (
|
|
<Card key={guard.guardId} data-testid={`card-guard-${guard.guardId}`}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-lg">
|
|
{guard.guardName} <Badge variant="outline">{guard.badgeNumber}</Badge>
|
|
</CardTitle>
|
|
<Badge>{guard.totalHours}h totali</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{guard.shifts.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Nessun turno assegnato</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{guard.shifts.map((shift) => (
|
|
<div
|
|
key={shift.shiftId}
|
|
className="flex items-start justify-between p-3 rounded-md bg-muted/50"
|
|
data-testid={`shift-${shift.shiftId}`}
|
|
>
|
|
<div className="space-y-1">
|
|
<div className="font-medium">{shift.siteName}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to} ({shift.hours}h)
|
|
</div>
|
|
{shift.vehicle && (
|
|
<div className="text-xs text-muted-foreground">
|
|
🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model})
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-center text-muted-foreground">Nessuna guardia con turni assegnati</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Vista Sito */}
|
|
<TabsContent value="site" className="space-y-4 mt-6">
|
|
{isLoadingSites ? (
|
|
<div className="space-y-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-32 w-full" />
|
|
))}
|
|
</div>
|
|
) : siteSchedules && siteSchedules.length > 0 ? (
|
|
<div className="grid gap-4">
|
|
{siteSchedules.map((site) => (
|
|
<Card key={site.siteId} data-testid={`card-site-${site.siteId}`}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-lg">{site.siteName}</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">{site.totalShifts} turni</Badge>
|
|
<Badge>{site.totalHours}h totali</Badge>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{site.shifts.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Nessun turno programmato</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{site.shifts.map((shift) => (
|
|
<div
|
|
key={shift.shiftId}
|
|
className="p-3 rounded-md bg-muted/50 space-y-2"
|
|
data-testid={`shift-${shift.shiftId}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="font-medium">
|
|
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to}
|
|
</div>
|
|
<Badge variant="secondary">{shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"}</Badge>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{shift.guards.map((guard, idx) => (
|
|
<div key={idx} className="text-sm text-muted-foreground">
|
|
👤 {guard.guardName} ({guard.badgeNumber}) - {guard.hours}h
|
|
</div>
|
|
))}
|
|
</div>
|
|
{shift.vehicle && (
|
|
<div className="text-xs text-muted-foreground">
|
|
🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model})
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-center text-muted-foreground">Nessun sito con turni programmati</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|