Compare commits
10 Commits
f0c0321d1a
...
2616fb775a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2616fb775a | ||
|
|
cf5fabbdab | ||
|
|
4d6fb9dff8 | ||
|
|
63ce62ee24 | ||
|
|
c07441cd72 | ||
|
|
edbd1f1aae | ||
|
|
cafaa76608 | ||
|
|
fad541525b | ||
|
|
10a113c4a7 | ||
|
|
14758fab56 |
@ -23,6 +23,7 @@ import Parameters from "@/pages/parameters";
|
|||||||
import Services from "@/pages/services";
|
import Services from "@/pages/services";
|
||||||
import Planning from "@/pages/planning";
|
import Planning from "@/pages/planning";
|
||||||
import OperationalPlanning from "@/pages/operational-planning";
|
import OperationalPlanning from "@/pages/operational-planning";
|
||||||
|
import GeneralPlanning from "@/pages/general-planning";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
@ -42,6 +43,7 @@ function Router() {
|
|||||||
<Route path="/shifts" component={Shifts} />
|
<Route path="/shifts" component={Shifts} />
|
||||||
<Route path="/planning" component={Planning} />
|
<Route path="/planning" component={Planning} />
|
||||||
<Route path="/operational-planning" component={OperationalPlanning} />
|
<Route path="/operational-planning" component={OperationalPlanning} />
|
||||||
|
<Route path="/general-planning" component={GeneralPlanning} />
|
||||||
<Route path="/advanced-planning" component={AdvancedPlanning} />
|
<Route path="/advanced-planning" component={AdvancedPlanning} />
|
||||||
<Route path="/reports" component={Reports} />
|
<Route path="/reports" component={Reports} />
|
||||||
<Route path="/notifications" component={Notifications} />
|
<Route path="/notifications" component={Notifications} />
|
||||||
|
|||||||
@ -55,6 +55,12 @@ const menuItems = [
|
|||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Planning Generale",
|
||||||
|
url: "/general-planning",
|
||||||
|
icon: BarChart3,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Gestione Pianificazioni",
|
title: "Gestione Pianificazioni",
|
||||||
url: "/advanced-planning",
|
url: "/advanced-planning",
|
||||||
|
|||||||
468
client/src/pages/general-planning.tsx
Normal file
468
client/src/pages/general-planning.tsx
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
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[];
|
||||||
|
summary: {
|
||||||
|
totalGuardsNeeded: number;
|
||||||
|
totalGuardsAssigned: number;
|
||||||
|
totalGuardsMissing: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Summary Guardie Settimana */}
|
||||||
|
{!isLoading && planningData?.summary && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Riepilogo Guardie Settimana
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-4 bg-primary/10 rounded-lg border border-primary/20">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Guardie Necessarie</p>
|
||||||
|
<p className="text-4xl font-bold text-primary">{planningData.summary.totalGuardsNeeded}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/20">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Guardie Pianificate</p>
|
||||||
|
<p className="text-4xl font-bold text-green-600 dark:text-green-500">{planningData.summary.totalGuardsAssigned}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`text-center p-4 rounded-lg border ${planningData.summary.totalGuardsMissing > 0 ? 'bg-destructive/10 border-destructive/20' : 'bg-green-500/10 border-green-500/20'}`}>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Guardie Mancanti</p>
|
||||||
|
<p className={`text-4xl font-bold ${planningData.summary.totalGuardsMissing > 0 ? 'text-destructive' : 'text-green-600 dark:text-green-500'}`}>
|
||||||
|
{planningData.summary.totalGuardsMissing}
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
@ -14,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { it } from "date-fns/locale";
|
import { it } from "date-fns/locale";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
interface Shift {
|
interface Shift {
|
||||||
id: string;
|
id: string;
|
||||||
@ -93,14 +94,33 @@ const locationLabels: Record<string, string> = {
|
|||||||
|
|
||||||
export default function OperationalPlanning() {
|
export default function OperationalPlanning() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte"); // Sede selezionata (primo step)
|
const [location] = useLocation();
|
||||||
|
|
||||||
|
// Leggi parametri dalla URL
|
||||||
|
const searchParams = new URLSearchParams(location.split('?')[1] || '');
|
||||||
|
const urlDate = searchParams.get('date');
|
||||||
|
const urlLocation = searchParams.get('location');
|
||||||
|
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<string>(
|
||||||
|
urlLocation && ['roccapiemonte', 'milano', 'roma'].includes(urlLocation)
|
||||||
|
? urlLocation
|
||||||
|
: "roccapiemonte"
|
||||||
|
);
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(
|
const [selectedDate, setSelectedDate] = useState<string>(
|
||||||
format(new Date(), "yyyy-MM-dd")
|
urlDate || format(new Date(), "yyyy-MM-dd")
|
||||||
);
|
);
|
||||||
const [selectedSite, setSelectedSite] = useState<UncoveredSite | null>(null);
|
const [selectedSite, setSelectedSite] = useState<UncoveredSite | null>(null);
|
||||||
const [selectedGuards, setSelectedGuards] = useState<string[]>([]);
|
const [selectedGuards, setSelectedGuards] = useState<string[]>([]);
|
||||||
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||||
const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false);
|
const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Aggiorna stato quando cambiano i parametri URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlDate) setSelectedDate(urlDate);
|
||||||
|
if (urlLocation && ['roccapiemonte', 'milano', 'roma'].includes(urlLocation)) {
|
||||||
|
setSelectedLocation(urlLocation);
|
||||||
|
}
|
||||||
|
}, [urlDate, urlLocation]);
|
||||||
|
|
||||||
// Query per siti non coperti (filtrati per sede e data)
|
// Query per siti non coperti (filtrati per sede e data)
|
||||||
const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
|
const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
|
||||||
|
|||||||
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.20_20251018_074134.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.20_20251018_074134.sql.gz
Normal file
Binary file not shown.
13
replit.md
13
replit.md
@ -37,7 +37,7 @@ The database includes core tables for `users`, `guards`, `certifications`, `site
|
|||||||
- Sites now reference service types via `serviceTypeId` foreign key; `shiftType` is optional and can be derived from service type
|
- Sites now reference service types via `serviceTypeId` foreign key; `shiftType` is optional and can be derived from service type
|
||||||
- **Multi-Location Support**: Added `location` field (enum: roccapiemonte, milano, roma) to `sites`, `guards`, and `vehicles` tables for complete multi-sede resource isolation
|
- **Multi-Location Support**: Added `location` field (enum: roccapiemonte, milano, roma) to `sites`, `guards`, and `vehicles` tables for complete multi-sede resource isolation
|
||||||
|
|
||||||
**Recent Features (October 17, 2025)**:
|
**Recent Features (October 17-18, 2025)**:
|
||||||
- **Multi-Sede Operational Planning**: Redesigned operational planning workflow with location-first approach:
|
- **Multi-Sede Operational Planning**: Redesigned operational planning workflow with location-first approach:
|
||||||
1. Select sede (Roccapiemonte/Milano/Roma) - first step with default value
|
1. Select sede (Roccapiemonte/Milano/Roma) - first step with default value
|
||||||
2. Select date
|
2. Select date
|
||||||
@ -46,6 +46,17 @@ The database includes core tables for `users`, `guards`, `certifications`, `site
|
|||||||
5. Assign resources and create shift
|
5. Assign resources and create shift
|
||||||
- **Location-Based Filtering**: Backend endpoints use INNER JOIN with sites table to ensure complete resource isolation between locations - guards/vehicles in one sede remain available even when assigned to shifts in other sedi
|
- **Location-Based Filtering**: Backend endpoints use INNER JOIN with sites table to ensure complete resource isolation between locations - guards/vehicles in one sede remain available even when assigned to shifts in other sedi
|
||||||
- **Site Management**: Added sede selection in site creation/editing forms with visual badges showing location in site listings
|
- **Site Management**: Added sede selection in site creation/editing forms with visual badges showing location in site listings
|
||||||
|
- **Planning Generale (October 18, 2025)**: New weekly planning overview feature showing all sites × 7 days in table format:
|
||||||
|
- **Contract filtering**: Shows only sites with active contracts in the week dates (`contractStartDate <= weekEnd AND contractEndDate >= weekStart`)
|
||||||
|
- Backend endpoint `/api/general-planning?weekStart=YYYY-MM-DD&location=sede` with complex joins and location filtering
|
||||||
|
- Automatic missing guards calculation: `ceil(totalShiftHours / maxHoursPerGuard) × minGuards - assignedGuards` (e.g., 24h shift, 2 guards min, 9h max = 6 total needed)
|
||||||
|
- **Weekly summary**: Shows total guards needed, guards assigned (counting slots, not unique people), and guards missing for the entire week
|
||||||
|
- Table cells display: assigned guards with hours, vehicles, missing guards badge (if any), shift count, total hours
|
||||||
|
- Interactive cells with click handler opening detail dialog
|
||||||
|
- Dialog shows: shift count, total hours, guard list with hours and badge numbers, vehicle list, missing guards warning with explanation
|
||||||
|
- "Modifica in Pianificazione Operativa" button in dialog navigates to operational planning page with pre-filled date/location parameters
|
||||||
|
- Week navigation (previous/next week) with location selector
|
||||||
|
- Operational planning page now supports query parameters (`?date=YYYY-MM-DD&location=sede`) for seamless integration
|
||||||
|
|
||||||
**Recent Bug Fixes (October 17, 2025)**:
|
**Recent Bug Fixes (October 17, 2025)**:
|
||||||
- **Operational Planning Date Handling**: Fixed date sanitization in `/api/operational-planning/uncovered-sites` and `/api/operational-planning/availability` endpoints to handle malformed date inputs (e.g., "2025-10-17/2025-10-17"). Both endpoints now validate dates using `parseISO`/`isValid` and return 400 for invalid formats.
|
- **Operational Planning Date Handling**: Fixed date sanitization in `/api/operational-planning/uncovered-sites` and `/api/operational-planning/availability` endpoints to handle malformed date inputs (e.g., "2025-10-17/2025-10-17"). Both endpoints now validate dates using `parseISO`/`isValid` and return 400 for invalid formats.
|
||||||
|
|||||||
230
server/routes.ts
230
server/routes.ts
@ -4,9 +4,9 @@ import { storage } from "./storage";
|
|||||||
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
||||||
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } from "@shared/schema";
|
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes } from "@shared/schema";
|
||||||
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
||||||
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid } from "date-fns";
|
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
||||||
|
|
||||||
// Determina quale sistema auth usare basandosi sull'ambiente
|
// Determina quale sistema auth usare basandosi sull'ambiente
|
||||||
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
|
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
|
||||||
@ -809,6 +809,232 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Endpoint per Planning Generale - vista settimanale con calcolo guardie mancanti
|
||||||
|
app.get("/api/general-planning", isAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Sanitizza input data inizio settimana
|
||||||
|
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
|
||||||
|
const normalizedWeekStart = rawWeekStart.split("/")[0];
|
||||||
|
|
||||||
|
// Valida la data
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Ottieni location dalla query (default: roccapiemonte)
|
||||||
|
const location = req.query.location as string || "roccapiemonte";
|
||||||
|
|
||||||
|
// Calcola fine settimana (weekStart + 6 giorni)
|
||||||
|
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
|
||||||
|
|
||||||
|
// Timestamp per filtro contratti
|
||||||
|
const weekStartTimestampForContract = new Date(weekStartDate);
|
||||||
|
const weekEndTimestampForContract = new Date(weekEndDate);
|
||||||
|
|
||||||
|
// Ottieni tutti i siti attivi della sede con contratto valido nelle date della settimana
|
||||||
|
const activeSites = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.isActive, true),
|
||||||
|
eq(sites.location, location as any),
|
||||||
|
// Contratto deve essere valido in almeno un giorno della settimana
|
||||||
|
// contractStartDate <= weekEnd AND contractEndDate >= weekStart
|
||||||
|
lte(sites.contractStartDate, weekEndTimestampForContract),
|
||||||
|
gte(sites.contractEndDate, weekStartTimestampForContract)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ottieni tutti i turni della settimana per la sede
|
||||||
|
const weekStartTimestamp = new Date(weekStartDate);
|
||||||
|
weekStartTimestamp.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const weekEndTimestamp = new Date(weekEndDate);
|
||||||
|
weekEndTimestamp.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const weekShifts = await db
|
||||||
|
.select({
|
||||||
|
shift: shifts,
|
||||||
|
site: sites,
|
||||||
|
})
|
||||||
|
.from(shifts)
|
||||||
|
.innerJoin(sites, eq(shifts.siteId, sites.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 dei turni della settimana
|
||||||
|
const shiftIds = weekShifts.map((s: any) => s.shift.id);
|
||||||
|
|
||||||
|
const assignments = shiftIds.length > 0 ? await db
|
||||||
|
.select({
|
||||||
|
assignment: shiftAssignments,
|
||||||
|
guard: guards,
|
||||||
|
shift: shifts,
|
||||||
|
})
|
||||||
|
.from(shiftAssignments)
|
||||||
|
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
|
||||||
|
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||||
|
.where(
|
||||||
|
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
// Ottieni veicoli assegnati
|
||||||
|
const vehicleAssignments = weekShifts
|
||||||
|
.filter((s: any) => s.shift.vehicleId)
|
||||||
|
.map((s: any) => s.shift.vehicleId);
|
||||||
|
|
||||||
|
const assignedVehicles = vehicleAssignments.length > 0 ? await db
|
||||||
|
.select()
|
||||||
|
.from(vehicles)
|
||||||
|
.where(
|
||||||
|
sql`${vehicles.id} IN (${sql.join(vehicleAssignments.map((id: string) => sql`${id}`), sql`, `)})`
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
// Costruisci struttura dati per 7 giorni
|
||||||
|
const weekData = [];
|
||||||
|
|
||||||
|
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
||||||
|
const currentDay = addDays(parsedWeekStart, dayOffset);
|
||||||
|
const dayStr = format(currentDay, "yyyy-MM-dd");
|
||||||
|
|
||||||
|
const dayStartTimestamp = new Date(dayStr);
|
||||||
|
dayStartTimestamp.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const dayEndTimestamp = new Date(dayStr);
|
||||||
|
dayEndTimestamp.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const sitesData = activeSites.map(({ sites: site, service_types: serviceType }: any) => {
|
||||||
|
// Trova turni del giorno per questo sito
|
||||||
|
const dayShifts = weekShifts.filter((s: any) =>
|
||||||
|
s.shift.siteId === site.id &&
|
||||||
|
s.shift.startTime >= dayStartTimestamp &&
|
||||||
|
s.shift.startTime <= dayEndTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ottieni assegnazioni guardie per i turni del giorno
|
||||||
|
const dayAssignments = assignments.filter((a: any) =>
|
||||||
|
dayShifts.some((ds: any) => ds.shift.id === a.shift.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calcola ore per ogni guardia
|
||||||
|
const guardsWithHours = dayAssignments.map((a: any) => {
|
||||||
|
const shiftStart = new Date(a.shift.startTime);
|
||||||
|
const shiftEnd = new Date(a.shift.endTime);
|
||||||
|
const hours = differenceInHours(shiftEnd, shiftStart);
|
||||||
|
|
||||||
|
return {
|
||||||
|
guardId: a.guard.id,
|
||||||
|
guardName: a.guard.fullName,
|
||||||
|
badgeNumber: a.guard.badgeNumber,
|
||||||
|
hours,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Veicoli assegnati ai turni del giorno
|
||||||
|
const dayVehicles = dayShifts
|
||||||
|
.filter((ds: any) => ds.shift.vehicleId)
|
||||||
|
.map((ds: any) => {
|
||||||
|
const vehicle = assignedVehicles.find((v: any) => v.id === ds.shift.vehicleId);
|
||||||
|
return vehicle ? {
|
||||||
|
vehicleId: vehicle.id,
|
||||||
|
licensePlate: vehicle.licensePlate,
|
||||||
|
brand: vehicle.brand,
|
||||||
|
model: vehicle.model,
|
||||||
|
} : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// Calcolo guardie mancanti
|
||||||
|
// Formula: ceil(24 / maxOreGuardia) × minGuardie - guardieAssegnate
|
||||||
|
const maxOreGuardia = 9; // Max ore per guardia
|
||||||
|
const minGuardie = site.minGuards || 1;
|
||||||
|
|
||||||
|
// Somma ore totali dei turni del giorno
|
||||||
|
const totalShiftHours = dayShifts.reduce((sum: number, ds: any) => {
|
||||||
|
const start = new Date(ds.shift.startTime);
|
||||||
|
const end = new Date(ds.shift.endTime);
|
||||||
|
return sum + differenceInHours(end, start);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Slot necessari per coprire le ore totali
|
||||||
|
const slotsNeeded = totalShiftHours > 0 ? Math.ceil(totalShiftHours / maxOreGuardia) : 0;
|
||||||
|
|
||||||
|
// Guardie totali necessarie (slot × min guardie contemporanee)
|
||||||
|
const totalGuardsNeeded = slotsNeeded * minGuardie;
|
||||||
|
|
||||||
|
// Guardie uniche assegnate (conta ogni guardia una volta anche se ha più turni)
|
||||||
|
const uniqueGuardsAssigned = new Set(guardsWithHours.map((g: any) => g.guardId)).size;
|
||||||
|
|
||||||
|
// Guardie mancanti
|
||||||
|
const missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned);
|
||||||
|
|
||||||
|
return {
|
||||||
|
siteId: site.id,
|
||||||
|
siteName: site.name,
|
||||||
|
serviceType: serviceType?.label || "N/A",
|
||||||
|
minGuards: site.minGuards,
|
||||||
|
guards: guardsWithHours,
|
||||||
|
vehicles: dayVehicles,
|
||||||
|
totalShiftHours,
|
||||||
|
guardsAssigned: uniqueGuardsAssigned,
|
||||||
|
missingGuards,
|
||||||
|
shiftsCount: dayShifts.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
weekData.push({
|
||||||
|
date: dayStr,
|
||||||
|
dayOfWeek: format(currentDay, "EEEE"),
|
||||||
|
sites: sitesData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcola guardie totali necessarie per l'intera settimana
|
||||||
|
let totalGuardsNeededForWeek = 0;
|
||||||
|
let totalGuardsAssignedForWeek = 0;
|
||||||
|
|
||||||
|
for (const day of weekData) {
|
||||||
|
for (const siteData of day.sites) {
|
||||||
|
// Somma guardie necessarie (già calcolate per sito/giorno)
|
||||||
|
// totalGuardsNeeded per sito = guardsAssigned + missingGuards
|
||||||
|
totalGuardsNeededForWeek += (siteData.guardsAssigned + siteData.missingGuards);
|
||||||
|
|
||||||
|
// Somma slot guardia assegnati (non guardie uniche)
|
||||||
|
// Questo conta ogni assegnazione, anche se la stessa guardia lavora più turni
|
||||||
|
totalGuardsAssignedForWeek += siteData.guardsAssigned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalGuardsMissingForWeek = Math.max(0, totalGuardsNeededForWeek - totalGuardsAssignedForWeek);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
weekStart: weekStartDate,
|
||||||
|
weekEnd: weekEndDate,
|
||||||
|
location,
|
||||||
|
days: weekData,
|
||||||
|
summary: {
|
||||||
|
totalGuardsNeeded: totalGuardsNeededForWeek,
|
||||||
|
totalGuardsAssigned: totalGuardsAssignedForWeek,
|
||||||
|
totalGuardsMissing: totalGuardsMissingForWeek,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching general planning:", error);
|
||||||
|
res.status(500).json({ message: "Failed to fetch general planning", error: String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============= CERTIFICATION ROUTES =============
|
// ============= CERTIFICATION ROUTES =============
|
||||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
10
version.json
10
version.json
@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.19",
|
"version": "1.0.20",
|
||||||
"lastUpdate": "2025-10-17T17:24:45.675Z",
|
"lastUpdate": "2025-10-18T07:41:49.922Z",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
{
|
||||||
|
"version": "1.0.20",
|
||||||
|
"date": "2025-10-18",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.20"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.19",
|
"version": "1.0.19",
|
||||||
"date": "2025-10-17",
|
"date": "2025-10-17",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user