Compare commits

...

10 Commits

Author SHA1 Message Date
Marco Lanzara
2616fb775a 🚀 Release v1.0.20
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.20_20251018_074134.sql.gz
- Data: 2025-10-18 07:41:50
2025-10-18 07:41:50 +00:00
marco370
cf5fabbdab Update shift planning to display relevant sites and available guards
Update the planning module to filter sites based on contract dates and display guards not yet assigned to shifts.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/uZXH8P1
2025-10-18 07:41:26 +00:00
marco370
4d6fb9dff8 Improve planning overview to show sites with active contracts
Update the general planning overview to filter sites by active contract dates and display a weekly summary of total guards needed, assigned, and missing.

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/uZXH8P1
2025-10-18 07:40:54 +00:00
marco370
63ce62ee24 Add a summary of guard availability to the planning view
Update the `GeneralPlanningResponse` interface to include a `summary` object containing `totalGuardsNeeded`, `totalGuardsAssigned`, and `totalGuardsMissing`. Render this summary in the UI.

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/uZXH8P1
2025-10-18 07:39:02 +00:00
marco370
c07441cd72 Filter sites by contract dates and calculate weekly guard summary
Update `registerRoutes` to filter active sites by contract validity dates within the specified week and calculate total guards needed, assigned, and missing for the week.

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/uZXH8P1
2025-10-18 07:38:01 +00:00
marco370
edbd1f1aae Enhance planning features with multi-location support and weekly overview
Add location-based resource isolation, a general weekly planning overview with missing guard calculation, and improve operational planning page integration.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/uZXH8P1
2025-10-18 07:27:10 +00:00
marco370
cafaa76608 Update planning page to use URL parameters for location and date
Incorporate `useLocation` hook from `wouter` to read `date` and `location` from URL search parameters, allowing pre-selection of these values and updating component state accordingly via `useEffect`.

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/uZXH8P1
2025-10-18 07:24:05 +00:00
marco370
fad541525b Add ability to view and edit shift details for each location
Integrates a dialog modal for detailed shift information, enabling navigation to operational planning and allowing users to view and potentially edit shift 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/uZXH8P1
2025-10-18 07:21:39 +00:00
marco370
10a113c4a7 Add a comprehensive general planning view with missing guard calculations
Adds a new client-side route and component for general planning, integrates it into the sidebar navigation, and updates server-side routes to fetch and process shift, guard, and vehicle assignment data for weekly planning views.

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/uZXH8P1
2025-10-18 07:20:15 +00:00
marco370
14758fab56 Add general weekly shift planning with missing guard calculations
Introduce a new API endpoint '/api/general-planning' to fetch weekly shift schedules, including active sites, assigned guards, and calculates missing guard requirements based on shift durations and site needs.

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/5GnGQQ0
2025-10-18 07:17:19 +00:00
9 changed files with 747 additions and 8 deletions

View File

@ -23,6 +23,7 @@ import Parameters from "@/pages/parameters";
import Services from "@/pages/services";
import Planning from "@/pages/planning";
import OperationalPlanning from "@/pages/operational-planning";
import GeneralPlanning from "@/pages/general-planning";
function Router() {
const { isAuthenticated, isLoading } = useAuth();
@ -42,6 +43,7 @@ function Router() {
<Route path="/shifts" component={Shifts} />
<Route path="/planning" component={Planning} />
<Route path="/operational-planning" component={OperationalPlanning} />
<Route path="/general-planning" component={GeneralPlanning} />
<Route path="/advanced-planning" component={AdvancedPlanning} />
<Route path="/reports" component={Reports} />
<Route path="/notifications" component={Notifications} />

View File

@ -55,6 +55,12 @@ const menuItems = [
icon: Calendar,
roles: ["admin", "coordinator"],
},
{
title: "Planning Generale",
url: "/general-planning",
icon: BarChart3,
roles: ["admin", "coordinator"],
},
{
title: "Gestione Pianificazioni",
url: "/advanced-planning",

View 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>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "@/lib/queryClient";
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 { it } from "date-fns/locale";
import { useToast } from "@/hooks/use-toast";
import { useLocation } from "wouter";
interface Shift {
id: string;
@ -93,15 +94,34 @@ const locationLabels: Record<string, string> = {
export default function OperationalPlanning() {
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>(
format(new Date(), "yyyy-MM-dd")
urlDate || format(new Date(), "yyyy-MM-dd")
);
const [selectedSite, setSelectedSite] = useState<UncoveredSite | null>(null);
const [selectedGuards, setSelectedGuards] = useState<string[]>([]);
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
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)
const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
queryKey: ['/api/operational-planning/uncovered-sites', selectedDate, selectedLocation],

View File

@ -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
- **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:
1. Select sede (Roccapiemonte/Milano/Roma) - first step with default value
2. Select date
@ -46,6 +46,17 @@ The database includes core tables for `users`, `guards`, `certifications`, `site
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
- **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)**:
- **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.

View File

@ -4,9 +4,9 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
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 { 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
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 =============
app.post("/api/certifications", isAuthenticated, async (req, res) => {
try {

View File

@ -1,7 +1,13 @@
{
"version": "1.0.19",
"lastUpdate": "2025-10-17T17:24:45.675Z",
"version": "1.0.20",
"lastUpdate": "2025-10-18T07:41:49.922Z",
"changelog": [
{
"version": "1.0.20",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.20"
},
{
"version": "1.0.19",
"date": "2025-10-17",