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
This commit is contained in:
parent
14758fab56
commit
10a113c4a7
4
.replit
4
.replit
@ -31,6 +31,10 @@ externalPort = 3002
|
||||
localPort = 43267
|
||||
externalPort = 3003
|
||||
|
||||
[[ports]]
|
||||
localPort = 44791
|
||||
externalPort = 4200
|
||||
|
||||
[env]
|
||||
PORT = "5000"
|
||||
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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",
|
||||
|
||||
303
client/src/pages/general-planning.tsx
Normal file
303
client/src/pages/general-planning.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { format, startOfWeek, addWeeks } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
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 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
export default function GeneralPlanning() {
|
||||
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
|
||||
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||
|
||||
// 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()
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
{/* 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}`}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -866,7 +866,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
);
|
||||
|
||||
// Ottieni tutte le assegnazioni dei turni della settimana
|
||||
const shiftIds = weekShifts.map(s => s.shift.id);
|
||||
const shiftIds = weekShifts.map((s: any) => s.shift.id);
|
||||
|
||||
const assignments = shiftIds.length > 0 ? await db
|
||||
.select({
|
||||
@ -878,19 +878,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
|
||||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||
.where(
|
||||
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map(id => sql`${id}`), sql`, `)})`
|
||||
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
|
||||
) : [];
|
||||
|
||||
// Ottieni veicoli assegnati
|
||||
const vehicleAssignments = weekShifts
|
||||
.filter(s => s.shift.vehicleId)
|
||||
.map(s => s.shift.vehicleId);
|
||||
.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 => sql`${id}`), sql`, `)})`
|
||||
sql`${vehicles.id} IN (${sql.join(vehicleAssignments.map((id: string) => sql`${id}`), sql`, `)})`
|
||||
) : [];
|
||||
|
||||
// Costruisci struttura dati per 7 giorni
|
||||
@ -906,21 +906,21 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const dayEndTimestamp = new Date(dayStr);
|
||||
dayEndTimestamp.setHours(23, 59, 59, 999);
|
||||
|
||||
const sitesData = activeSites.map(({ sites: site, service_types: serviceType }) => {
|
||||
const sitesData = activeSites.map(({ sites: site, service_types: serviceType }: any) => {
|
||||
// Trova turni del giorno per questo sito
|
||||
const dayShifts = weekShifts.filter(s =>
|
||||
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 =>
|
||||
dayShifts.some(ds => ds.shift.id === a.shift.id)
|
||||
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 => {
|
||||
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);
|
||||
@ -935,9 +935,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
|
||||
// Veicoli assegnati ai turni del giorno
|
||||
const dayVehicles = dayShifts
|
||||
.filter(ds => ds.shift.vehicleId)
|
||||
.map(ds => {
|
||||
const vehicle = assignedVehicles.find(v => v.id === ds.shift.vehicleId);
|
||||
.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,
|
||||
@ -953,7 +953,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const minGuardie = site.minGuards || 1;
|
||||
|
||||
// Somma ore totali dei turni del giorno
|
||||
const totalShiftHours = dayShifts.reduce((sum, ds) => {
|
||||
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);
|
||||
@ -966,7 +966,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const totalGuardsNeeded = slotsNeeded * minGuardie;
|
||||
|
||||
// Guardie uniche assegnate (conta ogni guardia una volta anche se ha più turni)
|
||||
const uniqueGuardsAssigned = new Set(guardsWithHours.map(g => g.guardId)).size;
|
||||
const uniqueGuardsAssigned = new Set(guardsWithHours.map((g: any) => g.guardId)).size;
|
||||
|
||||
// Guardie mancanti
|
||||
const missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user