Compare commits

..

3 Commits

Author SHA1 Message Date
Marco Lanzara
9bc4ed03d8 🚀 Release v1.0.23
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.23_20251018_092039.sql.gz
- Data: 2025-10-18 09:20:55
2025-10-18 09:20:55 +00:00
marco370
8068a808de Add ability to create multi-day shifts from planning interface
Update client to allow creating multi-day shifts directly from the General Planning dialog, and fix the `apiRequest` parameter order in the mutation.

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/zGfvPmX
2025-10-18 09:20:23 +00:00
marco370
eb3e6c4aac Add functionality to create shifts directly from the planning view
Introduces API endpoints and client-side logic for fetching guard availability and creating multi-day shifts from the general planning interface.

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/DrGaAl6
2025-10-18 08:49:02 +00:00
8 changed files with 412 additions and 8 deletions

View File

@ -1,12 +1,14 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation } from "@tanstack/react-query";
import { format, startOfWeek, addWeeks } from "date-fns";
import { it } from "date-fns/locale";
import { useLocation } from "wouter";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
@ -17,6 +19,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import type { GuardAvailability } from "@shared/schema";
interface GuardWithHours {
guardId: string;
@ -65,9 +70,14 @@ interface GeneralPlanningResponse {
export default function GeneralPlanning() {
const [, navigate] = useLocation();
const { toast } = useToast();
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
const [selectedCell, setSelectedCell] = useState<{ siteId: string; siteName: string; date: string; data: SiteData } | null>(null);
// Form state per creazione turno
const [selectedGuardId, setSelectedGuardId] = useState<string>("");
const [days, setDays] = useState<number>(1);
// Query per dati planning settimanale
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
@ -81,6 +91,61 @@ export default function GeneralPlanning() {
},
});
// Query per guardie disponibili (solo quando dialog è aperto)
const { data: availableGuards, isLoading: isLoadingGuards } = useQuery<GuardAvailability[]>({
queryKey: ["/api/guards/availability", format(weekStart, "yyyy-MM-dd"), selectedCell?.siteId, selectedLocation],
queryFn: async () => {
if (!selectedCell) return [];
const response = await fetch(
`/api/guards/availability?weekStart=${format(weekStart, "yyyy-MM-dd")}&siteId=${selectedCell.siteId}&location=${selectedLocation}`
);
if (!response.ok) throw new Error("Failed to fetch guards availability");
return response.json();
},
enabled: !!selectedCell, // Query attiva solo se dialog è aperto
});
// Mutation per creare turno multi-giorno
const createShiftMutation = useMutation({
mutationFn: async (data: { siteId: string; startDate: string; days: number; guardId: string }) => {
return apiRequest("POST", "/api/general-planning/shifts", data);
},
onSuccess: () => {
// Invalida cache planning generale
queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
toast({
title: "Turno creato",
description: "Il turno è stato creato con successo",
});
// Reset form e chiudi dialog
setSelectedGuardId("");
setDays(1);
setSelectedCell(null);
},
onError: (error: any) => {
toast({
title: "Errore",
description: error.message || "Impossibile creare il turno",
variant: "destructive",
});
},
});
// Handler per submit form creazione turno
const handleCreateShift = () => {
if (!selectedCell || !selectedGuardId) return;
createShiftMutation.mutate({
siteId: selectedCell.siteId,
startDate: selectedCell.date,
days,
guardId: selectedGuardId,
});
};
// Navigazione settimana
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1));
@ -459,6 +524,90 @@ export default function GeneralPlanning() {
<p>Nessun turno pianificato per questa data</p>
</div>
)}
{/* Form creazione nuovo turno */}
<div className="border-t pt-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold">
<Plus className="h-4 w-4" />
Crea Nuovo Turno
</div>
<div className="grid gap-4">
{/* Select guardia disponibile */}
<div className="space-y-2">
<Label htmlFor="guard-select">Guardia Disponibile</Label>
{isLoadingGuards ? (
<Skeleton className="h-10 w-full" />
) : (
<Select
value={selectedGuardId}
onValueChange={setSelectedGuardId}
disabled={createShiftMutation.isPending}
>
<SelectTrigger id="guard-select" data-testid="select-guard">
<SelectValue placeholder="Seleziona guardia..." />
</SelectTrigger>
<SelectContent>
{availableGuards && availableGuards.length > 0 ? (
availableGuards.map((guard) => (
<SelectItem key={guard.guardId} value={guard.guardId}>
{guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili
</SelectItem>
))
) : (
<SelectItem value="no-guards" disabled>
Nessuna guardia disponibile
</SelectItem>
)}
</SelectContent>
</Select>
)}
{availableGuards && availableGuards.length > 0 && selectedGuardId && (
<p className="text-xs text-muted-foreground">
{(() => {
const guard = availableGuards.find(g => g.guardId === selectedGuardId);
return guard ? `Ore assegnate: ${guard.weeklyHoursAssigned}h / ${guard.weeklyHoursMax}h (rimangono ${guard.weeklyHoursRemaining}h)` : "";
})()}
</p>
)}
</div>
{/* Input numero giorni */}
<div className="space-y-2">
<Label htmlFor="days-input">Numero Giorni Consecutivi</Label>
<Input
id="days-input"
type="number"
min={1}
max={7}
value={days}
onChange={(e) => setDays(Math.max(1, Math.min(7, parseInt(e.target.value) || 1)))}
disabled={createShiftMutation.isPending}
data-testid="input-days"
/>
<p className="text-xs text-muted-foreground">
Il turno verrà creato a partire da {selectedCell && format(new Date(selectedCell.date), "dd/MM/yyyy")} per {days} {days === 1 ? "giorno" : "giorni"}
</p>
</div>
{/* Bottone crea turno */}
<Button
onClick={handleCreateShift}
disabled={!selectedGuardId || createShiftMutation.isPending || (availableGuards && availableGuards.length === 0)}
data-testid="button-create-shift"
className="w-full"
>
{createShiftMutation.isPending ? (
"Creazione in corso..."
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Crea Turno ({days} {days === 1 ? "giorno" : "giorni"})
</>
)}
</Button>
</div>
</div>
</div>
)}

View File

@ -54,15 +54,23 @@ The database includes core tables for `users`, `guards`, `certifications`, `site
- 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, 2025)**:
**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.

View File

@ -4,7 +4,7 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
import { db } from "./db";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes } from "@shared/schema";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema } 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, isValid, addDays } from "date-fns";
@ -294,6 +294,35 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Get guards availability for general planning
app.get("/api/guards/availability", isAuthenticated, async (req, res) => {
try {
const { weekStart, siteId, location } = req.query;
if (!weekStart || !siteId || !location) {
return res.status(400).json({
message: "Missing required parameters: weekStart, siteId, location"
});
}
const weekStartDate = parseISO(weekStart as string);
if (!isValid(weekStartDate)) {
return res.status(400).json({ message: "Invalid weekStart date format" });
}
const availability = await storage.getGuardsAvailability(
weekStartDate,
siteId as string,
location as string
);
res.json(availability);
} catch (error) {
console.error("Error fetching guards availability:", error);
res.status(500).json({ message: "Failed to fetch guards availability" });
}
});
// ============= VEHICLE ROUTES =============
app.get("/api/vehicles", isAuthenticated, async (req, res) => {
try {
@ -1042,6 +1071,105 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Create multi-day shift from general planning
app.post("/api/general-planning/shifts", isAuthenticated, async (req, res) => {
try {
// Validate request body
const validationResult = createMultiDayShiftSchema.safeParse(req.body);
if (!validationResult.success) {
return res.status(400).json({
message: "Invalid request data",
errors: validationResult.error.errors
});
}
const { siteId, startDate, days, guardId, shiftType } = validationResult.data;
// Get site to check contract and service details
const site = await storage.getSite(siteId);
if (!site) {
return res.status(404).json({ message: "Site not found" });
}
// Get guard to verify it exists
const guard = await storage.getGuard(guardId);
if (!guard) {
return res.status(404).json({ message: "Guard not found" });
}
// Pre-validate all dates are within contract period
const startDateParsed = parseISO(startDate);
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
const shiftDate = addDays(startDateParsed, dayOffset);
const shiftDateStr = format(shiftDate, "yyyy-MM-dd");
if (site.contractStartDate && site.contractEndDate) {
const contractStart = new Date(site.contractStartDate);
const contractEnd = new Date(site.contractEndDate);
if (shiftDate < contractStart || shiftDate > contractEnd) {
return res.status(400).json({
message: `Cannot create shift for ${shiftDateStr}: outside contract period`
});
}
}
}
// Create shifts atomically in a transaction
const createdShifts = await db.transaction(async (tx) => {
const createdShiftsInTx = [];
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
const shiftDate = addDays(startDateParsed, dayOffset);
// Use site service schedule or default 24h
const serviceStart = site.serviceStartTime || "00:00";
const serviceEnd = site.serviceEndTime || "23:59";
const [startHour, startMin] = serviceStart.split(":").map(Number);
const [endHour, endMin] = serviceEnd.split(":").map(Number);
const shiftStart = new Date(shiftDate);
shiftStart.setHours(startHour, startMin, 0, 0);
const shiftEnd = new Date(shiftDate);
shiftEnd.setHours(endHour, endMin, 0, 0);
// If service ends before it starts, it spans midnight (add 1 day to end)
if (shiftEnd <= shiftStart) {
shiftEnd.setDate(shiftEnd.getDate() + 1);
}
// Create shift in transaction
const [shift] = await tx.insert(shifts).values({
siteId: site.id,
startTime: shiftStart,
endTime: shiftEnd,
shiftType: shiftType || site.shiftType || "fixed_post",
status: "planned",
}).returning();
// Create shift assignment in transaction
await tx.insert(shiftAssignments).values({
shiftId: shift.id,
guardId: guard.id,
});
createdShiftsInTx.push(shift);
}
return createdShiftsInTx;
});
res.json({
message: `Created ${createdShifts.length} shifts`,
shifts: createdShifts
});
} catch (error) {
console.error("Error creating multi-day shifts:", error);
res.status(500).json({ message: "Failed to create shifts", error: String(error) });
}
});
// ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => {
try {

View File

@ -54,9 +54,11 @@ import {
type InsertServiceType,
type CcnlSetting,
type InsertCcnlSetting,
type GuardAvailability,
} from "@shared/schema";
import { db } from "./db";
import { eq, and, gte, lte, desc, or } from "drizzle-orm";
import { eq, and, gte, lte, desc, or, sql as rawSql } from "drizzle-orm";
import { addDays, differenceInHours, parseISO, formatISO } from "date-fns";
export interface IStorage {
// User operations (Replit Auth required)
@ -153,6 +155,9 @@ export interface IStorage {
getCcnlSetting(key: string): Promise<CcnlSetting | undefined>;
upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting>;
deleteCcnlSetting(key: string): Promise<void>;
// General Planning operations
getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]>;
}
export class DatabaseStorage implements IStorage {
@ -181,7 +186,9 @@ export class DatabaseStorage implements IStorage {
.update(users)
.set({
...(userData.email && { email: userData.email }),
...(userData.name && { name: userData.name }),
...(userData.firstName && { firstName: userData.firstName }),
...(userData.lastName && { lastName: userData.lastName }),
...(userData.profileImageUrl && { profileImageUrl: userData.profileImageUrl }),
...(userData.role && { role: userData.role }),
updatedAt: new Date(),
})
@ -660,6 +667,87 @@ export class DatabaseStorage implements IStorage {
async deleteCcnlSetting(key: string): Promise<void> {
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
}
// General Planning operations
async getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]> {
const weekEnd = addDays(weekStart, 6);
// Get max weekly hours from CCNL settings (default 45h)
const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours');
const maxWeeklyHours = maxHoursSetting ? Number(maxHoursSetting.value) : 45;
// Get site to check requirements
const site = await this.getSite(siteId);
if (!site) {
return [];
}
// Get all guards from the same location
const allGuards = await db
.select()
.from(guards)
.where(eq(guards.location, location as any));
// Filter guards by site requirements
const eligibleGuards = allGuards.filter(guard => {
if (site.requiresArmed && !guard.isArmed) return false;
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
return true;
});
// Calculate weekly hours for each guard
const guardsWithHours: GuardAvailability[] = [];
for (const guard of eligibleGuards) {
// Get all shift assignments for this guard in the week
const assignments = await db
.select({
shiftId: shiftAssignments.shiftId,
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)
)
);
// Calculate total hours assigned
let weeklyHoursAssigned = 0;
for (const assignment of assignments) {
const hours = differenceInHours(assignment.endTime, assignment.startTime);
weeklyHoursAssigned += hours;
}
const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned;
// Only include guards with remaining hours
if (weeklyHoursRemaining > 0) {
const user = guard.userId ? await this.getUser(guard.userId) : undefined;
const guardName = user
? `${user.firstName || ''} ${user.lastName || ''}`.trim() || 'N/A'
: 'N/A';
guardsWithHours.push({
guardId: guard.id,
guardName,
badgeNumber: guard.badgeNumber,
weeklyHoursRemaining,
weeklyHoursAssigned,
weeklyHoursMax: maxWeeklyHours,
});
}
}
// Sort by remaining hours (descending)
guardsWithHours.sort((a, b) => b.weeklyHoursRemaining - a.weeklyHoursRemaining);
return guardsWithHours;
}
}
export const storage = new DatabaseStorage();

View File

@ -837,3 +837,28 @@ export type AbsenceWithDetails = Absence & {
shift: Shift;
})[];
};
// ============= DTOs FOR GENERAL PLANNING =============
// DTO per disponibilità guardia nella settimana
export const guardAvailabilitySchema = z.object({
guardId: z.string(),
guardName: z.string(),
badgeNumber: z.string(),
weeklyHoursRemaining: z.number(),
weeklyHoursAssigned: z.number(),
weeklyHoursMax: z.number(),
});
export type GuardAvailability = z.infer<typeof guardAvailabilitySchema>;
// DTO per creazione turno multi-giorno dal Planning Generale
export const createMultiDayShiftSchema = z.object({
siteId: z.string(),
startDate: z.string(), // YYYY-MM-DD
days: z.number().min(1).max(7),
guardId: z.string(),
shiftType: z.enum(["fixed_post", "patrol", "night_inspection", "quick_response"]).optional(),
});
export type CreateMultiDayShiftRequest = z.infer<typeof createMultiDayShiftSchema>;

View File

@ -1,7 +1,13 @@
{
"version": "1.0.22",
"lastUpdate": "2025-10-18T08:27:14.297Z",
"version": "1.0.23",
"lastUpdate": "2025-10-18T09:20:55.191Z",
"changelog": [
{
"version": "1.0.23",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.23"
},
{
"version": "1.0.22",
"date": "2025-10-18",