Add a new section for viewing and managing service planning details

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
This commit is contained in:
marco370 2025-10-22 08:08:00 +00:00
parent a945abdb5d
commit efcaca356a
4 changed files with 540 additions and 0 deletions

View File

@ -24,6 +24,7 @@ import Services from "@/pages/services";
import Planning from "@/pages/planning";
import OperationalPlanning from "@/pages/operational-planning";
import GeneralPlanning from "@/pages/general-planning";
import ServicePlanning from "@/pages/service-planning";
function Router() {
const { isAuthenticated, isLoading } = useAuth();
@ -44,6 +45,7 @@ function Router() {
<Route path="/planning" component={Planning} />
<Route path="/operational-planning" component={OperationalPlanning} />
<Route path="/general-planning" component={GeneralPlanning} />
<Route path="/service-planning" component={ServicePlanning} />
<Route path="/advanced-planning" component={AdvancedPlanning} />
<Route path="/reports" component={Reports} />
<Route path="/notifications" component={Notifications} />

View File

@ -61,6 +61,12 @@ const menuItems = [
icon: BarChart3,
roles: ["admin", "coordinator"],
},
{
title: "Planning di Servizio",
url: "/service-planning",
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Gestione Pianificazioni",
url: "/advanced-planning",

View File

@ -0,0 +1,288 @@
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>
);
}

View File

@ -1429,6 +1429,250 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ============= SERVICE PLANNING ROUTES =============
// Vista per Guardia - mostra orari e dotazioni per ogni guardia
app.get("/api/service-planning/by-guard", isAuthenticated, async (req, res) => {
try {
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
const normalizedWeekStart = rawWeekStart.split("/")[0];
const parsedWeekStart = parseISO(normalizedWeekStart);
if (!isValid(parsedWeekStart)) {
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
}
const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd");
const location = req.query.location as string || "roccapiemonte";
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
const weekStartTimestamp = new Date(weekStartDate);
weekStartTimestamp.setHours(0, 0, 0, 0);
const weekEndTimestamp = new Date(weekEndDate);
weekEndTimestamp.setHours(23, 59, 59, 999);
// Ottieni tutte le guardie della sede
const allGuards = await db
.select()
.from(guards)
.where(eq(guards.location, location as any))
.orderBy(guards.fullName);
// Ottieni tutti i turni della settimana per la sede (con JOIN su sites per filtrare location)
const weekShifts = await db
.select({
shift: shifts,
site: sites,
vehicle: vehicles,
})
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id))
.where(
and(
gte(shifts.startTime, weekStartTimestamp),
lte(shifts.startTime, weekEndTimestamp),
ne(shifts.status, "cancelled"),
eq(sites.location, location as any)
)
);
// Ottieni tutte le assegnazioni per i turni della settimana
const shiftIds = weekShifts.map((s: any) => s.shift.id);
const assignments = shiftIds.length > 0 ? await db
.select({
assignment: shiftAssignments,
guard: guards,
})
.from(shiftAssignments)
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
.where(
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
) : [];
// Costruisci dati per ogni guardia
const guardSchedules = allGuards.map((guard: any) => {
// Trova assegnazioni della guardia
const guardAssignments = assignments.filter((a: any) => a.guard.id === guard.id);
// Costruisci lista turni con dettagli
const shifts = guardAssignments.map((a: any) => {
const shiftData = weekShifts.find((s: any) => s.shift.id === a.assignment.shiftId);
if (!shiftData) return null;
const plannedStart = new Date(a.assignment.plannedStartTime);
const plannedEnd = new Date(a.assignment.plannedEndTime);
const minutes = differenceInMinutes(plannedEnd, plannedStart);
const hours = Math.round((minutes / 60) * 10) / 10; // Arrotonda a 1 decimale
return {
shiftId: shiftData.shift.id,
date: format(plannedStart, "yyyy-MM-dd"),
from: format(plannedStart, "HH:mm"),
to: format(plannedEnd, "HH:mm"),
siteName: shiftData.site.name,
siteId: shiftData.site.id,
vehicle: shiftData.vehicle ? {
licensePlate: shiftData.vehicle.licensePlate,
brand: shiftData.vehicle.brand,
model: shiftData.vehicle.model,
} : undefined,
hours,
};
}).filter(Boolean);
const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.hours, 0) * 10) / 10;
return {
guardId: guard.id,
guardName: guard.fullName,
badgeNumber: guard.badgeNumber,
shifts,
totalHours,
};
});
// Filtra solo guardie con turni assegnati
const guardsWithShifts = guardSchedules.filter((g: any) => g.shifts.length > 0);
res.json(guardsWithShifts);
} catch (error) {
console.error("Error fetching guard schedules:", error);
res.status(500).json({ message: "Failed to fetch guard schedules", error: String(error) });
}
});
// Vista per Sito - mostra agenti e dotazioni per ogni sito
app.get("/api/service-planning/by-site", isAuthenticated, async (req, res) => {
try {
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
const normalizedWeekStart = rawWeekStart.split("/")[0];
const parsedWeekStart = parseISO(normalizedWeekStart);
if (!isValid(parsedWeekStart)) {
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
}
const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd");
const location = req.query.location as string || "roccapiemonte";
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
const weekStartTimestamp = new Date(weekStartDate);
weekStartTimestamp.setHours(0, 0, 0, 0);
const weekEndTimestamp = new Date(weekEndDate);
weekEndTimestamp.setHours(23, 59, 59, 999);
// Ottieni tutti i siti attivi della sede
const activeSites = await db
.select()
.from(sites)
.where(
and(
eq(sites.isActive, true),
eq(sites.location, location as any)
)
)
.orderBy(sites.name);
// Ottieni tutti i turni della settimana per la sede
const weekShifts = await db
.select({
shift: shifts,
site: sites,
vehicle: vehicles,
})
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id))
.where(
and(
gte(shifts.startTime, weekStartTimestamp),
lte(shifts.startTime, weekEndTimestamp),
ne(shifts.status, "cancelled"),
eq(sites.location, location as any)
)
);
// Ottieni tutte le assegnazioni per i turni della settimana
const shiftIds = weekShifts.map((s: any) => s.shift.id);
const assignments = shiftIds.length > 0 ? await db
.select({
assignment: shiftAssignments,
guard: guards,
})
.from(shiftAssignments)
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
.where(
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
) : [];
// Costruisci dati per ogni sito
const siteSchedules = activeSites.map((site: any) => {
// Trova turni del sito
const siteShifts = weekShifts.filter((s: any) => s.site.id === site.id);
// Costruisci lista turni con guardie e veicoli
const shifts = siteShifts.map((shiftData: any) => {
const shiftAssignments = assignments.filter((a: any) => a.assignment.shiftId === shiftData.shift.id);
const guards = shiftAssignments.map((a: any) => {
const plannedStart = new Date(a.assignment.plannedStartTime);
const plannedEnd = new Date(a.assignment.plannedEndTime);
const minutes = differenceInMinutes(plannedEnd, plannedStart);
const hours = Math.round((minutes / 60) * 10) / 10; // Arrotonda a 1 decimale
return {
guardName: a.guard.fullName,
badgeNumber: a.guard.badgeNumber,
hours,
};
});
const shiftStart = new Date(shiftData.shift.startTime);
const shiftEnd = new Date(shiftData.shift.endTime);
const minutes = differenceInMinutes(shiftEnd, shiftStart);
const totalHours = Math.round((minutes / 60) * 10) / 10;
return {
shiftId: shiftData.shift.id,
date: format(shiftStart, "yyyy-MM-dd"),
from: format(shiftStart, "HH:mm"),
to: format(shiftEnd, "HH:mm"),
guards,
vehicle: shiftData.vehicle ? {
licensePlate: shiftData.vehicle.licensePlate,
brand: shiftData.vehicle.brand,
model: shiftData.vehicle.model,
} : undefined,
totalGuards: guards.length,
totalHours,
};
});
const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.totalHours, 0) * 10) / 10;
return {
siteId: site.id,
siteName: site.name,
location: site.location,
shifts,
totalShifts: shifts.length,
totalHours,
};
});
// Filtra solo siti con turni programmati
const sitesWithShifts = siteSchedules.filter((s: any) => s.shifts.length > 0);
res.json(sitesWithShifts);
} catch (error) {
console.error("Error fetching site schedules:", error);
res.status(500).json({ message: "Failed to fetch site schedules", error: String(error) });
}
});
// ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => {
try {