Compare commits

...

7 Commits

Author SHA1 Message Date
Marco Lanzara
03049f4090 🚀 Release v1.0.31
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.31_20251022_081911.sql.gz
- Data: 2025-10-22 08:19:28
2025-10-22 08:19:28 +00:00
marco370
2fd5764415 Add user roles and permissions for enhanced security
Implement RBAC model with roles (Admin, Supervisor, Guard) and permissions for CRUD operations on shifts, users, and locations.

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/KiuJzNf
2025-10-22 08:15:13 +00:00
marco370
b05bd3a0b9 Add monthly guard and site reports for specific locations
Implement new API endpoints and UI components to generate and display monthly reports for guard hours (including overtime and meal vouchers) and billable site hours, with filtering by month and location.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/KiuJzNf
2025-10-22 08:13:59 +00:00
marco370
efcaca356a Add a new section for viewing and managing service planning details
Implement the "Service Planning" page with backend API routes and frontend components for displaying guard and site schedules.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/KiuJzNf
2025-10-22 08:08:00 +00:00
marco370
a945abdb5d Add vehicle assignment to guard planning and improve dialog size
Integrate vehicle assignment into the general planning module by adding a `vehicleId` field to the `assign-guard` mutation and fetching available vehicles. Increase the dialog size for better usability and update related cache invalidations.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/KiuJzNf
2025-10-22 07:59:10 +00:00
marco370
d7c6136fcb Add ability to assign vehicles to guards for specific shifts
Adds a new API endpoint to retrieve available vehicles by location and modifies the general planning route to include vehicle assignments for guards.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/KiuJzNf
2025-10-22 07:50:19 +00:00
marco370
52f3aee8e4 Add development tools to reset and seed the application data
Introduces API endpoints `/api/dev/reset-data` (DELETE) and `/api/dev/seed-data` (POST) for clearing and populating the database with sample sites and guards, intended for development and testing purposes.

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/Jxn533V
2025-10-22 07:48:24 +00:00
9 changed files with 1507 additions and 206 deletions

View File

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

View File

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

View File

@ -21,7 +21,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { queryClient, apiRequest } from "@/lib/queryClient"; import { queryClient, apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { GuardAvailability } from "@shared/schema"; import type { GuardAvailability, Vehicle as VehicleDb } from "@shared/schema";
interface GuardWithHours { interface GuardWithHours {
assignmentId: string; assignmentId: string;
@ -98,6 +98,7 @@ export default function GeneralPlanning() {
// Form state per assegnazione guardia // Form state per assegnazione guardia
const [selectedGuardId, setSelectedGuardId] = useState<string>(""); const [selectedGuardId, setSelectedGuardId] = useState<string>("");
const [selectedVehicleId, setSelectedVehicleId] = useState<string>("");
const [startTime, setStartTime] = useState<string>("06:00"); const [startTime, setStartTime] = useState<string>("06:00");
const [durationHours, setDurationHours] = useState<number>(8); const [durationHours, setDurationHours] = useState<number>(8);
const [consecutiveDays, setConsecutiveDays] = useState<number>(1); const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
@ -147,6 +148,19 @@ export default function GeneralPlanning() {
staleTime: 0, // Dati sempre considerati stale, refetch ad ogni apertura dialog 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 // Mutation per eliminare assegnazione guardia
const deleteAssignmentMutation = useMutation({ const deleteAssignmentMutation = useMutation({
mutationFn: async (assignmentId: string) => { mutationFn: async (assignmentId: string) => {
@ -172,13 +186,14 @@ export default function GeneralPlanning() {
// Mutation per assegnare guardia con orari (anche multi-giorno) // Mutation per assegnare guardia con orari (anche multi-giorno)
const assignGuardMutation = useMutation({ const assignGuardMutation = useMutation({
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number }) => { mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number; vehicleId?: string }) => {
return apiRequest("POST", "/api/general-planning/assign-guard", data); return apiRequest("POST", "/api/general-planning/assign-guard", data);
}, },
onSuccess: async () => { onSuccess: async () => {
// Invalida cache planning generale // Invalida cache planning generale, guardie e veicoli
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] }); await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
await queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] }); await queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
await queryClient.invalidateQueries({ queryKey: ["/api/vehicles/available"] });
// Refetch immediatamente guardie disponibili per aggiornare lista // Refetch immediatamente guardie disponibili per aggiornare lista
await refetchGuards(); await refetchGuards();
@ -188,8 +203,9 @@ export default function GeneralPlanning() {
description: "La guardia è stata assegnata con successo", description: "La guardia è stata assegnata con successo",
}); });
// Reset solo guardia selezionata (NON chiudere dialog per vedere lista aggiornata) // Reset form (NON chiudere dialog per vedere lista aggiornata)
setSelectedGuardId(""); setSelectedGuardId("");
setSelectedVehicleId("");
}, },
onError: (error: any) => { onError: (error: any) => {
// Parse error message from API response // Parse error message from API response
@ -228,6 +244,7 @@ export default function GeneralPlanning() {
startTime, startTime,
durationHours, durationHours,
consecutiveDays, consecutiveDays,
...(selectedVehicleId && { vehicleId: selectedVehicleId }),
}); });
}; };
@ -526,8 +543,9 @@ export default function GeneralPlanning() {
setStartTime("06:00"); setStartTime("06:00");
setDurationHours(8); setDurationHours(8);
setConsecutiveDays(1); setConsecutiveDays(1);
setSelectedVehicleId("");
}}> }}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col"> <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" /> <Calendar className="h-5 w-5" />
@ -800,6 +818,38 @@ 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 */} {/* Bottone assegna */}
<Button <Button
onClick={handleAssignGuard} onClick={handleAssignGuard}

View File

@ -1,227 +1,399 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ShiftWithDetails, Guard } from "@shared/schema";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { BarChart3, Users, Clock, Calendar, TrendingUp } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { differenceInHours, format, startOfMonth, endOfMonth } from "date-fns"; 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 { it } from "date-fns/locale"; 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() { export default function Reports() {
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({ const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
queryKey: ["/api/shifts"], 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: guards, isLoading: guardsLoading } = useQuery<Guard[]>({ // Query per report siti
queryKey: ["/api/guards"], 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 isLoading = shiftsLoading || guardsLoading; // Genera mesi disponibili (ultimi 12 mesi)
const availableMonths = Array.from({ length: 12 }, (_, i) => {
// Calculate statistics const date = new Date();
const completedShifts = shifts?.filter(s => s.status === "completed") || []; date.setMonth(date.getMonth() - i);
const totalHours = completedShifts.reduce((acc, shift) => { return format(date, "yyyy-MM");
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;
});
}); });
const guardStats = Object.values(hoursPerGuard).sort((a, b) => b.hours - a.hours); // Export CSV guardie
const exportGuardsCSV = () => {
if (!guardReport?.guards) return;
// Monthly statistics const headers = "Guardia,Badge,Ore Ordinarie,Ore Straordinarie,Ore Totali,Buoni Pasto,Giorni Lavorativi\n";
const currentMonth = new Date(); const rows = guardReport.guards.map(g =>
const monthStart = startOfMonth(currentMonth); `"${g.guardName}",${g.badgeNumber},${g.ordinaryHours},${g.overtimeHours},${g.totalHours},${g.mealVouchers},${g.workingDays}`
const monthEnd = endOfMonth(currentMonth); ).join("\n");
const monthlyShifts = completedShifts.filter(s => { const csv = headers + rows;
const shiftDate = new Date(s.startTime); const blob = new Blob([csv], { type: "text/csv" });
return shiftDate >= monthStart && shiftDate <= monthEnd; const url = URL.createObjectURL(blob);
}); const a = document.createElement("a");
a.href = url;
a.download = `ore_guardie_${selectedMonth}_${selectedLocation}.csv`;
a.click();
};
const monthlyHours = monthlyShifts.reduce((acc, shift) => { // Export CSV siti
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); const exportSitesCSV = () => {
}, 0); 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();
};
return ( return (
<div className="space-y-6"> <div className="h-full overflow-auto p-6 space-y-6">
{/* Header */}
<div> <div>
<h1 className="text-3xl font-semibold mb-2">Report e Statistiche</h1> <h1 className="text-3xl font-bold">Report e Export</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Ore lavorate e copertura servizi Ore lavorate, buoni pasto e fatturazione
</p> </p>
</div> </div>
{isLoading ? ( {/* Filtri */}
<div className="grid gap-4 md:grid-cols-3"> <Card>
<Skeleton className="h-32" /> <CardContent className="pt-6">
<Skeleton className="h-32" /> <div className="flex flex-wrap items-center gap-4">
<Skeleton className="h-32" /> <div className="flex-1 min-w-[200px]">
</div> <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">
{/* Summary Cards */} <SelectValue />
<div className="grid gap-4 md:grid-cols-3"> </SelectTrigger>
<Card> <SelectContent>
<CardHeader className="pb-3"> <SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<CardDescription className="flex items-center gap-2"> <SelectItem value="milano">Milano</SelectItem>
<Clock className="h-4 w-4" /> <SelectItem value="roma">Roma</SelectItem>
Ore Totali Lavorate </SelectContent>
</CardDescription> </Select>
</CardHeader> </div>
<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>
<Card> <div className="flex-1 min-w-[200px]">
<CardHeader className="pb-3"> <label className="text-sm font-medium mb-2 block">Mese</label>
<CardDescription className="flex items-center gap-2"> <Select value={selectedMonth} onValueChange={setSelectedMonth}>
<Calendar className="h-4 w-4" /> <SelectTrigger data-testid="select-month">
Ore Mese Corrente <SelectValue />
</CardDescription> </SelectTrigger>
</CardHeader> <SelectContent>
<CardContent> {availableMonths.map(month => {
<p className="text-3xl font-semibold" data-testid="text-monthly-hours"> const [year, monthNum] = month.split("-");
{monthlyHours}h const date = new Date(parseInt(year), parseInt(monthNum) - 1);
</p> return (
<p className="text-xs text-muted-foreground mt-1"> <SelectItem key={month} value={month}>
{format(currentMonth, "MMMM yyyy", { locale: it })} {format(date, "MMMM yyyy", { locale: it })}
</p> </SelectItem>
</CardContent> );
</Card> })}
</SelectContent>
<Card> </Select>
<CardHeader className="pb-3"> </div>
<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> </div>
</CardContent>
</Card>
{/* Hours per Guard */} {/* Tabs Report */}
<Card> <Tabs defaultValue="guards" className="space-y-6">
<CardHeader> <TabsList className="grid w-full max-w-md grid-cols-2">
<CardTitle className="flex items-center gap-2"> <TabsTrigger value="guards" data-testid="tab-guard-report">
<BarChart3 className="h-5 w-5" /> <Users className="h-4 w-4 mr-2" />
Ore per Guardia Report Guardie
</CardTitle> </TabsTrigger>
<CardDescription> <TabsTrigger value="sites" data-testid="tab-site-report">
Ore totali lavorate per ogni guardia <Building2 className="h-4 w-4 mr-2" />
</CardDescription> Report Siti
</CardHeader> </TabsTrigger>
<CardContent> </TabsList>
{guardStats.length > 0 ? (
<div className="space-y-3"> {/* Tab Report Guardie */}
{guardStats.map((stat, index) => ( <TabsContent value="guards" className="space-y-4">
<div {isLoadingGuards ? (
key={index} <div className="space-y-4">
className="flex items-center gap-4" <Skeleton className="h-32 w-full" />
data-testid={`guard-stat-${index}`} <Skeleton className="h-64 w-full" />
> </div>
<div className="flex-1 min-w-0"> ) : guardReport ? (
<p className="font-medium truncate">{stat.name}</p> <>
<div className="mt-1 h-2 bg-secondary rounded-full overflow-hidden"> {/* Summary cards */}
<div <div className="grid gap-4 md:grid-cols-4">
className="h-full bg-primary" <Card>
style={{ <CardHeader className="pb-3">
width: `${(stat.hours / (guardStats[0]?.hours || 1)) * 100}%`, <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>
</div> </div>
</div> ))}
<div className="text-right">
<p className="text-lg font-semibold font-mono">{stat.hours}h</p>
</div>
</div> </div>
))} ) : (
</div> <p className="text-center text-muted-foreground py-8">Nessun sito con ore fatturabili</p>
) : ( )}
<div className="text-center py-8"> </CardContent>
<BarChart3 className="h-12 w-12 text-muted-foreground mx-auto mb-3" /> </Card>
<p className="text-sm text-muted-foreground"> </>
Nessun dato disponibile ) : null}
</p> </TabsContent>
</div> </Tabs>
)}
</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> </div>
); );
} }

View File

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

View File

@ -330,6 +330,34 @@ 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 ============= // ============= VEHICLE ROUTES =============
app.get("/api/vehicles", isAuthenticated, async (req, res) => { app.get("/api/vehicles", isAuthenticated, async (req, res) => {
try { try {
@ -1212,7 +1240,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Assign guard to site/date with specific time slot (supports multi-day assignments) // Assign guard to site/date with specific time slot (supports multi-day assignments)
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => { app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
try { try {
const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1 } = req.body; const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId } = req.body;
if (!siteId || !date || !guardId || !startTime || !durationHours) { if (!siteId || !date || !guardId || !startTime || !durationHours) {
return res.status(400).json({ return res.status(400).json({
@ -1317,6 +1345,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
endTime: shiftEnd, endTime: shiftEnd,
shiftType: site.shiftType || "fixed_post", shiftType: site.shiftType || "fixed_post",
status: "planned", status: "planned",
vehicleId: vehicleId || null,
}).returning(); }).returning();
} }
@ -1400,6 +1429,485 @@ 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 ============= // ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => { app.post("/api/certifications", isAuthenticated, async (req, res) => {
try { try {
@ -2153,6 +2661,275 @@ 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); const httpServer = createServer(app);
return httpServer; return httpServer;
} }

View File

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