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:
parent
a945abdb5d
commit
efcaca356a
@ -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} />
|
||||
|
||||
@ -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",
|
||||
|
||||
288
client/src/pages/service-planning.tsx
Normal file
288
client/src/pages/service-planning.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
server/routes.ts
244
server/routes.ts
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user