From 4a1c21455b2d5905375fa913714a3e97dbc279f4 Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Fri, 17 Oct 2025 07:39:19 +0000 Subject: [PATCH] Implement contract rules for shift assignments Add CCNL (Contratto Collettivo Nazionale di Lavoro) validation logic on the server-side to check shift durations against defined contract parameters. This includes updating the client to handle potential violations and display them to the user via an alert dialog. The server now exposes a new API endpoint (/api/shifts/validate-ccnl) for this validation. Additionally, seeding script has been updated to include default contract parameters. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/IdDfihe --- client/src/pages/shifts.tsx | 88 +++++++++++++++++-- server/routes.ts | 169 +++++++++++++++++++++++++++++++++++- server/seed.ts | 32 ++++++- 3 files changed, 279 insertions(+), 10 deletions(-) diff --git a/client/src/pages/shifts.tsx b/client/src/pages/shifts.tsx index 248250d..3f478d4 100644 --- a/client/src/pages/shifts.tsx +++ b/client/src/pages/shifts.tsx @@ -4,6 +4,8 @@ import { ShiftWithDetails, InsertShift, Site, GuardWithCertifications } from "@s import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useForm } from "react-hook-form"; @@ -25,6 +27,8 @@ export default function Shifts() { const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false); const [editingShift, setEditingShift] = useState(null); const [selectedLocation, setSelectedLocation] = useState("all"); + const [ccnlViolations, setCcnlViolations] = useState>([]); + const [pendingAssignment, setPendingAssignment] = useState<{shiftId: string, guardId: string} | null>(null); const { data: shifts, isLoading: shiftsLoading } = useQuery({ queryKey: ["/api/shifts"], @@ -100,6 +104,47 @@ export default function Shifts() { createMutation.mutate(data); }; + // Validate CCNL before assignment + const validateCcnl = async (shiftId: string, guardId: string) => { + const shift = shifts?.find(s => s.id === shiftId); + if (!shift) return { violations: [] }; + + try { + const response = await apiRequest("POST", "/api/shifts/validate-ccnl", { + guardId, + shiftStartTime: shift.startTime, + shiftEndTime: shift.endTime + }); + return response as { violations: Array<{type: string, message: string}> }; + } catch (error) { + console.error("Errore validazione CCNL:", error); + return { violations: [] }; + } + }; + + const handleAssignGuard = async (guardId: string) => { + if (!selectedShift) return; + + // Validate CCNL + const { violations } = await validateCcnl(selectedShift.id, guardId); + + if (violations.length > 0) { + setCcnlViolations(violations); + setPendingAssignment({ shiftId: selectedShift.id, guardId }); + } else { + // No violations, assign directly + assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId }); + } + }; + + const confirmAssignment = () => { + if (pendingAssignment) { + assignGuardMutation.mutate(pendingAssignment); + setPendingAssignment(null); + setCcnlViolations([]); + } + }; + const assignGuardMutation = useMutation({ mutationFn: async ({ shiftId, guardId }: { shiftId: string; guardId: string }) => { return await apiRequest("POST", "/api/shift-assignments", { shiftId, guardId }); @@ -180,12 +225,6 @@ export default function Shifts() { }, }); - const handleAssignGuard = (guardId: string) => { - if (selectedShift) { - assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId }); - } - }; - const handleRemoveAssignment = (assignmentId: string) => { removeAssignmentMutation.mutate(assignmentId); }; @@ -719,6 +758,43 @@ export default function Shifts() { + + {/* CCNL Violations Alert Dialog */} + !open && setPendingAssignment(null)}> + + + ⚠️ Violazioni CCNL Rilevate + + L'assegnazione di questa guardia al turno viola i seguenti limiti contrattuali: + + +
+ {ccnlViolations.map((violation, index) => ( + + {violation.message} + + ))} +
+ + { + setPendingAssignment(null); + setCcnlViolations([]); + }} + > + Annulla + + + Procedi Comunque + + +
+
); } diff --git a/server/routes.ts b/server/routes.ts index fa2ff9a..4164d5a 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -4,9 +4,9 @@ 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 } from "@shared/schema"; -import { eq } from "drizzle-orm"; -import { differenceInDays } from "date-fns"; +import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } from "@shared/schema"; +import { eq, and, gte, lte, desc, asc } from "drizzle-orm"; +import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO } 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; @@ -638,6 +638,169 @@ export async function registerRoutes(app: Express): Promise { } }); + // ============= CCNL VALIDATION ============= + app.post("/api/shifts/validate-ccnl", isAuthenticated, async (req, res) => { + try { + const { guardId, shiftStartTime, shiftEndTime } = req.body; + + if (!guardId || !shiftStartTime || !shiftEndTime) { + return res.status(400).json({ message: "Missing required fields" }); + } + + const startTime = new Date(shiftStartTime); + const endTime = new Date(shiftEndTime); + + // Load CCNL parameters + const params = await db.select().from(contractParameters).limit(1); + if (params.length === 0) { + return res.status(500).json({ message: "CCNL parameters not found" }); + } + const ccnl = params[0]; + + const violations: Array<{type: string, message: string}> = []; + + // Calculate shift hours precisely (in minutes, then convert) + const shiftMinutes = differenceInMinutes(endTime, startTime); + const shiftHours = Math.round((shiftMinutes / 60) * 100) / 100; // Round to 2 decimals + + // Check max daily hours + if (shiftHours > ccnl.maxHoursPerDay) { + violations.push({ + type: "MAX_DAILY_HOURS", + message: `Turno di ${shiftHours}h supera il limite giornaliero di ${ccnl.maxHoursPerDay}h` + }); + } + + // Get guard's shifts for the week (overlapping with week window) + const weekStart = startOfWeek(startTime, { weekStartsOn: 1 }); // Monday + const weekEnd = endOfWeek(startTime, { weekStartsOn: 1 }); + + const weekShifts = await db + .select() + .from(shiftAssignments) + .innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId)) + .where( + and( + eq(shiftAssignments.guardId, guardId), + // Shift overlaps week if: (shift_start <= week_end) AND (shift_end >= week_start) + lte(shifts.startTime, weekEnd), + gte(shifts.endTime, weekStart) + ) + ); + + // Calculate weekly hours precisely (only overlap with week window) + let weeklyMinutes = 0; + + // Add overlap of new shift with week (clamp to 0 if outside window) + const newShiftStart = startTime < weekStart ? weekStart : startTime; + const newShiftEnd = endTime > weekEnd ? weekEnd : endTime; + weeklyMinutes += Math.max(0, differenceInMinutes(newShiftEnd, newShiftStart)); + + // Add overlap of existing shifts with week (clamp to 0 if outside window) + for (const { shifts: shift } of weekShifts) { + const shiftStart = shift.startTime < weekStart ? weekStart : shift.startTime; + const shiftEnd = shift.endTime > weekEnd ? weekEnd : shift.endTime; + weeklyMinutes += Math.max(0, differenceInMinutes(shiftEnd, shiftStart)); + } + const weeklyHours = Math.round((weeklyMinutes / 60) * 100) / 100; + + if (weeklyHours > ccnl.maxHoursPerWeek) { + violations.push({ + type: "MAX_WEEKLY_HOURS", + message: `Ore settimanali (${weeklyHours}h) superano il limite di ${ccnl.maxHoursPerWeek}h` + }); + } + + // Check consecutive days (chronological order ASC, only past shifts before this one) + const pastShifts = await db + .select() + .from(shiftAssignments) + .innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId)) + .where( + and( + eq(shiftAssignments.guardId, guardId), + lte(shifts.startTime, startTime) + ) + ) + .orderBy(asc(shifts.startTime)); + + // Build consecutive days map + const shiftDays = new Set(); + for (const { shifts: shift } of pastShifts) { + shiftDays.add(startOfDay(shift.startTime).toISOString()); + } + shiftDays.add(startOfDay(startTime).toISOString()); + + // Count consecutive days backwards from new shift + let consecutiveDays = 0; + let checkDate = startOfDay(startTime); + while (shiftDays.has(checkDate.toISOString())) { + consecutiveDays++; + checkDate = new Date(checkDate); + checkDate.setDate(checkDate.getDate() - 1); + } + + if (consecutiveDays > 6) { + violations.push({ + type: "MAX_CONSECUTIVE_DAYS", + message: `${consecutiveDays} giorni consecutivi superano il limite di 6 giorni` + }); + } + + // Check for overlapping shifts (guard already assigned to another shift at same time) + // Overlap if: (existing_start < new_end) AND (existing_end > new_start) + // Note: Use strict inequalities to allow back-to-back shifts (end == start is OK) + const allAssignments = await db + .select() + .from(shiftAssignments) + .innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId)) + .where(eq(shiftAssignments.guardId, guardId)); + + const overlappingShifts = allAssignments.filter(({ shifts: shift }: any) => { + return shift.startTime < endTime && shift.endTime > startTime; + }); + + if (overlappingShifts.length > 0) { + violations.push({ + type: "OVERLAPPING_SHIFT", + message: `Guardia già assegnata a un turno sovrapposto in questo orario` + }); + } + + // Check rest hours (only last shift that ended before this one) + const lastCompletedShifts = await db + .select() + .from(shiftAssignments) + .innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId)) + .where( + and( + eq(shiftAssignments.guardId, guardId), + lte(shifts.endTime, startTime) + ) + ) + .orderBy(desc(shifts.endTime)) + .limit(1); + + if (lastCompletedShifts.length > 0) { + const lastShift = lastCompletedShifts[0].shifts; + const restMinutes = differenceInMinutes(startTime, lastShift.endTime); + const restHours = Math.round((restMinutes / 60) * 100) / 100; + + if (restHours < ccnl.minDailyRestHours) { + violations.push({ + type: "MIN_REST_HOURS", + message: `Riposo di ${restHours}h inferiore al minimo di ${ccnl.minDailyRestHours}h` + }); + } + } + + res.json({ violations, ccnlParams: ccnl }); + } catch (error) { + console.error("Error validating CCNL:", error); + res.status(500).json({ message: "Failed to validate CCNL" }); + } + }); + // ============= SHIFT ASSIGNMENT ROUTES ============= app.post("/api/shift-assignments", isAuthenticated, async (req, res) => { try { diff --git a/server/seed.ts b/server/seed.ts index 31a86cb..bf1a153 100644 --- a/server/seed.ts +++ b/server/seed.ts @@ -1,11 +1,41 @@ import { db } from "./db"; -import { users, guards, sites, vehicles } from "@shared/schema"; +import { users, guards, sites, vehicles, contractParameters } from "@shared/schema"; import { eq } from "drizzle-orm"; import bcrypt from "bcrypt"; async function seed() { console.log("🌱 Avvio seed database multi-sede..."); + // Create CCNL contract parameters + console.log("📋 Creazione parametri contrattuali CCNL..."); + const existingParams = await db.select().from(contractParameters).limit(1); + + if (existingParams.length === 0) { + await db.insert(contractParameters).values({ + contractType: "CCNL Vigilanza Privata", + maxHoursPerDay: 9, + maxOvertimePerDay: 2, + maxHoursPerWeek: 48, + maxOvertimePerWeek: 8, + minDailyRestHours: 11, + minDailyRestHoursReduced: 9, + maxDailyRestReductionsPerMonth: 3, + maxDailyRestReductionsPerYear: 20, + minWeeklyRestHours: 24, + maxNightHoursPerWeek: 40, + pauseMinutesIfOver6Hours: 30, + holidayPayIncrease: 30, + nightPayIncrease: 15, + overtimePayIncrease: 20, + mealVoucherEnabled: true, + mealVoucherAfterHours: 6, + mealVoucherAmount: 8 + }); + console.log(" ✓ Parametri CCNL creati"); + } else { + console.log(" ✓ Parametri CCNL già esistenti"); + } + // Locations const locations = ["roccapiemonte", "milano", "roma"] as const; const locationNames = {