Compare commits
4 Commits
f34e8f9136
...
51691bda11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51691bda11 | ||
|
|
eef9b6027d | ||
|
|
bd4a55e001 | ||
|
|
c5e4c66815 |
@ -26,6 +26,7 @@ import OperationalPlanning from "@/pages/operational-planning";
|
|||||||
import GeneralPlanning from "@/pages/general-planning";
|
import GeneralPlanning from "@/pages/general-planning";
|
||||||
import ServicePlanning from "@/pages/service-planning";
|
import ServicePlanning from "@/pages/service-planning";
|
||||||
import Customers from "@/pages/customers";
|
import Customers from "@/pages/customers";
|
||||||
|
import PlanningMobile from "@/pages/planning-mobile";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
@ -48,6 +49,7 @@ function Router() {
|
|||||||
<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="/service-planning" component={ServicePlanning} />
|
||||||
|
<Route path="/planning-mobile" component={PlanningMobile} />
|
||||||
<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} />
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
Car,
|
Car,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
Navigation,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import {
|
import {
|
||||||
@ -61,6 +62,12 @@ const menuItems = [
|
|||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
roles: ["admin", "coordinator"],
|
roles: ["admin", "coordinator"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Planning Mobile",
|
||||||
|
url: "/planning-mobile",
|
||||||
|
icon: Navigation,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Planning di Servizio",
|
title: "Planning di Servizio",
|
||||||
url: "/service-planning",
|
url: "/service-planning",
|
||||||
|
|||||||
284
client/src/pages/planning-mobile.tsx
Normal file
284
client/src/pages/planning-mobile.tsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Calendar, MapPin, User, Car, Clock } from "lucide-react";
|
||||||
|
import { format, parseISO, isValid } from "date-fns";
|
||||||
|
import { it } from "date-fns/locale";
|
||||||
|
|
||||||
|
type Location = "roccapiemonte" | "milano" | "roma";
|
||||||
|
|
||||||
|
type MobileSite = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
serviceTypeId: string;
|
||||||
|
serviceTypeName: string;
|
||||||
|
location: Location;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AvailableGuard = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
badgeNumber: string;
|
||||||
|
location: Location;
|
||||||
|
weeklyHours: number;
|
||||||
|
availableHours: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlanningMobile() {
|
||||||
|
const [selectedDate, setSelectedDate] = useState(format(new Date(), "yyyy-MM-dd"));
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||||
|
const [selectedGuardId, setSelectedGuardId] = useState<string>("");
|
||||||
|
|
||||||
|
// Query siti mobile per location
|
||||||
|
const { data: mobileSites, isLoading: sitesLoading } = useQuery<MobileSite[]>({
|
||||||
|
queryKey: ["/api/planning-mobile/sites", selectedLocation],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`/api/planning-mobile/sites?location=${selectedLocation}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch mobile sites");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedLocation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query guardie disponibili per location e data
|
||||||
|
const { data: availableGuards, isLoading: guardsLoading } = useQuery<AvailableGuard[]>({
|
||||||
|
queryKey: ["/api/planning-mobile/guards", selectedLocation, selectedDate],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch(`/api/planning-mobile/guards?location=${selectedLocation}&date=${selectedDate}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch available guards");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedLocation && !!selectedDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationLabels: Record<Location, string> = {
|
||||||
|
roccapiemonte: "Roccapiemonte",
|
||||||
|
milano: "Milano",
|
||||||
|
roma: "Roma",
|
||||||
|
};
|
||||||
|
|
||||||
|
const locationColors: Record<Location, string> = {
|
||||||
|
roccapiemonte: "bg-blue-500",
|
||||||
|
milano: "bg-green-500",
|
||||||
|
roma: "bg-purple-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold">Planning Mobile</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Pianificazione ronde, ispezioni e interventi notturni per servizi mobili
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtri */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-5 w-5" />
|
||||||
|
Filtri Pianificazione
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Seleziona sede, data e guardia per iniziare</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="location-select">Sede*</Label>
|
||||||
|
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
|
||||||
|
<SelectTrigger id="location-select" data-testid="select-mobile-location">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
||||||
|
<SelectItem value="milano">Milano</SelectItem>
|
||||||
|
<SelectItem value="roma">Roma</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date-select">Data*</Label>
|
||||||
|
<Input
|
||||||
|
id="date-select"
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
|
data-testid="input-mobile-date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="guard-select">Guardia (opzionale)</Label>
|
||||||
|
<Select value={selectedGuardId} onValueChange={setSelectedGuardId}>
|
||||||
|
<SelectTrigger id="guard-select" data-testid="select-mobile-guard">
|
||||||
|
<SelectValue placeholder="Tutte le guardie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Tutte le guardie</SelectItem>
|
||||||
|
{availableGuards?.map((guard) => (
|
||||||
|
<SelectItem key={guard.id} value={guard.id}>
|
||||||
|
{guard.firstName} {guard.lastName} - #{guard.badgeNumber} ({guard.availableHours}h disponibili)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Grid: Mappa + Siti */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 flex-1">
|
||||||
|
{/* Mappa Siti */}
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-5 w-5" />
|
||||||
|
Mappa Siti Mobile
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{mobileSites?.length || 0} siti con servizi mobili in {locationLabels[selectedLocation]}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex items-center justify-center bg-muted/20 rounded-lg">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<MapPin className="h-12 w-12 mx-auto text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Integrazione mappa in sviluppo
|
||||||
|
<br />
|
||||||
|
(Leaflet/Google Maps)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Lista Siti Mobile */}
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5" />
|
||||||
|
Siti con Servizi Mobili
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ronde notturne, ispezioni, interventi programmati
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 overflow-y-auto">
|
||||||
|
{sitesLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Caricamento...</p>
|
||||||
|
) : mobileSites && mobileSites.length > 0 ? (
|
||||||
|
mobileSites.map((site) => (
|
||||||
|
<div
|
||||||
|
key={site.id}
|
||||||
|
className="p-4 border rounded-lg space-y-2 hover-elevate cursor-pointer"
|
||||||
|
data-testid={`site-card-${site.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<h4 className="font-semibold">{site.name}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{site.address}, {site.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={locationColors[site.location]} data-testid={`badge-location-${site.id}`}>
|
||||||
|
{locationLabels[site.location]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="outline" data-testid={`badge-service-${site.id}`}>
|
||||||
|
{site.serviceTypeName}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button size="sm" variant="default" data-testid={`button-assign-${site.id}`}>
|
||||||
|
Assegna Guardia
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" data-testid={`button-view-${site.id}`}>
|
||||||
|
Dettagli
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 space-y-2">
|
||||||
|
<MapPin className="h-12 w-12 mx-auto text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nessun sito con servizi mobili in {locationLabels[selectedLocation]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guardie Disponibili */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
Guardie Disponibili ({availableGuards?.length || 0})
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Guardie con ore disponibili per {format(parseISO(selectedDate), "dd MMMM yyyy", { locale: it })}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{guardsLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground col-span-full">Caricamento...</p>
|
||||||
|
) : availableGuards && availableGuards.length > 0 ? (
|
||||||
|
availableGuards.map((guard) => (
|
||||||
|
<div
|
||||||
|
key={guard.id}
|
||||||
|
className="p-3 border rounded-lg space-y-2"
|
||||||
|
data-testid={`guard-card-${guard.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h5 className="font-semibold text-sm">
|
||||||
|
{guard.firstName} {guard.lastName}
|
||||||
|
</h5>
|
||||||
|
<p className="text-xs text-muted-foreground">#{guard.badgeNumber}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={locationColors[guard.location]}>
|
||||||
|
{locationLabels[guard.location]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Ore settimanali:</span>
|
||||||
|
<span className="font-medium">{guard.weeklyHours}h / 45h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Disponibili:</span>
|
||||||
|
<span className="font-medium text-green-600">{guard.availableHours}h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground col-span-full text-center py-4">
|
||||||
|
Nessuna guardia disponibile per la data selezionata
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -166,6 +166,7 @@ export default function Services() {
|
|||||||
description: "",
|
description: "",
|
||||||
icon: "Building2",
|
icon: "Building2",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
|
classification: "fisso", // ✅ NUOVO: Discriminante Planning Fissi/Mobile
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -178,6 +179,7 @@ export default function Services() {
|
|||||||
description: "",
|
description: "",
|
||||||
icon: "Building2",
|
icon: "Building2",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
|
classification: "fisso", // ✅ NUOVO: Discriminante Planning Fissi/Mobile
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -235,10 +237,7 @@ export default function Services() {
|
|||||||
description: type.description,
|
description: type.description,
|
||||||
icon: type.icon,
|
icon: type.icon,
|
||||||
color: type.color,
|
color: type.color,
|
||||||
fixedPostHours: type.fixedPostHours || null,
|
classification: type.classification, // ✅ NUOVO: includi classification
|
||||||
patrolPassages: type.patrolPassages || null,
|
|
||||||
inspectionFrequency: type.inspectionFrequency || null,
|
|
||||||
responseTimeMinutes: type.responseTimeMinutes || null,
|
|
||||||
isActive: type.isActive,
|
isActive: type.isActive,
|
||||||
});
|
});
|
||||||
setEditTypeDialogOpen(true);
|
setEditTypeDialogOpen(true);
|
||||||
@ -1071,91 +1070,28 @@ export default function Services() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 p-4 border rounded-lg">
|
{/* ✅ NUOVO: Classification (Fisso/Mobile) */}
|
||||||
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={createTypeForm.control}
|
control={createTypeForm.control}
|
||||||
name="fixedPostHours"
|
name="classification"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Ore Presidio Fisso</FormLabel>
|
<FormLabel>Tipo Pianificazione*</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<SelectTrigger data-testid="select-type-classification">
|
||||||
type="number"
|
<SelectValue />
|
||||||
{...field}
|
</SelectTrigger>
|
||||||
value={field.value || ""}
|
|
||||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
|
||||||
placeholder="es: 8, 12"
|
|
||||||
data-testid="input-fixed-post-hours"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fisso">Fisso (Planning Fissi)</SelectItem>
|
||||||
|
<SelectItem value="mobile">Mobile (Planning Mobile)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={createTypeForm.control}
|
|
||||||
name="patrolPassages"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Passaggi Pattugliamento</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
|
||||||
placeholder="es: 3, 5"
|
|
||||||
data-testid="input-patrol-passages"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={createTypeForm.control}
|
|
||||||
name="inspectionFrequency"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Frequenza Ispezioni (min)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
|
||||||
placeholder="es: 60, 120"
|
|
||||||
data-testid="input-inspection-frequency"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={createTypeForm.control}
|
|
||||||
name="responseTimeMinutes"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Tempo Risposta (min)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
|
||||||
placeholder="es: 15, 30"
|
|
||||||
data-testid="input-response-time"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={createTypeForm.control}
|
control={createTypeForm.control}
|
||||||
@ -1309,7 +1245,30 @@ export default function Services() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 p-4 border rounded-lg">
|
{/* ✅ NUOVO: Classification (Fisso/Mobile) */}
|
||||||
|
<FormField
|
||||||
|
control={editTypeForm.control}
|
||||||
|
name="classification"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tipo Pianificazione*</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger data-testid="select-edit-type-classification">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fisso">Fisso (Planning Fissi)</SelectItem>
|
||||||
|
<SelectItem value="mobile">Mobile (Planning Mobile)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-4 border rounded-lg" style={{display: "none"}}>
|
||||||
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
|
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.36_20251023_090614.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.36_20251023_090614.sql.gz
Normal file
Binary file not shown.
146
replit.md
146
replit.md
@ -1,7 +1,7 @@
|
|||||||
# VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza
|
# VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
VigilanzaTurni is a professional 24/7 shift management system designed for security companies. It offers multi-role authentication (Admin, Coordinator, Guard, Client), comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system. The system supports multi-location operations (Roccapiemonte, Milano, Roma) managing 250+ security personnel across different branches. The project aims to streamline operations and enhance efficiency for security institutes.
|
VigilanzaTurni is a professional 24/7 shift management system for security companies, designed to streamline operations and enhance efficiency. It supports multi-role authentication (Admin, Coordinator, Guard, Client) and multi-location operations, managing over 250 security personnel across different branches (Roccapiemonte, Milano, Roma). Key capabilities include comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system.
|
||||||
|
|
||||||
## User Preferences
|
## User Preferences
|
||||||
- Interfaccia in italiano
|
- Interfaccia in italiano
|
||||||
@ -10,43 +10,6 @@ VigilanzaTurni is a professional 24/7 shift management system designed for secur
|
|||||||
- Focus su efficienza e densità informativa
|
- Focus su efficienza e densità informativa
|
||||||
- **Testing**: Tutti i test vengono eseguiti ESCLUSIVAMENTE sul server esterno (vt.alfacom.it) con autenticazione locale (non Replit Auth)
|
- **Testing**: Tutti i test vengono eseguiti ESCLUSIVAMENTE sul server esterno (vt.alfacom.it) con autenticazione locale (non Replit Auth)
|
||||||
|
|
||||||
## ⚠️ CRITICAL: Date/Timezone Handling Rules
|
|
||||||
**PROBLEMA RICORRENTE**: Quando si assegna una guardia per il giorno X, appare assegnata al giorno X±1 a causa di conversioni timezone.
|
|
||||||
|
|
||||||
**REGOLE OBBLIGATORIE** per evitare questo bug:
|
|
||||||
|
|
||||||
1. **MAI usare `parseISO()` su date YYYY-MM-DD**
|
|
||||||
- ❌ SBAGLIATO: `const date = parseISO("2025-10-20")` → converte in UTC causando shift
|
|
||||||
- ✅ CORRETTO: `const [y, m, d] = "2025-10-20".split("-").map(Number); const date = new Date(y, m-1, d)`
|
|
||||||
|
|
||||||
2. **Costruire Date da componenti, NON da stringhe ISO**
|
|
||||||
```typescript
|
|
||||||
// ✅ CORRETTO - date components (no timezone conversion)
|
|
||||||
const [year, month, day] = startDate.split("-").map(Number);
|
|
||||||
const shiftDate = new Date(year, month - 1, day);
|
|
||||||
const shiftStart = new Date(year, month - 1, day, startHour, startMin, 0, 0);
|
|
||||||
|
|
||||||
// ❌ SBAGLIATO - parseISO o new Date(string ISO)
|
|
||||||
const date = parseISO(startDate); // converte in UTC!
|
|
||||||
const date = new Date("2025-10-20"); // timezone-dependent!
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Validazione date: usare regex, NON parseISO**
|
|
||||||
```typescript
|
|
||||||
// ✅ CORRETTO
|
|
||||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
|
||||||
if (!dateRegex.test(dateStr)) { /* invalid */ }
|
|
||||||
|
|
||||||
// ❌ SBAGLIATO
|
|
||||||
const parsed = parseISO(dateStr);
|
|
||||||
if (!isValid(parsed)) { /* invalid */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **File da verificare sempre**: `server/routes.ts` - tutte le route che ricevono date dal frontend
|
|
||||||
5. **Testare sempre**: Assegnare guardia giorno X → verificare appaia nel giorno X (non X±1)
|
|
||||||
|
|
||||||
**RIFERIMENTI FIX**: Vedere commit "Fix timezone bug in shift creation" - linee 1148-1184, 615-621, 753-759 in server/routes.ts
|
|
||||||
|
|
||||||
## System Architecture
|
## System Architecture
|
||||||
|
|
||||||
### Stack Tecnologico
|
### Stack Tecnologico
|
||||||
@ -65,94 +28,29 @@ VigilanzaTurni is a professional 24/7 shift management system designed for secur
|
|||||||
- **Componenti**: Shadcn UI with an operational design.
|
- **Componenti**: Shadcn UI with an operational design.
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
The database includes core tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, and `notifications`. Advanced scheduling and constraints are managed via `guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, and `absence_affected_shifts`. All tables include appropriate foreign keys and unique constraints to maintain data integrity.
|
The database includes tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, `notifications`, `customers`, and various tables for advanced scheduling and constraints (`guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, `absence_affected_shifts`). Service types include specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, and `responseTimeMinutes`. Sites support multi-location (`location` field) and contract management (`contractReference`, `contractStartDate`, `contractEndDate`).
|
||||||
|
|
||||||
**Recent Schema Updates (October 2025)**:
|
### Core Features
|
||||||
- Service types now include specialized parameters: `fixedPostHours` (ore presidio fisso), `patrolPassages` (numero passaggi pattuglia), `inspectionFrequency` (frequenza ispezioni), `responseTimeMinutes` (tempo risposta pronto intervento)
|
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering sites, guards, and vehicles by selected branch.
|
||||||
- Sites include service schedule fields: `serviceStartTime` and `serviceEndTime` (formato HH:MM)
|
- **Service Type Classification**: Service types are classified as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules (Planning Fissi, Planning Mobile).
|
||||||
- **Contract Management**: Sites now include contract fields: `contractReference` (codice contratto), `contractStartDate`, `contractEndDate` (date validità contratto in formato YYYY-MM-DD)
|
- **Planning Fissi**: Weekly planning grid showing all sites with active contracts, allowing direct shift creation for multiple days with guard availability checks.
|
||||||
- Sites now reference service types via `serviceTypeId` foreign key; `shiftType` is optional and can be derived from service type
|
- **Planning Mobile**: Dedicated guard-centric interface for mobile services, displaying guard availability and hours for mobile-classified sites. Includes a map placeholder for future integration.
|
||||||
- **Multi-Location Support**: Added `location` field (enum: roccapiemonte, milano, roma) to `sites`, `guards`, and `vehicles` tables for complete multi-sede resource isolation
|
- **Customer Management**: Full CRUD operations for customers with comprehensive details.
|
||||||
- **Customer Management (October 23, 2025)**: Added `customers` table with `sites.customerId` foreign key for customer-centric organization. Customers include: name, business name, VAT/fiscal code, address, city, province, ZIP, phone, email, PEC, contact person, notes, active status.
|
- **Customer-Centric Reports**: New reports aggregating data by customer, replacing site-based billing, with specific counters for fixed posts (hours), patrols (passages), inspections, and interventions. CSV export is supported.
|
||||||
|
- **Dashboard Operativa**: Live KPIs and real-time shift status.
|
||||||
**Recent Features (October 23, 2025)**:
|
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and unique badge numbers.
|
||||||
- **Customer Management (Anagrafica Clienti)**: New `/customers` page with full CRUD operations:
|
- **Gestione Siti/Commesse**: Service types with specialized parameters and minimum requirements. Sites include service schedule, contract management, and location assignment.
|
||||||
- Comprehensive customer form: name, business name, VAT/fiscal code, address, contacts (phone/email/PEC), referent, notes
|
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses.
|
||||||
- Customer status toggle (active/inactive)
|
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences.
|
||||||
- Delete confirmation with cascade considerations
|
|
||||||
- Sidebar menu entry for admin/coordinator roles
|
|
||||||
- Backend: Full CRUD routes with validation
|
|
||||||
- **Reports per Cliente**: New customer-centric billing reports replacing site-based approach:
|
|
||||||
- Backend endpoint `/api/reports/customer-billing` aggregating by customer → sites → service types
|
|
||||||
- Separate counters based on service type:
|
|
||||||
* Presidio Fisso → Hours worked
|
|
||||||
* Pattuglia/Ronda → Number of passages (from `serviceType.patrolPassages`)
|
|
||||||
* Ispezione → Number of inspections counted
|
|
||||||
* Pronto Intervento → Number of interventions counted
|
|
||||||
- Frontend: "Report Clienti" tab with 5 KPI cards (customers, hours, passages, inspections, interventions)
|
|
||||||
- Hierarchical display: Customer header → Sites list → Service type details with conditional badges
|
|
||||||
- CSV export with 8 columns: Cliente, Sito, Tipologia Servizio, Ore, Turni, Passaggi, Ispezioni, Interventi
|
|
||||||
- Maintains existing "Report Guardie" and "Report Siti" tabs for compatibility
|
|
||||||
|
|
||||||
**Recent Features (October 17-18, 2025)**:
|
|
||||||
- **Multi-Sede Operational Planning**: Redesigned operational planning workflow with location-first approach:
|
|
||||||
1. Select sede (Roccapiemonte/Milano/Roma) - first step with default value
|
|
||||||
2. Select date
|
|
||||||
3. View uncovered sites filtered by selected sede
|
|
||||||
4. Select site → view available resources (guards and vehicles) filtered by sede
|
|
||||||
5. Assign resources and create shift
|
|
||||||
- **Location-Based Filtering**: Backend endpoints use INNER JOIN with sites table to ensure complete resource isolation between locations - guards/vehicles in one sede remain available even when assigned to shifts in other sedi
|
|
||||||
- **Site Management**: Added sede selection in site creation/editing forms with visual badges showing location in site listings
|
|
||||||
- **Planning Fissi (October 18, 2025)**: New weekly planning overview feature showing all sites × 7 days in table format:
|
|
||||||
- **Contract filtering**: Shows only sites with active contracts in the week dates (`contractStartDate <= weekEnd AND contractEndDate >= weekStart`)
|
|
||||||
- Backend endpoint `/api/general-planning?weekStart=YYYY-MM-DD&location=sede` with complex joins and location filtering
|
|
||||||
- Automatic missing guards calculation: `ceil(totalShiftHours / maxHoursPerGuard) × minGuards - assignedGuards` (e.g., 24h shift, 2 guards min, 9h max = 6 total needed)
|
|
||||||
- **Weekly summary**: Shows total guards needed, guards assigned (counting slots, not unique people), and guards missing for the entire week
|
|
||||||
- Table cells display: assigned guards with hours, vehicles, missing guards badge (if any), shift count, total hours
|
|
||||||
- Interactive cells with click handler opening detail dialog
|
|
||||||
- Dialog shows: shift count, total hours, guard list with hours and badge numbers, vehicle list, missing guards warning with explanation
|
|
||||||
- **Direct Shift Creation from Dialog**: Users can now create multi-day shifts directly from the Planning Generale dialog:
|
|
||||||
- Select guard from dropdown showing name + weekly available hours (max 45h - assigned hours)
|
|
||||||
- Specify number of consecutive days (1-7)
|
|
||||||
- Backend endpoint `POST /api/general-planning/shifts` with atomic transaction using `db.transaction()` - all shifts created or none (rollback on error)
|
|
||||||
- Validates contract dates, site and guard existence before transaction
|
|
||||||
- Automatically creates shifts spanning multiple days with correct time ranges from site service schedule
|
|
||||||
- TanStack Query mutation with cache invalidation for real-time planning grid updates
|
|
||||||
- "Modifica in Pianificazione Operativa" button in dialog navigates to operational planning page with pre-filled date/location parameters
|
|
||||||
- Week navigation (previous/next week) with location selector
|
|
||||||
- Operational planning page now supports query parameters (`?date=YYYY-MM-DD&location=sede`) for seamless integration
|
|
||||||
|
|
||||||
**Recent Bug Fixes (October 17-18, 2025)**:
|
|
||||||
- **Operational Planning Date Handling**: Fixed date sanitization in `/api/operational-planning/uncovered-sites` and `/api/operational-planning/availability` endpoints to handle malformed date inputs (e.g., "2025-10-17/2025-10-17"). Both endpoints now validate dates using `parseISO`/`isValid` and return 400 for invalid formats.
|
|
||||||
- **Checkbox Event Propagation**: Fixed double-toggle bug in operational planning resource selection by wrapping vehicle and guard checkboxes in `<div onClick={e => e.stopPropagation()}>` to prevent Card onClick from firing when clicking checkboxes.
|
|
||||||
- **Multi-Sede Resource Isolation**: Fixed critical bug where resources from different sedi were incorrectly marked as unavailable due to global shift queries. Now both availability and uncovered-sites endpoints filter shifts by location using JOIN with sites table.
|
|
||||||
- **QueryKey Cache Invalidation**: Fixed queryKey structure from single-string to hierarchical array with custom queryFn to enable targeted cache invalidation by location and date while preventing URL concatenation errors.
|
|
||||||
- **apiRequest Parameter Order (October 18, 2025)**: Fixed inverted parameters bug in Planning Generale shift creation mutation. Changed `apiRequest(url, method, data)` to correct signature `apiRequest(method, url, data)` matching queryClient.ts function definition.
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
Comprehensive RESTful API endpoints are provided for Authentication, Users, Guards, Sites, Shifts, and Notifications, supporting full CRUD operations with role-based access control.
|
|
||||||
|
|
||||||
### Frontend Routes
|
|
||||||
Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/notifications`, and `/users`, with access controlled by user roles.
|
|
||||||
|
|
||||||
### User Roles
|
### User Roles
|
||||||
- **Admin**: Full access to all functionalities, managing guards, sites, shifts, and reports.
|
- **Admin**: Full access.
|
||||||
- **Coordinator**: Shift planning, guard assignment, operational site management, and reporting.
|
- **Coordinator**: Shift planning, guard assignment, operational site management, reporting.
|
||||||
- **Guard**: View assigned shifts, future time-punching, notifications, and personal profile.
|
- **Guard**: View assigned shifts, time-punching, notifications, personal profile.
|
||||||
- **Client**: View assigned sites, service reporting, and KPIs.
|
- **Client**: View assigned sites, service reporting, KPIs.
|
||||||
|
|
||||||
### Key Features
|
### Critical Date/Timezone Handling
|
||||||
- **Dashboard Operativa**: Live KPIs (active shifts, total guards, active sites, expiring certifications) and real-time shift status.
|
To prevent timezone-related bugs, especially when assigning shifts, dates should always be constructed from components (`new Date(year, month-1, day)`) and never parsed from ISO strings directly using `parseISO()` or `new Date(string ISO)`. Date validation should use regex instead of `parseISO()`.
|
||||||
- **Gestione Guardie**: Complete profiles with skill matrix (armed, fire safety, first aid, driver's license), certification management with automatic expiry, and unique badge numbers.
|
|
||||||
- **Gestione Siti/Commesse**: Service types with specialized parameters (fixed post hours, patrol passages, inspection frequency, response time) and minimum requirements (guard count, armed, driver's license). Sites include service schedule (start/end time), contract management (reference code, validity period with start/end dates), and location/sede assignment. Contract status is visualized with badges (active/expiring/expired) and enforces shift creation only within active contract periods.
|
|
||||||
- **Pianificazione Operativa Multi-Sede**: Location-aware workflow for shift assignment:
|
|
||||||
1. Select sede (Roccapiemonte/Milano/Roma) → filters all subsequent data by location
|
|
||||||
2. Select date → view uncovered sites with coverage status (sede-filtered)
|
|
||||||
3. Select site → view available resources (guards and vehicles matching sede and requirements)
|
|
||||||
4. Assign resources → create shift with atomic guard assignments and vehicle allocation
|
|
||||||
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses (planned, active, completed, cancelled).
|
|
||||||
- **Reportistica**: Total hours worked, monthly hours per guard, shift statistics, and data export capabilities.
|
|
||||||
- **Advanced Planning**: Management of guard constraints (preferences, max hours, rest days), site preferences (preferred/blacklisted guards), contract parameters, training courses, holidays, and absences with substitution system.
|
|
||||||
|
|
||||||
## External Dependencies
|
## External Dependencies
|
||||||
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
|
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
|
||||||
@ -163,7 +61,3 @@ Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/n
|
|||||||
- **TanStack Query**: For data fetching and state management.
|
- **TanStack Query**: For data fetching and state management.
|
||||||
- **Wouter**: For client-side routing.
|
- **Wouter**: For client-side routing.
|
||||||
- **date-fns**: For date manipulation and formatting.
|
- **date-fns**: For date manipulation and formatting.
|
||||||
- **PM2**: Production process manager for Node.js applications.
|
|
||||||
- **Nginx**: As a reverse proxy for the production environment.
|
|
||||||
- **Let's Encrypt**: For SSL/TLS certificates.
|
|
||||||
- **GitLab CI/CD**: For continuous integration and deployment.
|
|
||||||
|
|||||||
180
server/routes.ts
180
server/routes.ts
@ -4,7 +4,7 @@ import { storage } from "./storage";
|
|||||||
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
||||||
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema } from "@shared/schema";
|
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema, customers } from "@shared/schema";
|
||||||
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
||||||
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -882,23 +882,31 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
|
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
|
||||||
const normalizedWeekStart = rawWeekStart.split("/")[0];
|
const normalizedWeekStart = rawWeekStart.split("/")[0];
|
||||||
|
|
||||||
// Valida la data
|
// ✅ CORRETTO: Valida date con regex, NON parseISO
|
||||||
const parsedWeekStart = parseISO(normalizedWeekStart);
|
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
if (!isValid(parsedWeekStart)) {
|
if (!dateRegex.test(normalizedWeekStart)) {
|
||||||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd");
|
// ✅ CORRETTO: Costruisci Date da componenti per evitare timezone shift
|
||||||
|
const [year, month, day] = normalizedWeekStart.split("-").map(Number);
|
||||||
|
const parsedWeekStart = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
|
|
||||||
|
const weekStartDate = normalizedWeekStart;
|
||||||
|
|
||||||
// Ottieni location dalla query (default: roccapiemonte)
|
// Ottieni location dalla query (default: roccapiemonte)
|
||||||
const location = req.query.location as string || "roccapiemonte";
|
const location = req.query.location as string || "roccapiemonte";
|
||||||
|
|
||||||
// Calcola fine settimana (weekStart + 6 giorni)
|
// Calcola fine settimana (weekStart + 6 giorni) usando componenti
|
||||||
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
|
const tempWeekEnd = new Date(year, month - 1, day + 6, 23, 59, 59, 999);
|
||||||
|
const weekEndYear = tempWeekEnd.getFullYear();
|
||||||
|
const weekEndMonth = tempWeekEnd.getMonth() + 1;
|
||||||
|
const weekEndDay = tempWeekEnd.getDate();
|
||||||
|
const weekEndDate = `${weekEndYear}-${String(weekEndMonth).padStart(2, '0')}-${String(weekEndDay).padStart(2, '0')}`;
|
||||||
|
|
||||||
// Timestamp per filtro contratti
|
// ✅ CORRETTO: Timestamp da componenti per query database
|
||||||
const weekStartTimestampForContract = new Date(weekStartDate);
|
const weekStartTimestampForContract = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
const weekEndTimestampForContract = new Date(weekEndDate);
|
const weekEndTimestampForContract = new Date(weekEndYear, weekEndMonth - 1, weekEndDay, 23, 59, 59, 999);
|
||||||
|
|
||||||
// Ottieni tutti i siti attivi della sede con contratto valido nelle date della settimana
|
// Ottieni tutti i siti attivi della sede con contratto valido nelle date della settimana
|
||||||
const activeSites = await db
|
const activeSites = await db
|
||||||
@ -917,11 +925,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Ottieni tutti i turni della settimana per la sede
|
// Ottieni tutti i turni della settimana per la sede
|
||||||
const weekStartTimestamp = new Date(weekStartDate);
|
// ✅ CORRETTO: Usa timestamp già creati correttamente sopra
|
||||||
weekStartTimestamp.setHours(0, 0, 0, 0);
|
const weekStartTimestamp = weekStartTimestampForContract;
|
||||||
|
const weekEndTimestamp = weekEndTimestampForContract;
|
||||||
const weekEndTimestamp = new Date(weekEndDate);
|
|
||||||
weekEndTimestamp.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
const weekShifts = await db
|
const weekShifts = await db
|
||||||
.select({
|
.select({
|
||||||
@ -971,14 +977,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const weekData = [];
|
const weekData = [];
|
||||||
|
|
||||||
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
||||||
const currentDay = addDays(parsedWeekStart, dayOffset);
|
// ✅ CORRETTO: Calcola date usando componenti per evitare timezone shift
|
||||||
const dayStr = format(currentDay, "yyyy-MM-dd");
|
const currentDayTimestamp = new Date(year, month - 1, day + dayOffset, 0, 0, 0, 0);
|
||||||
|
const currentYear = currentDayTimestamp.getFullYear();
|
||||||
|
const currentMonth = currentDayTimestamp.getMonth() + 1;
|
||||||
|
const currentDay_num = currentDayTimestamp.getDate();
|
||||||
|
const dayStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(currentDay_num).padStart(2, '0')}`;
|
||||||
|
|
||||||
const dayStartTimestamp = new Date(dayStr);
|
const dayStartTimestamp = new Date(currentYear, currentMonth - 1, currentDay_num, 0, 0, 0, 0);
|
||||||
dayStartTimestamp.setHours(0, 0, 0, 0);
|
const dayEndTimestamp = new Date(currentYear, currentMonth - 1, currentDay_num, 23, 59, 59, 999);
|
||||||
|
|
||||||
const dayEndTimestamp = new Date(dayStr);
|
|
||||||
dayEndTimestamp.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
const sitesData = activeSites.map(({ sites: site, service_types: serviceType }: any) => {
|
const sitesData = activeSites.map(({ sites: site, service_types: serviceType }: any) => {
|
||||||
// Trova turni del giorno per questo sito
|
// Trova turni del giorno per questo sito
|
||||||
@ -3168,6 +3175,135 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============= PLANNING MOBILE ROUTES =============
|
||||||
|
// GET /api/planning-mobile/sites?location=X - Siti con servizi mobili (ronde/ispezioni/interventi)
|
||||||
|
app.get("/api/planning-mobile/sites", isAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { location } = req.query;
|
||||||
|
|
||||||
|
if (!location || !["roccapiemonte", "milano", "roma"].includes(location as string)) {
|
||||||
|
return res.status(400).json({ message: "Location parameter required (roccapiemonte|milano|roma)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query siti con serviceType.classification = 'mobile' e location matching
|
||||||
|
const mobileSites = await db
|
||||||
|
.select({
|
||||||
|
id: sites.id,
|
||||||
|
name: sites.name,
|
||||||
|
address: sites.address,
|
||||||
|
city: sites.city,
|
||||||
|
serviceTypeId: sites.serviceTypeId,
|
||||||
|
serviceTypeName: serviceTypes.label,
|
||||||
|
location: sites.location,
|
||||||
|
latitude: sites.latitude,
|
||||||
|
longitude: sites.longitude,
|
||||||
|
})
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.location, location as "roccapiemonte" | "milano" | "roma"),
|
||||||
|
eq(serviceTypes.classification, "mobile"),
|
||||||
|
eq(sites.isActive, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(sites.name);
|
||||||
|
|
||||||
|
res.json(mobileSites);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching mobile sites:", error);
|
||||||
|
res.status(500).json({ message: "Errore caricamento siti mobili" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/planning-mobile/guards?location=X&date=YYYY-MM-DD - Guardie disponibili per location e data
|
||||||
|
app.get("/api/planning-mobile/guards", isAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { location, date } = req.query;
|
||||||
|
|
||||||
|
if (!location || !["roccapiemonte", "milano", "roma"].includes(location as string)) {
|
||||||
|
return res.status(400).json({ message: "Location parameter required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!date || typeof date !== "string") {
|
||||||
|
return res.status(400).json({ message: "Date parameter required (YYYY-MM-DD)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valida formato data
|
||||||
|
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
if (!dateRegex.test(date)) {
|
||||||
|
return res.status(400).json({ message: "Invalid date format (use YYYY-MM-DD)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ottieni tutte le guardie per location
|
||||||
|
const allGuards = await db
|
||||||
|
.select()
|
||||||
|
.from(guards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(guards.location, location as "roccapiemonte" | "milano" | "roma"),
|
||||||
|
eq(guards.isActive, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(guards.lastName, guards.firstName);
|
||||||
|
|
||||||
|
// Calcola settimana corrente per calcolare ore settimanali
|
||||||
|
const [year, month, day] = date.split("-").map(Number);
|
||||||
|
const targetDate = new Date(year, month - 1, day);
|
||||||
|
const weekStart = startOfWeek(targetDate, { weekStartsOn: 1 }); // lunedì
|
||||||
|
const weekEnd = endOfWeek(targetDate, { weekStartsOn: 1 });
|
||||||
|
|
||||||
|
// Per ogni guardia, calcola ore già assegnate nella settimana
|
||||||
|
const guardsWithAvailability = await Promise.all(
|
||||||
|
allGuards.map(async (guard) => {
|
||||||
|
// Query shifts assegnati alla guardia nella settimana
|
||||||
|
const weekShifts = await db
|
||||||
|
.select({
|
||||||
|
shiftId: shifts.id,
|
||||||
|
startTime: shifts.startTime,
|
||||||
|
endTime: shifts.endTime,
|
||||||
|
})
|
||||||
|
.from(shiftAssignments)
|
||||||
|
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(shiftAssignments.guardId, guard.id),
|
||||||
|
gte(shifts.startTime, weekStart),
|
||||||
|
lte(shifts.startTime, weekEnd)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calcola ore totali nella settimana
|
||||||
|
const weeklyHours = weekShifts.reduce((total, shift) => {
|
||||||
|
const hours = differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
|
||||||
|
return total + hours;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const maxWeeklyHours = 45; // CCNL limit
|
||||||
|
const availableHours = Math.max(0, maxWeeklyHours - weeklyHours);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: guard.id,
|
||||||
|
firstName: guard.firstName,
|
||||||
|
lastName: guard.lastName,
|
||||||
|
badgeNumber: guard.badgeNumber,
|
||||||
|
location: guard.location,
|
||||||
|
weeklyHours,
|
||||||
|
availableHours,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filtra solo guardie con ore disponibili
|
||||||
|
const availableGuards = guardsWithAvailability.filter(g => g.availableHours > 0);
|
||||||
|
|
||||||
|
res.json(availableGuards);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching available guards:", error);
|
||||||
|
res.status(500).json({ message: "Errore caricamento guardie disponibili" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
return httpServer;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -91,6 +91,11 @@ export const locationEnum = pgEnum("location", [
|
|||||||
"roma", // Sede Roma
|
"roma", // Sede Roma
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const serviceClassificationEnum = pgEnum("service_classification", [
|
||||||
|
"fisso", // Presidio fisso - Planning Fissi
|
||||||
|
"mobile", // Pattuglie/ronde/interventi - Planning Mobile
|
||||||
|
]);
|
||||||
|
|
||||||
// ============= SESSION & AUTH TABLES (Replit Auth) =============
|
// ============= SESSION & AUTH TABLES (Replit Auth) =============
|
||||||
|
|
||||||
// Session storage table - mandatory for Replit Auth
|
// Session storage table - mandatory for Replit Auth
|
||||||
@ -189,6 +194,9 @@ export const serviceTypes = pgTable("service_types", {
|
|||||||
icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide
|
icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide
|
||||||
color: varchar("color").notNull().default("blue"), // blue, green, purple, orange
|
color: varchar("color").notNull().default("blue"), // blue, green, purple, orange
|
||||||
|
|
||||||
|
// ✅ NUOVO: Classificazione servizio - determina quale planning usare
|
||||||
|
classification: serviceClassificationEnum("classification").notNull().default("fisso"),
|
||||||
|
|
||||||
// Parametri specifici per tipo servizio
|
// Parametri specifici per tipo servizio
|
||||||
fixedPostHours: integer("fixed_post_hours"), // Ore presidio fisso (es. 8, 12)
|
fixedPostHours: integer("fixed_post_hours"), // Ore presidio fisso (es. 8, 12)
|
||||||
patrolPassages: integer("patrol_passages"), // Numero passaggi pattugliamento (es. 3, 5)
|
patrolPassages: integer("patrol_passages"), // Numero passaggi pattugliamento (es. 3, 5)
|
||||||
|
|||||||
10
version.json
10
version.json
@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.35",
|
"version": "1.0.36",
|
||||||
"lastUpdate": "2025-10-23T08:29:30.422Z",
|
"lastUpdate": "2025-10-23T09:06:31.261Z",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
{
|
||||||
|
"version": "1.0.36",
|
||||||
|
"date": "2025-10-23",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.36"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.35",
|
"version": "1.0.35",
|
||||||
"date": "2025-10-23",
|
"date": "2025-10-23",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user