Compare commits
7 Commits
24a1c81d6e
...
03049f4090
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03049f4090 | ||
|
|
2fd5764415 | ||
|
|
b05bd3a0b9 | ||
|
|
efcaca356a | ||
|
|
a945abdb5d | ||
|
|
d7c6136fcb | ||
|
|
52f3aee8e4 |
@ -24,6 +24,7 @@ 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();
|
||||
@ -44,6 +45,7 @@ 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} />
|
||||
|
||||
@ -61,6 +61,12 @@ 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",
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { GuardAvailability } from "@shared/schema";
|
||||
import type { GuardAvailability, Vehicle as VehicleDb } from "@shared/schema";
|
||||
|
||||
interface GuardWithHours {
|
||||
assignmentId: string;
|
||||
@ -98,6 +98,7 @@ 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);
|
||||
@ -147,6 +148,19 @@ 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) => {
|
||||
@ -172,13 +186,14 @@ 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 }) => {
|
||||
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);
|
||||
},
|
||||
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/guards/availability"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["/api/vehicles/available"] });
|
||||
|
||||
// Refetch immediatamente guardie disponibili per aggiornare lista
|
||||
await refetchGuards();
|
||||
@ -188,8 +203,9 @@ export default function GeneralPlanning() {
|
||||
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("");
|
||||
setSelectedVehicleId("");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Parse error message from API response
|
||||
@ -228,6 +244,7 @@ export default function GeneralPlanning() {
|
||||
startTime,
|
||||
durationHours,
|
||||
consecutiveDays,
|
||||
...(selectedVehicleId && { vehicleId: selectedVehicleId }),
|
||||
});
|
||||
};
|
||||
|
||||
@ -526,8 +543,9 @@ export default function GeneralPlanning() {
|
||||
setStartTime("06:00");
|
||||
setDurationHours(8);
|
||||
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>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<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 */}
|
||||
<Button
|
||||
onClick={handleAssignGuard}
|
||||
|
||||
@ -1,227 +1,399 @@
|
||||
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 { 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 { 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";
|
||||
|
||||
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 { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
|
||||
queryKey: ["/api/shifts"],
|
||||
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: guards, isLoading: guardsLoading } = useQuery<Guard[]>({
|
||||
queryKey: ["/api/guards"],
|
||||
// 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 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;
|
||||
});
|
||||
// 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 guardStats = Object.values(hoursPerGuard).sort((a, b) => b.hours - a.hours);
|
||||
// Export CSV guardie
|
||||
const exportGuardsCSV = () => {
|
||||
if (!guardReport?.guards) return;
|
||||
|
||||
// Monthly statistics
|
||||
const currentMonth = new Date();
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
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");
|
||||
|
||||
const monthlyShifts = completedShifts.filter(s => {
|
||||
const shiftDate = new Date(s.startTime);
|
||||
return shiftDate >= monthStart && shiftDate <= monthEnd;
|
||||
});
|
||||
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 monthlyHours = monthlyShifts.reduce((acc, shift) => {
|
||||
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
|
||||
}, 0);
|
||||
// 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();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-full overflow-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<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">
|
||||
Ore lavorate e copertura servizi
|
||||
Ore lavorate, buoni pasto e fatturazione
|
||||
</p>
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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}%`,
|
||||
}}
|
||||
/>
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold font-mono">{stat.hours}h</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">Nessun sito con ore fatturabili</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
288
client/src/pages/service-planning.tsx
Normal file
288
client/src/pages/service-planning.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.31_20251022_081911.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.31_20251022_081911.sql.gz
Normal file
Binary file not shown.
779
server/routes.ts
779
server/routes.ts
@ -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 =============
|
||||
app.get("/api/vehicles", isAuthenticated, async (req, res) => {
|
||||
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)
|
||||
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
|
||||
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) {
|
||||
return res.status(400).json({
|
||||
@ -1317,6 +1345,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
endTime: shiftEnd,
|
||||
shiftType: site.shiftType || "fixed_post",
|
||||
status: "planned",
|
||||
vehicleId: vehicleId || null,
|
||||
}).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 =============
|
||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||
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);
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
10
version.json
10
version.json
@ -1,7 +1,13 @@
|
||||
{
|
||||
"version": "1.0.30",
|
||||
"lastUpdate": "2025-10-22T07:13:11.868Z",
|
||||
"version": "1.0.31",
|
||||
"lastUpdate": "2025-10-22T08:19:27.977Z",
|
||||
"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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user