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 = {