Compare commits

..

No commits in common. "03049f4090a1e6959f8f329752034c1ed2134ca1" and "24a1c81d6ebc8b3000d7a5a8901630563545fa9d" have entirely different histories.

9 changed files with 206 additions and 1507 deletions

View File

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

View File

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

View File

@ -21,7 +21,7 @@ import {
} from "@/components/ui/dialog";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import type { GuardAvailability, Vehicle as VehicleDb } from "@shared/schema";
import type { GuardAvailability } from "@shared/schema";
interface GuardWithHours {
assignmentId: string;
@ -98,7 +98,6 @@ export default function GeneralPlanning() {
// Form state per assegnazione guardia
const [selectedGuardId, setSelectedGuardId] = useState<string>("");
const [selectedVehicleId, setSelectedVehicleId] = useState<string>("");
const [startTime, setStartTime] = useState<string>("06:00");
const [durationHours, setDurationHours] = useState<number>(8);
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
@ -148,19 +147,6 @@ export default function GeneralPlanning() {
staleTime: 0, // Dati sempre considerati stale, refetch ad ogni apertura dialog
});
// Query per veicoli disponibili (solo quando dialog è aperto)
const { data: availableVehicles, isLoading: isLoadingVehicles } = useQuery<VehicleDb[]>({
queryKey: ["/api/vehicles/available", selectedLocation],
queryFn: async () => {
if (!selectedCell) return [];
const response = await fetch(`/api/vehicles/available?location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch available vehicles");
return response.json();
},
enabled: !!selectedCell,
staleTime: 0,
});
// Mutation per eliminare assegnazione guardia
const deleteAssignmentMutation = useMutation({
mutationFn: async (assignmentId: string) => {
@ -186,14 +172,13 @@ export default function GeneralPlanning() {
// Mutation per assegnare guardia con orari (anche multi-giorno)
const assignGuardMutation = useMutation({
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number; vehicleId?: string }) => {
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number }) => {
return apiRequest("POST", "/api/general-planning/assign-guard", data);
},
onSuccess: async () => {
// Invalida cache planning generale, guardie e veicoli
// Invalida cache planning generale
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
await queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
await queryClient.invalidateQueries({ queryKey: ["/api/vehicles/available"] });
// Refetch immediatamente guardie disponibili per aggiornare lista
await refetchGuards();
@ -203,9 +188,8 @@ export default function GeneralPlanning() {
description: "La guardia è stata assegnata con successo",
});
// Reset form (NON chiudere dialog per vedere lista aggiornata)
// Reset solo guardia selezionata (NON chiudere dialog per vedere lista aggiornata)
setSelectedGuardId("");
setSelectedVehicleId("");
},
onError: (error: any) => {
// Parse error message from API response
@ -244,7 +228,6 @@ export default function GeneralPlanning() {
startTime,
durationHours,
consecutiveDays,
...(selectedVehicleId && { vehicleId: selectedVehicleId }),
});
};
@ -543,9 +526,8 @@ export default function GeneralPlanning() {
setStartTime("06:00");
setDurationHours(8);
setConsecutiveDays(1);
setSelectedVehicleId("");
}}>
<DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
@ -818,38 +800,6 @@ export default function GeneralPlanning() {
);
})()}
{/* Select veicolo (opzionale) */}
<div className="space-y-2">
<Label htmlFor="vehicle-select">Veicolo (opzionale)</Label>
{isLoadingVehicles ? (
<Skeleton className="h-10 w-full" />
) : (
<Select
value={selectedVehicleId}
onValueChange={setSelectedVehicleId}
disabled={assignGuardMutation.isPending}
>
<SelectTrigger id="vehicle-select" data-testid="select-vehicle">
<SelectValue placeholder="Nessun veicolo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Nessun veicolo</SelectItem>
{availableVehicles && availableVehicles.length > 0 ? (
availableVehicles.map((vehicle) => (
<SelectItem key={vehicle.id} value={vehicle.id}>
{vehicle.licensePlate} - {vehicle.brand} {vehicle.model}
</SelectItem>
))
) : (
<SelectItem value="no-vehicles" disabled>
Nessun veicolo disponibile
</SelectItem>
)}
</SelectContent>
</Select>
)}
</div>
{/* Bottone assegna */}
<Button
onClick={handleAssignGuard}

View File

@ -1,399 +1,227 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ShiftWithDetails, Guard } from "@shared/schema";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { BarChart3, Users, Clock, Calendar, TrendingUp } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Download, Users, Building2, Clock, TrendingUp } from "lucide-react";
import { format } from "date-fns";
import { differenceInHours, format, startOfMonth, endOfMonth } from "date-fns";
import { it } from "date-fns/locale";
type Location = "roccapiemonte" | "milano" | "roma";
interface GuardReport {
guardId: string;
guardName: string;
badgeNumber: string;
ordinaryHours: number;
overtimeHours: number;
totalHours: number;
mealVouchers: number;
workingDays: number;
}
interface SiteReport {
siteId: string;
siteName: string;
serviceTypes: {
name: string;
hours: number;
shifts: number;
}[];
totalHours: number;
totalShifts: number;
}
export default function Reports() {
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
const [selectedMonth, setSelectedMonth] = useState<string>(format(new Date(), "yyyy-MM"));
// Query per report guardie
const { data: guardReport, isLoading: isLoadingGuards } = useQuery<{
month: string;
location: string;
guards: GuardReport[];
summary: {
totalGuards: number;
totalOrdinaryHours: number;
totalOvertimeHours: number;
totalHours: number;
totalMealVouchers: number;
};
}>({
queryKey: ["/api/reports/monthly-guard-hours", selectedMonth, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/reports/monthly-guard-hours?month=${selectedMonth}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch guard report");
return response.json();
},
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
queryKey: ["/api/shifts"],
});
// Query per report siti
const { data: siteReport, isLoading: isLoadingSites } = useQuery<{
month: string;
location: string;
sites: SiteReport[];
summary: {
totalSites: number;
totalHours: number;
totalShifts: number;
};
}>({
queryKey: ["/api/reports/billable-site-hours", selectedMonth, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/reports/billable-site-hours?month=${selectedMonth}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch site report");
return response.json();
},
const { data: guards, isLoading: guardsLoading } = useQuery<Guard[]>({
queryKey: ["/api/guards"],
});
// Genera mesi disponibili (ultimi 12 mesi)
const availableMonths = Array.from({ length: 12 }, (_, i) => {
const date = new Date();
date.setMonth(date.getMonth() - i);
return format(date, "yyyy-MM");
const isLoading = shiftsLoading || guardsLoading;
// Calculate statistics
const completedShifts = shifts?.filter(s => s.status === "completed") || [];
const totalHours = completedShifts.reduce((acc, shift) => {
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
}, 0);
// Hours per guard
const hoursPerGuard: Record<string, { name: string; hours: number }> = {};
completedShifts.forEach(shift => {
shift.assignments.forEach(assignment => {
const guardId = assignment.guardId;
const guardName = `${assignment.guard.user?.firstName || ""} ${assignment.guard.user?.lastName || ""}`.trim();
const hours = differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
if (!hoursPerGuard[guardId]) {
hoursPerGuard[guardId] = { name: guardName, hours: 0 };
}
hoursPerGuard[guardId].hours += hours;
});
});
// Export CSV guardie
const exportGuardsCSV = () => {
if (!guardReport?.guards) return;
const guardStats = Object.values(hoursPerGuard).sort((a, b) => b.hours - a.hours);
const headers = "Guardia,Badge,Ore Ordinarie,Ore Straordinarie,Ore Totali,Buoni Pasto,Giorni Lavorativi\n";
const rows = guardReport.guards.map(g =>
`"${g.guardName}",${g.badgeNumber},${g.ordinaryHours},${g.overtimeHours},${g.totalHours},${g.mealVouchers},${g.workingDays}`
).join("\n");
// Monthly statistics
const currentMonth = new Date();
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const csv = headers + rows;
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ore_guardie_${selectedMonth}_${selectedLocation}.csv`;
a.click();
};
const monthlyShifts = completedShifts.filter(s => {
const shiftDate = new Date(s.startTime);
return shiftDate >= monthStart && shiftDate <= monthEnd;
});
// Export CSV siti
const exportSitesCSV = () => {
if (!siteReport?.sites) return;
const headers = "Sito,Tipologia Servizio,Ore,Turni\n";
const rows = siteReport.sites.flatMap(s =>
s.serviceTypes.map(st =>
`"${s.siteName}","${st.name}",${st.hours},${st.shifts}`
)
).join("\n");
const csv = headers + rows;
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ore_siti_${selectedMonth}_${selectedLocation}.csv`;
a.click();
};
const monthlyHours = monthlyShifts.reduce((acc, shift) => {
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
}, 0);
return (
<div className="h-full overflow-auto p-6 space-y-6">
{/* Header */}
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Report e Export</h1>
<h1 className="text-3xl font-semibold mb-2">Report e Statistiche</h1>
<p className="text-muted-foreground">
Ore lavorate, buoni pasto e fatturazione
Ore lavorate e copertura servizi
</p>
</div>
{/* Filtri */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
<SelectTrigger data-testid="select-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-3">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
) : (
<>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Totali Lavorate
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-total-hours">
{totalHours}h
</p>
<p className="text-xs text-muted-foreground mt-1">
{completedShifts.length} turni completati
</p>
</CardContent>
</Card>
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">Mese</label>
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger data-testid="select-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => {
const [year, monthNum] = month.split("-");
const date = new Date(parseInt(year), parseInt(monthNum) - 1);
return (
<SelectItem key={month} value={month}>
{format(date, "MMMM yyyy", { locale: it })}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Ore Mese Corrente
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-monthly-hours">
{monthlyHours}h
</p>
<p className="text-xs text-muted-foreground mt-1">
{format(currentMonth, "MMMM yyyy", { locale: it })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Users className="h-4 w-4" />
Guardie Attive
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-active-guards">
{guardStats.length}
</p>
<p className="text-xs text-muted-foreground mt-1">
Con turni completati
</p>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
{/* Tabs Report */}
<Tabs defaultValue="guards" className="space-y-6">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="guards" data-testid="tab-guard-report">
<Users className="h-4 w-4 mr-2" />
Report Guardie
</TabsTrigger>
<TabsTrigger value="sites" data-testid="tab-site-report">
<Building2 className="h-4 w-4 mr-2" />
Report Siti
</TabsTrigger>
</TabsList>
{/* Tab Report Guardie */}
<TabsContent value="guards" className="space-y-4">
{isLoadingGuards ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : guardReport ? (
<>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Users className="h-4 w-4" />
Guardie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalGuards}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Ordinarie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalOrdinaryHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Ore Straordinarie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalOvertimeHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Buoni Pasto</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalMealVouchers}</p>
</CardContent>
</Card>
</div>
{/* Tabella guardie */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Dettaglio Ore per Guardia</CardTitle>
<CardDescription>Ordinarie, straordinarie e buoni pasto</CardDescription>
</div>
<Button onClick={exportGuardsCSV} data-testid="button-export-guards">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader>
<CardContent>
{guardReport.guards.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-3 font-medium">Guardia</th>
<th className="text-left p-3 font-medium">Badge</th>
<th className="text-right p-3 font-medium">Ore Ord.</th>
<th className="text-right p-3 font-medium">Ore Strao.</th>
<th className="text-right p-3 font-medium">Totale</th>
<th className="text-center p-3 font-medium">Buoni Pasto</th>
<th className="text-center p-3 font-medium">Giorni</th>
</tr>
</thead>
<tbody>
{guardReport.guards.map((guard) => (
<tr key={guard.guardId} className="border-b hover:bg-muted/50" data-testid={`guard-row-${guard.guardId}`}>
<td className="p-3">{guard.guardName}</td>
<td className="p-3"><Badge variant="outline">{guard.badgeNumber}</Badge></td>
<td className="p-3 text-right font-mono">{guard.ordinaryHours}h</td>
<td className="p-3 text-right font-mono text-orange-600 dark:text-orange-500">
{guard.overtimeHours > 0 ? `${guard.overtimeHours}h` : "-"}
</td>
<td className="p-3 text-right font-mono font-semibold">{guard.totalHours}h</td>
<td className="p-3 text-center">{guard.mealVouchers}</td>
<td className="p-3 text-center text-muted-foreground">{guard.workingDays}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-center text-muted-foreground py-8">Nessuna guardia con ore lavorate</p>
)}
</CardContent>
</Card>
</>
) : null}
</TabsContent>
{/* Tab Report Siti */}
<TabsContent value="sites" className="space-y-4">
{isLoadingSites ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : siteReport ? (
<>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
Siti Attivi
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalSites}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Fatturabili
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Turni Totali</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalShifts}</p>
</CardContent>
</Card>
</div>
{/* Tabella siti */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Ore Fatturabili per Sito</CardTitle>
<CardDescription>Raggruppate per tipologia servizio</CardDescription>
</div>
<Button onClick={exportSitesCSV} data-testid="button-export-sites">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader>
<CardContent>
{siteReport.sites.length > 0 ? (
<div className="space-y-4">
{siteReport.sites.map((site) => (
<div key={site.siteId} className="border rounded-md p-4" data-testid={`site-report-${site.siteId}`}>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-lg">{site.siteName}</h3>
<div className="flex items-center gap-2">
<Badge>{site.totalHours}h totali</Badge>
<Badge variant="outline">{site.totalShifts} turni</Badge>
</div>
</div>
<div className="space-y-2">
{site.serviceTypes.map((st, idx) => (
<div key={idx} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
<span>{st.name}</span>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">{st.shifts} turni</span>
<span className="font-mono font-semibold">{st.hours}h</span>
</div>
</div>
))}
</div>
{/* Hours per Guard */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Ore per Guardia
</CardTitle>
<CardDescription>
Ore totali lavorate per ogni guardia
</CardDescription>
</CardHeader>
<CardContent>
{guardStats.length > 0 ? (
<div className="space-y-3">
{guardStats.map((stat, index) => (
<div
key={index}
className="flex items-center gap-4"
data-testid={`guard-stat-${index}`}
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{stat.name}</p>
<div className="mt-1 h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary"
style={{
width: `${(stat.hours / (guardStats[0]?.hours || 1)) * 100}%`,
}}
/>
</div>
))}
</div>
<div className="text-right">
<p className="text-lg font-semibold font-mono">{stat.hours}h</p>
</div>
</div>
) : (
<p className="text-center text-muted-foreground py-8">Nessun sito con ore fatturabili</p>
)}
</CardContent>
</Card>
</>
) : null}
</TabsContent>
</Tabs>
))}
</div>
) : (
<div className="text-center py-8">
<BarChart3 className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
Nessun dato disponibile
</p>
</div>
)}
</CardContent>
</Card>
{/* Recent Shifts Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Turni Recenti
</CardTitle>
<CardDescription>
Ultimi turni completati
</CardDescription>
</CardHeader>
<CardContent>
{completedShifts.length > 0 ? (
<div className="space-y-3">
{completedShifts.slice(0, 5).map((shift) => (
<div
key={shift.id}
className="flex items-center justify-between p-3 rounded-md border"
data-testid={`recent-shift-${shift.id}`}
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{shift.site.name}</p>
<p className="text-sm text-muted-foreground">
{format(new Date(shift.startTime), "dd MMM yyyy", { locale: it })}
</p>
</div>
<div className="text-right">
<p className="font-mono text-sm">
{differenceInHours(new Date(shift.endTime), new Date(shift.startTime))}h
</p>
<p className="text-xs text-muted-foreground">
{shift.assignments.length} guardie
</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<Calendar className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
Nessun turno completato
</p>
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
);
}

View File

@ -1,288 +0,0 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { format, addWeeks, addDays, startOfWeek } from "date-fns";
import { it } from "date-fns/locale";
import { ChevronLeft, ChevronRight, Users, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type Location = "roccapiemonte" | "milano" | "roma";
interface ShiftDetail {
shiftId: string;
date: string;
from: string;
to: string;
siteName: string;
siteId: string;
vehicle?: {
licensePlate: string;
brand: string;
model: string;
};
hours: number;
}
interface GuardSchedule {
guardId: string;
guardName: string;
badgeNumber: string;
shifts: ShiftDetail[];
totalHours: number;
}
interface SiteSchedule {
siteId: string;
siteName: string;
location: string;
shifts: {
shiftId: string;
date: string;
from: string;
to: string;
guards: {
guardName: string;
badgeNumber: string;
hours: number;
}[];
vehicle?: {
licensePlate: string;
brand: string;
model: string;
};
totalGuards: number;
totalHours: number;
}[];
totalShifts: number;
totalHours: number;
}
export default function ServicePlanning() {
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
const [weekStart, setWeekStart] = useState<Date>(startOfWeek(new Date(), { weekStartsOn: 1 }));
const [viewMode, setViewMode] = useState<"guard" | "site">("guard");
const weekStartStr = format(weekStart, "yyyy-MM-dd");
const weekEndStr = format(addDays(weekStart, 6), "yyyy-MM-dd");
// Query per vista Guardie
const { data: guardSchedules, isLoading: isLoadingGuards } = useQuery<GuardSchedule[]>({
queryKey: ["/api/service-planning/by-guard", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/by-guard?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch guard schedules");
return response.json();
},
enabled: viewMode === "guard",
});
// Query per vista Siti
const { data: siteSchedules, isLoading: isLoadingSites } = useQuery<SiteSchedule[]>({
queryKey: ["/api/service-planning/by-site", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/by-site?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch site schedules");
return response.json();
},
enabled: viewMode === "site",
});
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1));
return (
<div className="h-full overflow-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Planning di Servizio</h1>
<p className="text-muted-foreground">
Visualizza orari e dotazioni per guardia o sito
</p>
</div>
</div>
{/* Controlli */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-4">
{/* Selezione sede */}
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
<SelectTrigger data-testid="select-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
{/* Navigazione settimana */}
<div className="flex-1 min-w-[300px]">
<label className="text-sm font-medium mb-2 block">Settimana</label>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={goToPreviousWeek} data-testid="button-prev-week">
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex-1 text-center font-medium">
{format(weekStart, "d MMM", { locale: it })} - {format(addDays(weekStart, 6), "d MMM yyyy", { locale: it })}
</div>
<Button variant="outline" size="icon" onClick={goToNextWeek} data-testid="button-next-week">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Tabs per vista */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard" | "site")}>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="guard" data-testid="tab-guard-view">
<Users className="h-4 w-4 mr-2" />
Vista Agente
</TabsTrigger>
<TabsTrigger value="site" data-testid="tab-site-view">
<Building2 className="h-4 w-4 mr-2" />
Vista Sito
</TabsTrigger>
</TabsList>
{/* Vista Agente */}
<TabsContent value="guard" className="space-y-4 mt-6">
{isLoadingGuards ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : guardSchedules && guardSchedules.length > 0 ? (
<div className="grid gap-4">
{guardSchedules.map((guard) => (
<Card key={guard.guardId} data-testid={`card-guard-${guard.guardId}`}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
{guard.guardName} <Badge variant="outline">{guard.badgeNumber}</Badge>
</CardTitle>
<Badge>{guard.totalHours}h totali</Badge>
</div>
</CardHeader>
<CardContent>
{guard.shifts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nessun turno assegnato</p>
) : (
<div className="space-y-3">
{guard.shifts.map((shift) => (
<div
key={shift.shiftId}
className="flex items-start justify-between p-3 rounded-md bg-muted/50"
data-testid={`shift-${shift.shiftId}`}
>
<div className="space-y-1">
<div className="font-medium">{shift.siteName}</div>
<div className="text-sm text-muted-foreground">
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} {shift.from} - {shift.to} ({shift.hours}h)
</div>
{shift.vehicle && (
<div className="text-xs text-muted-foreground">
🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model})
</div>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Nessuna guardia con turni assegnati</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Vista Sito */}
<TabsContent value="site" className="space-y-4 mt-6">
{isLoadingSites ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : siteSchedules && siteSchedules.length > 0 ? (
<div className="grid gap-4">
{siteSchedules.map((site) => (
<Card key={site.siteId} data-testid={`card-site-${site.siteId}`}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{site.siteName}</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="outline">{site.totalShifts} turni</Badge>
<Badge>{site.totalHours}h totali</Badge>
</div>
</div>
</CardHeader>
<CardContent>
{site.shifts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nessun turno programmato</p>
) : (
<div className="space-y-3">
{site.shifts.map((shift) => (
<div
key={shift.shiftId}
className="p-3 rounded-md bg-muted/50 space-y-2"
data-testid={`shift-${shift.shiftId}`}
>
<div className="flex items-center justify-between">
<div className="font-medium">
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} {shift.from} - {shift.to}
</div>
<Badge variant="secondary">{shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"}</Badge>
</div>
<div className="space-y-1">
{shift.guards.map((guard, idx) => (
<div key={idx} className="text-sm text-muted-foreground">
👤 {guard.guardName} ({guard.badgeNumber}) - {guard.hours}h
</div>
))}
</div>
{shift.vehicle && (
<div className="text-xs text-muted-foreground">
🚗 {shift.vehicle.licensePlate} ({shift.vehicle.brand} {shift.vehicle.model})
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Nessun sito con turni programmati</p>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -330,34 +330,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Get vehicles available for a location
app.get("/api/vehicles/available", isAuthenticated, async (req, res) => {
try {
const { location } = req.query;
if (!location) {
return res.status(400).json({ message: "Missing required parameter: location" });
}
// Get all vehicles for this location with status 'available'
const availableVehicles = await db
.select()
.from(vehicles)
.where(
and(
eq(vehicles.location, location as any),
eq(vehicles.status, "available")
)
)
.orderBy(vehicles.licensePlate);
res.json(availableVehicles);
} catch (error) {
console.error("Error fetching available vehicles:", error);
res.status(500).json({ message: "Failed to fetch available vehicles" });
}
});
// ============= VEHICLE ROUTES =============
app.get("/api/vehicles", isAuthenticated, async (req, res) => {
try {
@ -1240,7 +1212,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Assign guard to site/date with specific time slot (supports multi-day assignments)
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
try {
const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId } = req.body;
const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1 } = req.body;
if (!siteId || !date || !guardId || !startTime || !durationHours) {
return res.status(400).json({
@ -1345,7 +1317,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
endTime: shiftEnd,
shiftType: site.shiftType || "fixed_post",
status: "planned",
vehicleId: vehicleId || null,
}).returning();
}
@ -1429,485 +1400,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ============= SERVICE PLANNING ROUTES =============
// Vista per Guardia - mostra orari e dotazioni per ogni guardia
app.get("/api/service-planning/by-guard", isAuthenticated, async (req, res) => {
try {
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
const normalizedWeekStart = rawWeekStart.split("/")[0];
const parsedWeekStart = parseISO(normalizedWeekStart);
if (!isValid(parsedWeekStart)) {
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
}
const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd");
const location = req.query.location as string || "roccapiemonte";
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
const weekStartTimestamp = new Date(weekStartDate);
weekStartTimestamp.setHours(0, 0, 0, 0);
const weekEndTimestamp = new Date(weekEndDate);
weekEndTimestamp.setHours(23, 59, 59, 999);
// Ottieni tutte le guardie della sede
const allGuards = await db
.select()
.from(guards)
.where(eq(guards.location, location as any))
.orderBy(guards.fullName);
// Ottieni tutti i turni della settimana per la sede (con JOIN su sites per filtrare location)
const weekShifts = await db
.select({
shift: shifts,
site: sites,
vehicle: vehicles,
})
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id))
.where(
and(
gte(shifts.startTime, weekStartTimestamp),
lte(shifts.startTime, weekEndTimestamp),
ne(shifts.status, "cancelled"),
eq(sites.location, location as any)
)
);
// Ottieni tutte le assegnazioni per i turni della settimana
const shiftIds = weekShifts.map((s: any) => s.shift.id);
const assignments = shiftIds.length > 0 ? await db
.select({
assignment: shiftAssignments,
guard: guards,
})
.from(shiftAssignments)
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
.where(
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
) : [];
// Costruisci dati per ogni guardia
const guardSchedules = allGuards.map((guard: any) => {
// Trova assegnazioni della guardia
const guardAssignments = assignments.filter((a: any) => a.guard.id === guard.id);
// Costruisci lista turni con dettagli
const shifts = guardAssignments.map((a: any) => {
const shiftData = weekShifts.find((s: any) => s.shift.id === a.assignment.shiftId);
if (!shiftData) return null;
const plannedStart = new Date(a.assignment.plannedStartTime);
const plannedEnd = new Date(a.assignment.plannedEndTime);
const minutes = differenceInMinutes(plannedEnd, plannedStart);
const hours = Math.round((minutes / 60) * 10) / 10; // Arrotonda a 1 decimale
return {
shiftId: shiftData.shift.id,
date: format(plannedStart, "yyyy-MM-dd"),
from: format(plannedStart, "HH:mm"),
to: format(plannedEnd, "HH:mm"),
siteName: shiftData.site.name,
siteId: shiftData.site.id,
vehicle: shiftData.vehicle ? {
licensePlate: shiftData.vehicle.licensePlate,
brand: shiftData.vehicle.brand,
model: shiftData.vehicle.model,
} : undefined,
hours,
};
}).filter(Boolean);
const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.hours, 0) * 10) / 10;
return {
guardId: guard.id,
guardName: guard.fullName,
badgeNumber: guard.badgeNumber,
shifts,
totalHours,
};
});
// Filtra solo guardie con turni assegnati
const guardsWithShifts = guardSchedules.filter((g: any) => g.shifts.length > 0);
res.json(guardsWithShifts);
} catch (error) {
console.error("Error fetching guard schedules:", error);
res.status(500).json({ message: "Failed to fetch guard schedules", error: String(error) });
}
});
// Vista per Sito - mostra agenti e dotazioni per ogni sito
app.get("/api/service-planning/by-site", isAuthenticated, async (req, res) => {
try {
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
const normalizedWeekStart = rawWeekStart.split("/")[0];
const parsedWeekStart = parseISO(normalizedWeekStart);
if (!isValid(parsedWeekStart)) {
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
}
const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd");
const location = req.query.location as string || "roccapiemonte";
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
const weekStartTimestamp = new Date(weekStartDate);
weekStartTimestamp.setHours(0, 0, 0, 0);
const weekEndTimestamp = new Date(weekEndDate);
weekEndTimestamp.setHours(23, 59, 59, 999);
// Ottieni tutti i siti attivi della sede
const activeSites = await db
.select()
.from(sites)
.where(
and(
eq(sites.isActive, true),
eq(sites.location, location as any)
)
)
.orderBy(sites.name);
// Ottieni tutti i turni della settimana per la sede
const weekShifts = await db
.select({
shift: shifts,
site: sites,
vehicle: vehicles,
})
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id))
.where(
and(
gte(shifts.startTime, weekStartTimestamp),
lte(shifts.startTime, weekEndTimestamp),
ne(shifts.status, "cancelled"),
eq(sites.location, location as any)
)
);
// Ottieni tutte le assegnazioni per i turni della settimana
const shiftIds = weekShifts.map((s: any) => s.shift.id);
const assignments = shiftIds.length > 0 ? await db
.select({
assignment: shiftAssignments,
guard: guards,
})
.from(shiftAssignments)
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
.where(
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
) : [];
// Costruisci dati per ogni sito
const siteSchedules = activeSites.map((site: any) => {
// Trova turni del sito
const siteShifts = weekShifts.filter((s: any) => s.site.id === site.id);
// Costruisci lista turni con guardie e veicoli
const shifts = siteShifts.map((shiftData: any) => {
const shiftAssignments = assignments.filter((a: any) => a.assignment.shiftId === shiftData.shift.id);
const guards = shiftAssignments.map((a: any) => {
const plannedStart = new Date(a.assignment.plannedStartTime);
const plannedEnd = new Date(a.assignment.plannedEndTime);
const minutes = differenceInMinutes(plannedEnd, plannedStart);
const hours = Math.round((minutes / 60) * 10) / 10; // Arrotonda a 1 decimale
return {
guardName: a.guard.fullName,
badgeNumber: a.guard.badgeNumber,
hours,
};
});
const shiftStart = new Date(shiftData.shift.startTime);
const shiftEnd = new Date(shiftData.shift.endTime);
const minutes = differenceInMinutes(shiftEnd, shiftStart);
const totalHours = Math.round((minutes / 60) * 10) / 10;
return {
shiftId: shiftData.shift.id,
date: format(shiftStart, "yyyy-MM-dd"),
from: format(shiftStart, "HH:mm"),
to: format(shiftEnd, "HH:mm"),
guards,
vehicle: shiftData.vehicle ? {
licensePlate: shiftData.vehicle.licensePlate,
brand: shiftData.vehicle.brand,
model: shiftData.vehicle.model,
} : undefined,
totalGuards: guards.length,
totalHours,
};
});
const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.totalHours, 0) * 10) / 10;
return {
siteId: site.id,
siteName: site.name,
location: site.location,
shifts,
totalShifts: shifts.length,
totalHours,
};
});
// Filtra solo siti con turni programmati
const sitesWithShifts = siteSchedules.filter((s: any) => s.shifts.length > 0);
res.json(sitesWithShifts);
} catch (error) {
console.error("Error fetching site schedules:", error);
res.status(500).json({ message: "Failed to fetch site schedules", error: String(error) });
}
});
// ============= REPORTS ROUTES =============
// Report mensile ore per guardia (ordinarie/straordinarie + buoni pasto)
app.get("/api/reports/monthly-guard-hours", isAuthenticated, async (req, res) => {
try {
const rawMonth = req.query.month as string || format(new Date(), "yyyy-MM");
const location = req.query.location as string || "roccapiemonte";
// Parse mese (formato: YYYY-MM)
const [year, month] = rawMonth.split("-").map(Number);
const monthStart = new Date(year, month - 1, 1);
monthStart.setHours(0, 0, 0, 0);
const monthEnd = new Date(year, month, 0); // ultimo giorno del mese
monthEnd.setHours(23, 59, 59, 999);
// Ottieni tutte le guardie della sede
const allGuards = await db
.select()
.from(guards)
.where(eq(guards.location, location as any))
.orderBy(guards.fullName);
// Ottieni tutti i turni del mese per la sede
const monthShifts = await db
.select({
shift: shifts,
site: sites,
})
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.where(
and(
gte(shifts.startTime, monthStart),
lte(shifts.startTime, monthEnd),
ne(shifts.status, "cancelled"),
eq(sites.location, location as any)
)
);
// Ottieni tutte le assegnazioni del mese
const shiftIds = monthShifts.map((s: any) => s.shift.id);
const assignments = shiftIds.length > 0 ? await db
.select({
assignment: shiftAssignments,
guard: guards,
})
.from(shiftAssignments)
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
.where(
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
) : [];
// Calcola statistiche per ogni guardia
const guardReports = allGuards.map((guard: any) => {
const guardAssignments = assignments.filter((a: any) => a.guard.id === guard.id);
// Raggruppa assegnazioni per settimana (lunedì = inizio settimana)
const weeklyHours: Record<string, number> = {};
const dailyHours: Record<string, number> = {}; // Per calcolare buoni pasto
guardAssignments.forEach((a: any) => {
const plannedStart = new Date(a.assignment.plannedStartTime);
const plannedEnd = new Date(a.assignment.plannedEndTime);
const minutes = differenceInMinutes(plannedEnd, plannedStart);
const hours = minutes / 60;
// Settimana (ISO week - lunedì come primo giorno)
const weekStart = startOfWeek(plannedStart, { weekStartsOn: 1 });
const weekKey = format(weekStart, "yyyy-MM-dd");
weeklyHours[weekKey] = (weeklyHours[weekKey] || 0) + hours;
// Giorno (per buoni pasto)
const dayKey = format(plannedStart, "yyyy-MM-dd");
dailyHours[dayKey] = (dailyHours[dayKey] || 0) + hours;
});
// Calcola ore ordinarie e straordinarie
let ordinaryHours = 0;
let overtimeHours = 0;
Object.values(weeklyHours).forEach((weekHours: number) => {
if (weekHours <= 40) {
ordinaryHours += weekHours;
} else {
ordinaryHours += 40;
overtimeHours += (weekHours - 40);
}
});
// Calcola buoni pasto (giorni con ore ≥ 6)
const mealVouchers = Object.values(dailyHours).filter(
(dayHours: number) => dayHours >= 6
).length;
const totalHours = ordinaryHours + overtimeHours;
return {
guardId: guard.id,
guardName: guard.fullName,
badgeNumber: guard.badgeNumber,
ordinaryHours: Math.round(ordinaryHours * 10) / 10,
overtimeHours: Math.round(overtimeHours * 10) / 10,
totalHours: Math.round(totalHours * 10) / 10,
mealVouchers,
workingDays: Object.keys(dailyHours).length,
};
});
// Filtra solo guardie con ore lavorate
const guardsWithHours = guardReports.filter((g: any) => g.totalHours > 0);
res.json({
month: rawMonth,
location,
guards: guardsWithHours,
summary: {
totalGuards: guardsWithHours.length,
totalOrdinaryHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.ordinaryHours, 0) * 10) / 10,
totalOvertimeHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.overtimeHours, 0) * 10) / 10,
totalHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.totalHours, 0) * 10) / 10,
totalMealVouchers: guardsWithHours.reduce((sum: number, g: any) => sum + g.mealVouchers, 0),
},
});
} catch (error) {
console.error("Error fetching monthly guard hours:", error);
res.status(500).json({ message: "Failed to fetch monthly guard hours", error: String(error) });
}
});
// Report ore fatturabili per sito per tipologia servizio
app.get("/api/reports/billable-site-hours", isAuthenticated, async (req, res) => {
try {
const rawMonth = req.query.month as string || format(new Date(), "yyyy-MM");
const location = req.query.location as string || "roccapiemonte";
// Parse mese (formato: YYYY-MM)
const [year, month] = rawMonth.split("-").map(Number);
const monthStart = new Date(year, month - 1, 1);
monthStart.setHours(0, 0, 0, 0);
const monthEnd = new Date(year, month, 0);
monthEnd.setHours(23, 59, 59, 999);
// Ottieni tutti i turni del mese per la sede con dettagli sito e servizio
const monthShifts = await db
.select({
shift: shifts,
site: sites,
serviceType: serviceTypes,
})
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id))
.where(
and(
gte(shifts.startTime, monthStart),
lte(shifts.startTime, monthEnd),
ne(shifts.status, "cancelled"),
eq(sites.location, location as any)
)
);
// Raggruppa per sito
const siteHoursMap: Record<string, any> = {};
monthShifts.forEach((shiftData: any) => {
const siteId = shiftData.site.id;
const siteName = shiftData.site.name;
const serviceTypeName = shiftData.serviceType?.name || "Non specificato";
const shiftStart = new Date(shiftData.shift.startTime);
const shiftEnd = new Date(shiftData.shift.endTime);
const minutes = differenceInMinutes(shiftEnd, shiftStart);
const hours = minutes / 60;
if (!siteHoursMap[siteId]) {
siteHoursMap[siteId] = {
siteId,
siteName,
serviceTypes: {},
totalHours: 0,
totalShifts: 0,
};
}
if (!siteHoursMap[siteId].serviceTypes[serviceTypeName]) {
siteHoursMap[siteId].serviceTypes[serviceTypeName] = {
hours: 0,
shifts: 0,
};
}
siteHoursMap[siteId].serviceTypes[serviceTypeName].hours += hours;
siteHoursMap[siteId].serviceTypes[serviceTypeName].shifts += 1;
siteHoursMap[siteId].totalHours += hours;
siteHoursMap[siteId].totalShifts += 1;
});
// Converti mappa in array e arrotonda ore
const siteReports = Object.values(siteHoursMap).map((site: any) => {
const serviceTypesArray = Object.entries(site.serviceTypes).map(([name, data]: [string, any]) => ({
name,
hours: Math.round(data.hours * 10) / 10,
shifts: data.shifts,
}));
return {
siteId: site.siteId,
siteName: site.siteName,
serviceTypes: serviceTypesArray,
totalHours: Math.round(site.totalHours * 10) / 10,
totalShifts: site.totalShifts,
};
});
// Ordina per ore totali (decrescente)
siteReports.sort((a: any, b: any) => b.totalHours - a.totalHours);
res.json({
month: rawMonth,
location,
sites: siteReports,
summary: {
totalSites: siteReports.length,
totalHours: Math.round(siteReports.reduce((sum: number, s: any) => sum + s.totalHours, 0) * 10) / 10,
totalShifts: siteReports.reduce((sum: number, s: any) => sum + s.totalShifts, 0),
},
});
} catch (error) {
console.error("Error fetching billable site hours:", error);
res.status(500).json({ message: "Failed to fetch billable site hours", error: String(error) });
}
});
// ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => {
try {
@ -2661,275 +2153,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ============= DEV UTILITIES (Reset & Seed Data) =============
// DELETE all sites and guards (for testing)
app.delete("/api/dev/reset-data", isAuthenticated, async (req, res) => {
try {
// Delete all shift assignments first (foreign key constraints)
await db.delete(shiftAssignments);
// Delete all shifts
await db.delete(shifts);
// Delete all sites
await db.delete(sites);
// Delete all certifications
await db.delete(certifications);
// Delete all guards
await db.delete(guards);
// Delete all vehicles
await db.delete(vehicles);
res.json({
success: true,
message: "Tutti i dati (siti, guardie, turni, veicoli) sono stati eliminati"
});
} catch (error) {
console.error("Error resetting data:", error);
res.status(500).json({ message: "Errore durante reset dati" });
}
});
// Create sample data (3 sites Milano + 3 Roccapiemonte, 10 guards each)
app.post("/api/dev/seed-data", isAuthenticated, async (req, res) => {
try {
// Create service types first
const [serviceTypePresidioFisso] = await db.insert(serviceTypes).values({
name: "Presidio Fisso",
description: "Servizio di presidio fisso con guardia armata",
shiftType: "fixed_post",
fixedPostHours: 24,
}).returning();
const [serviceTypePattuglia] = await db.insert(serviceTypes).values({
name: "Pattuglia Mobile",
description: "Servizio di pattuglia mobile con passaggi programmati",
shiftType: "patrol",
patrolPassages: 4,
}).returning();
// Create 3 sites for Milano
const [siteMilano1] = await db.insert(sites).values({
name: "Banca Centrale Milano",
address: "Via Dante 45, Milano",
city: "Milano",
location: "milano",
serviceTypeId: serviceTypePresidioFisso.id,
shiftType: "fixed_post",
requiresArmed: true,
requiresDriverLicense: false,
minGuardsRequired: 1,
serviceStartTime: "00:00",
serviceEndTime: "24:00",
contractReference: "CTR-MI-001-2025",
contractStartDate: "2025-01-01",
contractEndDate: "2025-12-31",
}).returning();
const [siteMilano2] = await db.insert(sites).values({
name: "Museo Arte Moderna Milano",
address: "Corso Magenta 12, Milano",
city: "Milano",
location: "milano",
serviceTypeId: serviceTypePattuglia.id,
shiftType: "patrol",
requiresArmed: false,
requiresDriverLicense: true,
minGuardsRequired: 1,
serviceStartTime: "08:00",
serviceEndTime: "20:00",
contractReference: "CTR-MI-002-2025",
contractStartDate: "2025-01-01",
contractEndDate: "2025-06-30",
}).returning();
const [siteMilano3] = await db.insert(sites).values({
name: "Centro Commerciale Porta Nuova",
address: "Piazza Gae Aulenti 1, Milano",
city: "Milano",
location: "milano",
serviceTypeId: serviceTypePresidioFisso.id,
shiftType: "fixed_post",
requiresArmed: true,
requiresDriverLicense: false,
minGuardsRequired: 2,
serviceStartTime: "06:00",
serviceEndTime: "22:00",
contractReference: "CTR-MI-003-2025",
contractStartDate: "2025-01-01",
contractEndDate: "2025-12-31",
}).returning();
// Create 3 sites for Roccapiemonte
const [siteRocca1] = await db.insert(sites).values({
name: "Deposito Logistica Roccapiemonte",
address: "Via Industriale 23, Roccapiemonte",
city: "Roccapiemonte",
location: "roccapiemonte",
serviceTypeId: serviceTypePresidioFisso.id,
shiftType: "fixed_post",
requiresArmed: true,
requiresDriverLicense: false,
minGuardsRequired: 1,
serviceStartTime: "00:00",
serviceEndTime: "24:00",
contractReference: "CTR-RC-001-2025",
contractStartDate: "2025-01-01",
contractEndDate: "2025-12-31",
}).returning();
const [siteRocca2] = await db.insert(sites).values({
name: "Cantiere Edile Salerno Nord",
address: "SS 18 km 45, Roccapiemonte",
city: "Roccapiemonte",
location: "roccapiemonte",
serviceTypeId: serviceTypePattuglia.id,
shiftType: "patrol",
requiresArmed: false,
requiresDriverLicense: true,
minGuardsRequired: 1,
serviceStartTime: "18:00",
serviceEndTime: "06:00",
contractReference: "CTR-RC-002-2025",
contractStartDate: "2025-01-15",
contractEndDate: "2025-07-15",
}).returning();
const [siteRocca3] = await db.insert(sites).values({
name: "Stabilimento Farmaceutico",
address: "Via delle Industrie 89, Roccapiemonte",
city: "Roccapiemonte",
location: "roccapiemonte",
serviceTypeId: serviceTypePresidioFisso.id,
shiftType: "fixed_post",
requiresArmed: true,
requiresDriverLicense: false,
minGuardsRequired: 2,
serviceStartTime: "00:00",
serviceEndTime: "24:00",
contractReference: "CTR-RC-003-2025",
contractStartDate: "2025-01-01",
contractEndDate: "2025-12-31",
}).returning();
// Create 10 guards for Milano
const milanNames = [
{ firstName: "Marco", lastName: "Rossi", badgeNumber: "MI-001" },
{ firstName: "Giulia", lastName: "Bianchi", badgeNumber: "MI-002" },
{ firstName: "Luca", lastName: "Ferrari", badgeNumber: "MI-003" },
{ firstName: "Sara", lastName: "Romano", badgeNumber: "MI-004" },
{ firstName: "Andrea", lastName: "Colombo", badgeNumber: "MI-005" },
{ firstName: "Elena", lastName: "Ricci", badgeNumber: "MI-006" },
{ firstName: "Francesco", lastName: "Marino", badgeNumber: "MI-007" },
{ firstName: "Chiara", lastName: "Greco", badgeNumber: "MI-008" },
{ firstName: "Matteo", lastName: "Bruno", badgeNumber: "MI-009" },
{ firstName: "Alessia", lastName: "Gallo", badgeNumber: "MI-010" },
];
for (let i = 0; i < milanNames.length; i++) {
await db.insert(guards).values({
...milanNames[i],
location: "milano",
isArmed: i % 2 === 0, // Alternare armati/non armati
hasDriverLicense: i % 3 === 0, // 1 su 3 con patente
hasFireSafety: true,
hasFirstAid: i % 2 === 1,
phone: `+39 333 ${String(i).padStart(3, '0')}${String(i).padStart(4, '0')}`,
email: `${milanNames[i].firstName.toLowerCase()}.${milanNames[i].lastName.toLowerCase()}@vigilanza.it`,
});
}
// Create 10 guards for Roccapiemonte
const roccaNames = [
{ firstName: "Antonio", lastName: "Esposito", badgeNumber: "RC-001" },
{ firstName: "Maria", lastName: "De Luca", badgeNumber: "RC-002" },
{ firstName: "Giuseppe", lastName: "Russo", badgeNumber: "RC-003" },
{ firstName: "Anna", lastName: "Costa", badgeNumber: "RC-004" },
{ firstName: "Vincenzo", lastName: "Ferrara", badgeNumber: "RC-005" },
{ firstName: "Rosa", lastName: "Gatti", badgeNumber: "RC-006" },
{ firstName: "Salvatore", lastName: "Leone", badgeNumber: "RC-007" },
{ firstName: "Lucia", lastName: "Longo", badgeNumber: "RC-008" },
{ firstName: "Michele", lastName: "Martino", badgeNumber: "RC-009" },
{ firstName: "Carmela", lastName: "Moretti", badgeNumber: "RC-010" },
];
for (let i = 0; i < roccaNames.length; i++) {
await db.insert(guards).values({
...roccaNames[i],
location: "roccapiemonte",
isArmed: i % 2 === 0,
hasDriverLicense: i % 3 === 0,
hasFireSafety: true,
hasFirstAid: i % 2 === 1,
phone: `+39 333 ${String(i + 10).padStart(3, '0')}${String(i + 10).padStart(4, '0')}`,
email: `${roccaNames[i].firstName.toLowerCase()}.${roccaNames[i].lastName.toLowerCase()}@vigilanza.it`,
});
}
// Create 5 vehicles for Milano
const vehiclesMilano = [
{ licensePlate: "MI123AB", brand: "Fiat", model: "Ducato", vehicleType: "van" as const },
{ licensePlate: "MI456CD", brand: "Volkswagen", model: "Transporter", vehicleType: "van" as const },
{ licensePlate: "MI789EF", brand: "Ford", model: "Transit", vehicleType: "van" as const },
{ licensePlate: "MI012GH", brand: "Renault", model: "Kangoo", vehicleType: "car" as const },
{ licensePlate: "MI345IJ", brand: "Opel", model: "Vivaro", vehicleType: "van" as const },
];
for (const vehicle of vehiclesMilano) {
await db.insert(vehicles).values({
...vehicle,
location: "milano",
year: 2022,
status: "available",
});
}
// Create 5 vehicles for Roccapiemonte
const vehiclesRocca = [
{ licensePlate: "SA123AB", brand: "Fiat", model: "Ducato", vehicleType: "van" as const },
{ licensePlate: "SA456CD", brand: "Volkswagen", model: "Caddy", vehicleType: "car" as const },
{ licensePlate: "SA789EF", brand: "Ford", model: "Transit", vehicleType: "van" as const },
{ licensePlate: "SA012GH", brand: "Renault", model: "Master", vehicleType: "van" as const },
{ licensePlate: "SA345IJ", brand: "Peugeot", model: "Partner", vehicleType: "car" as const },
];
for (const vehicle of vehiclesRocca) {
await db.insert(vehicles).values({
...vehicle,
location: "roccapiemonte",
year: 2023,
status: "available",
});
}
res.json({
success: true,
message: "Dati di esempio creati con successo",
summary: {
sites: {
milano: 3,
roccapiemonte: 3,
},
guards: {
milano: 10,
roccapiemonte: 10,
},
vehicles: {
milano: 5,
roccapiemonte: 5,
},
},
});
} catch (error) {
console.error("Error seeding data:", error);
res.status(500).json({ message: "Errore durante creazione dati di esempio" });
}
});
const httpServer = createServer(app);
return httpServer;
}

View File

@ -1,13 +1,7 @@
{
"version": "1.0.31",
"lastUpdate": "2025-10-22T08:19:27.977Z",
"version": "1.0.30",
"lastUpdate": "2025-10-22T07:13:11.868Z",
"changelog": [
{
"version": "1.0.31",
"date": "2025-10-22",
"type": "patch",
"description": "Deployment automatico v1.0.31"
},
{
"version": "1.0.30",
"date": "2025-10-22",