Compare commits

...

5 Commits

Author SHA1 Message Date
Marco Lanzara
f0c0321d1a 🚀 Release v1.0.19
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.19_20251017_172429.sql.gz
- Data: 2025-10-17 17:24:45
2025-10-17 17:24:45 +00:00
marco370
6f6fb4f90c Add basic user authentication and authorization to the system
Implement JWT authentication and role-based authorization middleware, defining user roles and permissions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/5GnGQQ0
2025-10-17 17:21:13 +00:00
marco370
eb2ccab920 Add multi-location support for operational planning
Introduce location filtering to operational planning, site management, and resource availability queries. This includes backend route modifications to handle location parameters and frontend updates for location selection and display.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/5GnGQQ0
2025-10-17 17:20:41 +00:00
marco370
121206a492 Improve operational planning by fixing date handling and selection logic
Fixes issues with date validation and event propagation in operational planning, and updates resource query keys for better data fetching.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/09WwRvv
2025-10-17 16:36:09 +00:00
marco370
1edc335ca6 Improve date filtering for daily shift assignments
Update SQL queries to use date range comparisons for shift start times, replacing direct date string matching.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/BjRzszS
2025-10-17 16:00:28 +00:00
8 changed files with 233 additions and 57 deletions

View File

@ -19,10 +19,6 @@ externalPort = 80
localPort = 33035
externalPort = 3001
[[ports]]
localPort = 37125
externalPort = 4200
[[ports]]
localPort = 41343
externalPort = 3000

View File

@ -9,7 +9,8 @@ import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
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 { it } from "date-fns/locale";
import { useToast } from "@/hooks/use-toast";
@ -84,8 +85,15 @@ interface ResourcesData {
guards: Guard[];
}
const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma",
};
export default function OperationalPlanning() {
const { toast } = useToast();
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte"); // Sede selezionata (primo step)
const [selectedDate, setSelectedDate] = useState<string>(
format(new Date(), "yyyy-MM-dd")
);
@ -94,17 +102,40 @@ export default function OperationalPlanning() {
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
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>({
queryKey: [`/api/operational-planning/uncovered-sites?date=${selectedDate}`, selectedDate],
enabled: !!selectedDate,
queryKey: ['/api/operational-planning/uncovered-sites', selectedDate, selectedLocation],
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
const { data: resourcesData, isLoading: isLoadingResources } = useQuery<ResourcesData>({
queryKey: [`/api/operational-planning/availability?date=${selectedDate}`, selectedDate],
enabled: !!selectedDate && !!selectedSite,
queryKey: ['/api/operational-planning/availability', selectedDate, selectedLocation, selectedSite?.id],
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>) => {
setSelectedDate(e.target.value);
@ -226,11 +257,25 @@ export default function OperationalPlanning() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Seleziona Data
Seleziona Sede e Data
</CardTitle>
</CardHeader>
<CardContent>
<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">
<Label htmlFor="planning-date">Data</Label>
<Input
@ -423,11 +468,13 @@ export default function OperationalPlanning() {
{vehicle.brand} {vehicle.model}
</p>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedVehicle === vehicle.id}
onCheckedChange={() => setSelectedVehicle(vehicle.id)}
/>
</div>
</div>
</CardContent>
</Card>
))}
@ -481,11 +528,13 @@ export default function OperationalPlanning() {
Ore sett.: {guard.availability.weeklyHours}h | Rimaste: {guard.availability.remainingWeeklyHours}h
</p>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedGuards.includes(guard.id)}
onCheckedChange={() => toggleGuardSelection(guard.id)}
/>
</div>
</div>
</CardContent>
</Card>
))}

View File

@ -11,7 +11,7 @@ import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
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 { useToast } from "@/hooks/use-toast";
import { StatusBadge } from "@/components/status-badge";
@ -25,6 +25,12 @@ const shiftTypeLabels: Record<string, string> = {
quick_response: "Pronto Intervento",
};
const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma",
};
export default function Sites() {
const { toast } = useToast();
const [isDialogOpen, setIsDialogOpen] = useState(false);
@ -129,6 +135,7 @@ export default function Sites() {
editForm.reset({
name: site.name,
address: site.address,
location: site.location,
shiftType: site.shiftType,
minGuards: site.minGuards,
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">
<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">
<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-1 min-w-0">
<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" />
{site.address}
</div>
<div>
<Building2 className="h-3 w-3 inline mr-1" />
Sede: {locationLabels[site.location]}
</div>
</CardDescription>
</div>
<div className="flex items-center gap-2">

View File

@ -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)
- **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
- **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
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
- **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 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.
- **Pianificazione Operativa Interattiva**: Three-step workflow for shift assignment:
1. Select date → view uncovered sites with coverage status
2. Select site → view filtered resources (guards and vehicles matching requirements)
3. Assign resources → create shift with atomic guard assignments and vehicle allocation
- **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.

View File

@ -6,7 +6,7 @@ import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./local
import { db } from "./db";
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 { 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
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) => {
try {
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
const startOfDay = new Date(dateStr + "T00:00:00.000Z");
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 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
.select()
.select({
shift: shifts
})
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.where(
and(
gte(shifts.startTime, startOfDay),
lte(shifts.startTime, endOfDay)
lte(shifts.startTime, endOfDay),
eq(sites.location, location)
)
);
// Mappa veicoli con disponibilità
const vehiclesWithAvailability = await Promise.all(
allVehicles.map(async (vehicle) => {
const assignedShift = dayShifts.find((shift: any) => shift.vehicleId === vehicle.id);
locationVehicles.map(async (vehicle) => {
const assignedShiftRecord = dayShifts.find((s: any) => s.shift.vehicleId === vehicle.id);
return {
...vehicle,
isAvailable: !assignedShift,
assignedShift: assignedShift ? {
id: assignedShift.id,
startTime: assignedShift.startTime,
endTime: assignedShift.endTime,
siteId: assignedShift.siteId
isAvailable: !assignedShiftRecord,
assignedShift: assignedShiftRecord ? {
id: assignedShiftRecord.shift.id,
startTime: assignedShiftRecord.shift.startTime,
endTime: assignedShiftRecord.shift.endTime,
siteId: assignedShiftRecord.shift.siteId
} : null
};
})
);
// Ottieni tutte le guardie
// Ottieni tutte le guardie della sede selezionata
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
.select()
.from(shiftAssignments)
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
.innerJoin(sites, eq(shifts.siteId, sites.id))
.where(
and(
gte(shifts.startTime, startOfDay),
lte(shifts.startTime, endOfDay)
lte(shifts.startTime, endOfDay),
eq(sites.location, location)
)
);
// Calcola disponibilità agenti con report CCNL
const guardsWithAvailability = await Promise.all(
allGuards.map(async (guard) => {
locationGuards.map(async (guard) => {
const assignedShift = dayShiftAssignments.find(
(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) => {
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
.select()
.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
const sitesWithValidContract = allSites.filter((site: any) => {
@ -686,18 +725,27 @@ export async function registerRoutes(app: Express): Promise<Server> {
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
.select({
shift: shifts,
assignmentCount: sql<number>`count(${shiftAssignments.id})::int`
})
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.leftJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
sql`DATE(${shifts.startTime}) = ${dateStr}`,
ne(shifts.status, "cancelled")
gte(shifts.startTime, startOfDayDate),
lte(shifts.startTime, endOfDayDate),
ne(shifts.status, "cancelled"),
eq(sites.location, location)
)
)
.groupBy(shifts.id);

View File

@ -1,7 +1,13 @@
{
"version": "1.0.18",
"lastUpdate": "2025-10-17T15:56:56.628Z",
"version": "1.0.19",
"lastUpdate": "2025-10-17T17:24:45.675Z",
"changelog": [
{
"version": "1.0.19",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.19"
},
{
"version": "1.0.18",
"date": "2025-10-17",