Compare commits
5 Commits
0ab1a804eb
...
f0c0321d1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c0321d1a | ||
|
|
6f6fb4f90c | ||
|
|
eb2ccab920 | ||
|
|
121206a492 | ||
|
|
1edc335ca6 |
4
.replit
4
.replit
@ -19,10 +19,6 @@ externalPort = 80
|
|||||||
localPort = 33035
|
localPort = 33035
|
||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 37125
|
|
||||||
externalPort = 4200
|
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41343
|
localPort = 41343
|
||||||
externalPort = 3000
|
externalPort = 3000
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Calendar, AlertCircle, CheckCircle2, Clock, MapPin, Users, Shield, Car as CarIcon } from "lucide-react";
|
import { Calendar, AlertCircle, CheckCircle2, Clock, MapPin, Users, Shield, Car as CarIcon, Building2 } from "lucide-react";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { it } from "date-fns/locale";
|
import { it } from "date-fns/locale";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@ -84,8 +85,15 @@ interface ResourcesData {
|
|||||||
guards: Guard[];
|
guards: Guard[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locationLabels: Record<string, string> = {
|
||||||
|
roccapiemonte: "Roccapiemonte",
|
||||||
|
milano: "Milano",
|
||||||
|
roma: "Roma",
|
||||||
|
};
|
||||||
|
|
||||||
export default function OperationalPlanning() {
|
export default function OperationalPlanning() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte"); // Sede selezionata (primo step)
|
||||||
const [selectedDate, setSelectedDate] = useState<string>(
|
const [selectedDate, setSelectedDate] = useState<string>(
|
||||||
format(new Date(), "yyyy-MM-dd")
|
format(new Date(), "yyyy-MM-dd")
|
||||||
);
|
);
|
||||||
@ -94,17 +102,40 @@ export default function OperationalPlanning() {
|
|||||||
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||||
const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false);
|
const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Query per siti non coperti
|
// Query per siti non coperti (filtrati per sede e data)
|
||||||
const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
|
const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
|
||||||
queryKey: [`/api/operational-planning/uncovered-sites?date=${selectedDate}`, selectedDate],
|
queryKey: ['/api/operational-planning/uncovered-sites', selectedDate, selectedLocation],
|
||||||
enabled: !!selectedDate,
|
queryFn: async ({ queryKey }) => {
|
||||||
|
const [, date, location] = queryKey;
|
||||||
|
const res = await fetch(`/api/operational-planning/uncovered-sites?date=${date}&location=${location}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedDate && !!selectedLocation,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query per risorse (veicoli e guardie) - solo quando c'è un sito selezionato
|
// Query per risorse (veicoli e guardie) - solo quando c'è un sito selezionato
|
||||||
const { data: resourcesData, isLoading: isLoadingResources } = useQuery<ResourcesData>({
|
const { data: resourcesData, isLoading: isLoadingResources } = useQuery<ResourcesData>({
|
||||||
queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate],
|
queryKey: ['/api/operational-planning/availability', selectedDate, selectedLocation, selectedSite?.id],
|
||||||
enabled: !!selectedDate && !!selectedSite,
|
queryFn: async ({ queryKey }) => {
|
||||||
|
const [, date, location] = queryKey;
|
||||||
|
const res = await fetch(`/api/operational-planning/availability?date=${date}&location=${location}`, {
|
||||||
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!selectedDate && !!selectedLocation && !!selectedSite,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLocationChange = (location: string) => {
|
||||||
|
setSelectedLocation(location);
|
||||||
|
setSelectedSite(null);
|
||||||
|
setSelectedGuards([]);
|
||||||
|
setSelectedVehicle(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSelectedDate(e.target.value);
|
setSelectedDate(e.target.value);
|
||||||
@ -226,11 +257,25 @@ export default function OperationalPlanning() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Calendar className="h-5 w-5" />
|
<Calendar className="h-5 w-5" />
|
||||||
Seleziona Data
|
Seleziona Sede e Data
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex gap-4 items-end">
|
<div className="flex gap-4 items-end">
|
||||||
|
<div className="flex-1 max-w-xs">
|
||||||
|
<Label htmlFor="planning-location">Sede</Label>
|
||||||
|
<Select value={selectedLocation} onValueChange={handleLocationChange}>
|
||||||
|
<SelectTrigger id="planning-location" data-testid="select-planning-location" className="mt-1">
|
||||||
|
<Building2 className="h-4 w-4 mr-2" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
||||||
|
<SelectItem value="milano">Milano</SelectItem>
|
||||||
|
<SelectItem value="roma">Roma</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="flex-1 max-w-xs">
|
<div className="flex-1 max-w-xs">
|
||||||
<Label htmlFor="planning-date">Data</Label>
|
<Label htmlFor="planning-date">Data</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -423,11 +468,13 @@ export default function OperationalPlanning() {
|
|||||||
{vehicle.brand} {vehicle.model}
|
{vehicle.brand} {vehicle.model}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedVehicle === vehicle.id}
|
checked={selectedVehicle === vehicle.id}
|
||||||
onCheckedChange={() => setSelectedVehicle(vehicle.id)}
|
onCheckedChange={() => setSelectedVehicle(vehicle.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@ -481,11 +528,13 @@ export default function OperationalPlanning() {
|
|||||||
Ore sett.: {guard.availability.weeklyHours}h | Rimaste: {guard.availability.remainingWeeklyHours}h
|
Ore sett.: {guard.availability.weeklyHours}h | Rimaste: {guard.availability.remainingWeeklyHours}h
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedGuards.includes(guard.id)}
|
checked={selectedGuards.includes(guard.id)}
|
||||||
onCheckedChange={() => toggleGuardSelection(guard.id)}
|
onCheckedChange={() => toggleGuardSelection(guard.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { insertSiteSchema } from "@shared/schema";
|
import { insertSiteSchema } from "@shared/schema";
|
||||||
import { Plus, MapPin, Shield, Users, Pencil } from "lucide-react";
|
import { Plus, MapPin, Shield, Users, Pencil, Building2 } from "lucide-react";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
@ -25,6 +25,12 @@ const shiftTypeLabels: Record<string, string> = {
|
|||||||
quick_response: "Pronto Intervento",
|
quick_response: "Pronto Intervento",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const locationLabels: Record<string, string> = {
|
||||||
|
roccapiemonte: "Roccapiemonte",
|
||||||
|
milano: "Milano",
|
||||||
|
roma: "Roma",
|
||||||
|
};
|
||||||
|
|
||||||
export default function Sites() {
|
export default function Sites() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
@ -129,6 +135,7 @@ export default function Sites() {
|
|||||||
editForm.reset({
|
editForm.reset({
|
||||||
name: site.name,
|
name: site.name,
|
||||||
address: site.address,
|
address: site.address,
|
||||||
|
location: site.location,
|
||||||
shiftType: site.shiftType,
|
shiftType: site.shiftType,
|
||||||
minGuards: site.minGuards,
|
minGuards: site.minGuards,
|
||||||
requiresArmed: site.requiresArmed,
|
requiresArmed: site.requiresArmed,
|
||||||
@ -221,6 +228,29 @@ export default function Sites() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="location"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Sede Gestionale</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value || "roccapiemonte"}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger data-testid="select-location">
|
||||||
|
<SelectValue placeholder="Seleziona sede" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
||||||
|
<SelectItem value="milano">Milano</SelectItem>
|
||||||
|
<SelectItem value="roma">Roma</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="border-t pt-4 space-y-4">
|
<div className="border-t pt-4 space-y-4">
|
||||||
<p className="text-sm font-medium">Dati Contrattuali</p>
|
<p className="text-sm font-medium">Dati Contrattuali</p>
|
||||||
|
|
||||||
@ -459,6 +489,29 @@ export default function Sites() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="location"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Sede Gestionale</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value || "roccapiemonte"}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger data-testid="select-edit-location">
|
||||||
|
<SelectValue placeholder="Seleziona sede" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
||||||
|
<SelectItem value="milano">Milano</SelectItem>
|
||||||
|
<SelectItem value="roma">Roma</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="border-t pt-4 space-y-4">
|
<div className="border-t pt-4 space-y-4">
|
||||||
<p className="text-sm font-medium">Dati Contrattuali</p>
|
<p className="text-sm font-medium">Dati Contrattuali</p>
|
||||||
|
|
||||||
@ -684,9 +737,15 @@ export default function Sites() {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<CardTitle className="text-lg truncate">{site.name}</CardTitle>
|
<CardTitle className="text-lg truncate">{site.name}</CardTitle>
|
||||||
<CardDescription className="text-xs mt-1">
|
<CardDescription className="text-xs mt-1 space-y-0.5">
|
||||||
|
<div>
|
||||||
<MapPin className="h-3 w-3 inline mr-1" />
|
<MapPin className="h-3 w-3 inline mr-1" />
|
||||||
{site.address}
|
{site.address}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Building2 className="h-3 w-3 inline mr-1" />
|
||||||
|
Sede: {locationLabels[site.location]}
|
||||||
|
</div>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
BIN
database-backups/vigilanzaturni_v1.0.19_20251017_172429.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.19_20251017_172429.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
28
replit.md
28
replit.md
@ -35,6 +35,23 @@ The database includes core tables for `users`, `guards`, `certifications`, `site
|
|||||||
- Sites include service schedule fields: `serviceStartTime` and `serviceEndTime` (formato HH:MM)
|
- Sites include service schedule fields: `serviceStartTime` and `serviceEndTime` (formato HH:MM)
|
||||||
- **Contract Management**: Sites now include contract fields: `contractReference` (codice contratto), `contractStartDate`, `contractEndDate` (date validità contratto in formato YYYY-MM-DD)
|
- **Contract Management**: Sites now include contract fields: `contractReference` (codice contratto), `contractStartDate`, `contractEndDate` (date validità contratto in formato YYYY-MM-DD)
|
||||||
- Sites now reference service types via `serviceTypeId` foreign key; `shiftType` is optional and can be derived from service type
|
- Sites now reference service types via `serviceTypeId` foreign key; `shiftType` is optional and can be derived from service type
|
||||||
|
- **Multi-Location Support**: Added `location` field (enum: roccapiemonte, milano, roma) to `sites`, `guards`, and `vehicles` tables for complete multi-sede resource isolation
|
||||||
|
|
||||||
|
**Recent Features (October 17, 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
|
||||||
|
|
||||||
|
**Recent Bug Fixes (October 17, 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.
|
||||||
|
|
||||||
### API Endpoints
|
### 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.
|
Comprehensive RESTful API endpoints are provided for Authentication, Users, Guards, Sites, Shifts, and Notifications, supporting full CRUD operations with role-based access control.
|
||||||
@ -51,11 +68,12 @@ Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/n
|
|||||||
### Key Features
|
### Key Features
|
||||||
- **Dashboard Operativa**: Live KPIs (active shifts, total guards, active sites, expiring certifications) and real-time shift status.
|
- **Dashboard Operativa**: Live KPIs (active shifts, total guards, active sites, expiring certifications) and real-time shift status.
|
||||||
- **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 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) and contract management (reference code, validity period with start/end dates). Contract status is visualized with badges (active/expiring/expired) and enforces shift creation only within active contract periods.
|
- **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 Interattiva**: Three-step workflow for shift assignment:
|
- **Pianificazione Operativa Multi-Sede**: Location-aware workflow for shift assignment:
|
||||||
1. Select date → view uncovered sites with coverage status
|
1. Select sede (Roccapiemonte/Milano/Roma) → filters all subsequent data by location
|
||||||
2. Select site → view filtered resources (guards and vehicles matching requirements)
|
2. Select date → view uncovered sites with coverage status (sede-filtered)
|
||||||
3. Assign resources → create shift with atomic guard assignments and vehicle allocation
|
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).
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
100
server/routes.ts
100
server/routes.ts
@ -6,7 +6,7 @@ import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./local
|
|||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } from "@shared/schema";
|
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } 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 } from "date-fns";
|
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid } from "date-fns";
|
||||||
|
|
||||||
// Determina quale sistema auth usare basandosi sull'ambiente
|
// Determina quale sistema auth usare basandosi sull'ambiente
|
||||||
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
|
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
|
||||||
@ -538,63 +538,84 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
app.get("/api/operational-planning/availability", isAuthenticated, async (req, res) => {
|
app.get("/api/operational-planning/availability", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { getGuardAvailabilityReport } = await import("./ccnlRules");
|
const { getGuardAvailabilityReport } = await import("./ccnlRules");
|
||||||
const dateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
|
||||||
const date = new Date(dateStr + "T00:00:00.000Z");
|
// Sanitizza input: gestisce sia "2025-10-17" che "2025-10-17/2025-10-17"
|
||||||
|
const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
||||||
|
const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash
|
||||||
|
|
||||||
|
// Valida la data
|
||||||
|
const parsedDate = parseISO(normalizedDateStr);
|
||||||
|
if (!isValid(parsedDate)) {
|
||||||
|
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = format(parsedDate, "yyyy-MM-dd");
|
||||||
|
|
||||||
|
// Ottieni location dalla query (default: roccapiemonte)
|
||||||
|
const location = req.query.location as string || "roccapiemonte";
|
||||||
|
|
||||||
// Imposta inizio e fine giornata in UTC
|
// Imposta inizio e fine giornata in UTC
|
||||||
const startOfDay = new Date(dateStr + "T00:00:00.000Z");
|
const startOfDay = new Date(dateStr + "T00:00:00.000Z");
|
||||||
const endOfDay = new Date(dateStr + "T23:59:59.999Z");
|
const endOfDay = new Date(dateStr + "T23:59:59.999Z");
|
||||||
|
|
||||||
// Ottieni tutti i veicoli
|
// Ottieni tutti i veicoli della sede selezionata
|
||||||
const allVehicles = await storage.getAllVehicles();
|
const allVehicles = await storage.getAllVehicles();
|
||||||
|
const locationVehicles = allVehicles.filter(v => v.location === location);
|
||||||
|
|
||||||
// Ottieni turni del giorno per trovare veicoli assegnati
|
// Ottieni turni del giorno SOLO della sede selezionata (join con sites per filtrare per location)
|
||||||
const dayShifts = await db
|
const dayShifts = await db
|
||||||
.select()
|
.select({
|
||||||
|
shift: shifts
|
||||||
|
})
|
||||||
.from(shifts)
|
.from(shifts)
|
||||||
|
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
gte(shifts.startTime, startOfDay),
|
gte(shifts.startTime, startOfDay),
|
||||||
lte(shifts.startTime, endOfDay)
|
lte(shifts.startTime, endOfDay),
|
||||||
|
eq(sites.location, location)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mappa veicoli con disponibilità
|
// Mappa veicoli con disponibilità
|
||||||
const vehiclesWithAvailability = await Promise.all(
|
const vehiclesWithAvailability = await Promise.all(
|
||||||
allVehicles.map(async (vehicle) => {
|
locationVehicles.map(async (vehicle) => {
|
||||||
const assignedShift = dayShifts.find((shift: any) => shift.vehicleId === vehicle.id);
|
const assignedShiftRecord = dayShifts.find((s: any) => s.shift.vehicleId === vehicle.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...vehicle,
|
...vehicle,
|
||||||
isAvailable: !assignedShift,
|
isAvailable: !assignedShiftRecord,
|
||||||
assignedShift: assignedShift ? {
|
assignedShift: assignedShiftRecord ? {
|
||||||
id: assignedShift.id,
|
id: assignedShiftRecord.shift.id,
|
||||||
startTime: assignedShift.startTime,
|
startTime: assignedShiftRecord.shift.startTime,
|
||||||
endTime: assignedShift.endTime,
|
endTime: assignedShiftRecord.shift.endTime,
|
||||||
siteId: assignedShift.siteId
|
siteId: assignedShiftRecord.shift.siteId
|
||||||
} : null
|
} : null
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ottieni tutte le guardie
|
// Ottieni tutte le guardie della sede selezionata
|
||||||
const allGuards = await storage.getAllGuards();
|
const allGuards = await storage.getAllGuards();
|
||||||
|
const locationGuards = allGuards.filter(g => g.location === location);
|
||||||
|
|
||||||
// Ottieni assegnazioni turni del giorno
|
// Ottieni assegnazioni turni del giorno SOLO della sede selezionata (join con sites per filtrare per location)
|
||||||
const dayShiftAssignments = await db
|
const dayShiftAssignments = await db
|
||||||
.select()
|
.select()
|
||||||
.from(shiftAssignments)
|
.from(shiftAssignments)
|
||||||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||||
|
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
gte(shifts.startTime, startOfDay),
|
gte(shifts.startTime, startOfDay),
|
||||||
lte(shifts.startTime, endOfDay)
|
lte(shifts.startTime, endOfDay),
|
||||||
|
eq(sites.location, location)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calcola disponibilità agenti con report CCNL
|
// Calcola disponibilità agenti con report CCNL
|
||||||
const guardsWithAvailability = await Promise.all(
|
const guardsWithAvailability = await Promise.all(
|
||||||
allGuards.map(async (guard) => {
|
locationGuards.map(async (guard) => {
|
||||||
const assignedShift = dayShiftAssignments.find(
|
const assignedShift = dayShiftAssignments.find(
|
||||||
(assignment: any) => assignment.shift_assignments.guardId === guard.id
|
(assignment: any) => assignment.shift_assignments.guardId === guard.id
|
||||||
);
|
);
|
||||||
@ -654,16 +675,34 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Endpoint per ottenere siti non completamente coperti per una data
|
// Endpoint per ottenere siti non completamente coperti per una data e sede
|
||||||
app.get("/api/operational-planning/uncovered-sites", isAuthenticated, async (req, res) => {
|
app.get("/api/operational-planning/uncovered-sites", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const dateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
// Sanitizza input: gestisce sia "2025-10-17" che "2025-10-17/2025-10-17"
|
||||||
|
const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
||||||
|
const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash
|
||||||
|
|
||||||
// Ottieni tutti i siti attivi
|
// Valida la data
|
||||||
|
const parsedDate = parseISO(normalizedDateStr);
|
||||||
|
if (!isValid(parsedDate)) {
|
||||||
|
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = format(parsedDate, "yyyy-MM-dd");
|
||||||
|
|
||||||
|
// Ottieni location dalla query (default: roccapiemonte)
|
||||||
|
const location = req.query.location as string || "roccapiemonte";
|
||||||
|
|
||||||
|
// Ottieni tutti i siti attivi della sede selezionata
|
||||||
const allSites = await db
|
const allSites = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(eq(sites.isActive, true));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.isActive, true),
|
||||||
|
eq(sites.location, location)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Filtra siti con contratto valido per la data selezionata
|
// Filtra siti con contratto valido per la data selezionata
|
||||||
const sitesWithValidContract = allSites.filter((site: any) => {
|
const sitesWithValidContract = allSites.filter((site: any) => {
|
||||||
@ -686,18 +725,27 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
return selectedDate >= contractStart && selectedDate <= contractEnd;
|
return selectedDate >= contractStart && selectedDate <= contractEnd;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ottieni turni del giorno con assegnazioni (usando SQL date comparison)
|
// Ottieni turni del giorno con assegnazioni SOLO della sede selezionata
|
||||||
|
const startOfDayDate = new Date(dateStr);
|
||||||
|
startOfDayDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const endOfDayDate = new Date(dateStr);
|
||||||
|
endOfDayDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
const dayShifts = await db
|
const dayShifts = await db
|
||||||
.select({
|
.select({
|
||||||
shift: shifts,
|
shift: shifts,
|
||||||
assignmentCount: sql<number>`count(${shiftAssignments.id})::int`
|
assignmentCount: sql<number>`count(${shiftAssignments.id})::int`
|
||||||
})
|
})
|
||||||
.from(shifts)
|
.from(shifts)
|
||||||
|
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||||||
.leftJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId))
|
.leftJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
sql`DATE(${shifts.startTime}) = ${dateStr}`,
|
gte(shifts.startTime, startOfDayDate),
|
||||||
ne(shifts.status, "cancelled")
|
lte(shifts.startTime, endOfDayDate),
|
||||||
|
ne(shifts.status, "cancelled"),
|
||||||
|
eq(sites.location, location)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.groupBy(shifts.id);
|
.groupBy(shifts.id);
|
||||||
|
|||||||
10
version.json
10
version.json
@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.18",
|
"version": "1.0.19",
|
||||||
"lastUpdate": "2025-10-17T15:56:56.628Z",
|
"lastUpdate": "2025-10-17T17:24:45.675Z",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
{
|
||||||
|
"version": "1.0.19",
|
||||||
|
"date": "2025-10-17",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.19"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.18",
|
"version": "1.0.18",
|
||||||
"date": "2025-10-17",
|
"date": "2025-10-17",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user