diff --git a/attached_assets/immagine_1761064454333.png b/attached_assets/immagine_1761064454333.png new file mode 100644 index 0000000..f68d48f Binary files /dev/null and b/attached_assets/immagine_1761064454333.png differ diff --git a/client/src/pages/general-planning.tsx b/client/src/pages/general-planning.tsx index fba8000..97e8bc1 100644 --- a/client/src/pages/general-planning.tsx +++ b/client/src/pages/general-planning.tsx @@ -95,6 +95,7 @@ export default function GeneralPlanning() { const [startTime, setStartTime] = useState("06:00"); const [durationHours, setDurationHours] = useState(8); const [consecutiveDays, setConsecutiveDays] = useState(1); + const [showOvertimeGuards, setShowOvertimeGuards] = useState(false); // Query per dati planning settimanale const { data: planningData, isLoading } = useQuery({ @@ -517,7 +518,7 @@ export default function GeneralPlanning() { setDurationHours(8); setConsecutiveDays(1); }}> - + @@ -529,7 +530,7 @@ export default function GeneralPlanning() { {selectedCell && ( -
+
{/* Info turni */}
@@ -542,27 +543,6 @@ export default function GeneralPlanning() {
- {/* Guardie assegnate */} - {selectedCell.data.guards.length > 0 && ( -
-
- - Guardie Assegnate ({selectedCell.data.guards.length}) -
-
- {selectedCell.data.guards.map((guard, idx) => ( -
-
-

{guard.guardName}

-

{guard.badgeNumber}

-
- {guard.hours}h -
- ))} -
-
- )} - {/* Veicoli */} {selectedCell.data.vehicles.length > 0 && (
@@ -706,33 +686,67 @@ export default function GeneralPlanning() { {/* Select guardia disponibile */}
- +
+ + {!isLoadingGuards && availableGuards && availableGuards.some(g => g.requiresOvertime && g.isAvailable) && ( + + )} +
{isLoadingGuards ? ( ) : ( - + <> + {(() => { + // Filtra guardie: mostra solo con ore ordinarie se toggle è off + const filteredGuards = availableGuards?.filter(g => + g.isAvailable && (showOvertimeGuards || !g.requiresOvertime) + ) || []; + + return ( + <> + + {filteredGuards.length === 0 && !showOvertimeGuards && availableGuards && availableGuards.some(g => g.isAvailable && g.requiresOvertime) && ( +

+ ℹ️ Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle. +

+ )} + + ); + })()} + )} {availableGuards && availableGuards.length > 0 && selectedGuardId && (
diff --git a/server/storage.ts b/server/storage.ts index 3b1ac65..1437704 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -688,6 +688,22 @@ export class DatabaseStorage implements IStorage { return start1 < end2 && end1 > start2; }; + // Helper: Calculate night hours (22:00-06:00) + const calculateNightHours = (start: Date, end: Date): number => { + let nightHours = 0; + const current = new Date(start); + + while (current < end) { + const hour = current.getUTCHours(); + if (hour >= 22 || hour < 6) { + nightHours += 1; + } + current.setHours(current.getHours() + 1); + } + + return nightHours; + }; + // Calculate week boundaries for weekly hours calculation const weekStart = new Date(plannedStart); weekStart.setDate(plannedStart.getDate() - plannedStart.getDay() + (plannedStart.getDay() === 0 ? -6 : 1)); @@ -695,9 +711,19 @@ export class DatabaseStorage implements IStorage { const weekEnd = addDays(weekStart, 6); weekEnd.setHours(23, 59, 59, 999); - // Get max weekly hours from CCNL settings (default 45h) - const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours'); - const maxWeeklyHours = maxHoursSetting ? Number(maxHoursSetting.value) : 45; + // Get contract parameters + let contractParams = await this.getContractParameters(); + if (!contractParams) { + contractParams = await this.createContractParameters({ + contractType: "CCNL_VIGILANZA_2024", + }); + } + + const maxOrdinaryHours = contractParams.maxHoursPerWeek || 40; // 40h + const maxOvertimeHours = contractParams.maxOvertimePerWeek || 8; // 8h + const maxTotalHours = maxOrdinaryHours + maxOvertimeHours; // 48h + const maxNightHours = contractParams.maxNightHoursPerWeek || 48; // 48h + const minDailyRest = contractParams.minDailyRestHours || 11; // 11h // Get site to check requirements const site = await this.getSite(siteId); @@ -718,6 +744,9 @@ export class DatabaseStorage implements IStorage { return true; }); + const requestedHours = differenceInHours(plannedEnd, plannedStart); + const requestedNightHours = calculateNightHours(plannedStart, plannedEnd); + // Analyze each guard's availability const guardsWithAvailability: GuardAvailability[] = []; @@ -737,17 +766,34 @@ export class DatabaseStorage implements IStorage { gte(shiftAssignments.plannedStartTime, weekStart), lte(shiftAssignments.plannedStartTime, weekEnd) ) - ); + ) + .orderBy(desc(shiftAssignments.plannedEndTime)); - // Calculate total weekly hours assigned + // Calculate total weekly hours and night hours assigned let weeklyHoursAssigned = 0; + let nightHoursAssigned = 0; + let lastShiftEnd: Date | null = null; + for (const assignment of weeklyAssignments) { const hours = differenceInHours(assignment.plannedEndTime, assignment.plannedStartTime); weeklyHoursAssigned += hours; + nightHoursAssigned += calculateNightHours(assignment.plannedStartTime, assignment.plannedEndTime); + + // Track last shift end for rest calculation + if (!lastShiftEnd || assignment.plannedEndTime > lastShiftEnd) { + lastShiftEnd = assignment.plannedEndTime; + } } - const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned; - const requestedHours = differenceInHours(plannedEnd, plannedStart); + // Calculate ordinary and overtime hours + const ordinaryHoursAssigned = Math.min(weeklyHoursAssigned, maxOrdinaryHours); + const overtimeHoursAssigned = Math.max(0, weeklyHoursAssigned - maxOrdinaryHours); + const ordinaryHoursRemaining = Math.max(0, maxOrdinaryHours - weeklyHoursAssigned); + const overtimeHoursRemaining = Math.max(0, maxOvertimeHours - overtimeHoursAssigned); + const weeklyHoursRemaining = ordinaryHoursRemaining + overtimeHoursRemaining; + + // Check if shift requires overtime + const requiresOvertime = requestedHours > ordinaryHoursRemaining; // Check for time conflicts with the requested slot const conflicts = []; @@ -771,19 +817,42 @@ export class DatabaseStorage implements IStorage { } } + // Check rest violation (11h between shifts) + let hasRestViolation = false; + if (lastShiftEnd) { + const hoursSinceLastShift = differenceInHours(plannedStart, lastShiftEnd); + if (hoursSinceLastShift < minDailyRest) { + hasRestViolation = true; + reasons.push(`Riposo insufficiente (${Math.max(0, hoursSinceLastShift).toFixed(1)}h dall'ultimo turno, minimo ${minDailyRest}h)`); + } + } + // Determine availability let isAvailable = true; + // EXCLUDE guards already assigned on same day/time if (conflicts.length > 0) { isAvailable = false; reasons.push(`Già assegnata in ${conflicts.length} turno/i nello stesso orario`); } + // Check if enough hours available (total) if (weeklyHoursRemaining < requestedHours) { isAvailable = false; reasons.push(`Ore settimanali insufficienti (${Math.max(0, weeklyHoursRemaining)}h disponibili, ${requestedHours}h richieste)`); } + // Check night hours limit + if (nightHoursAssigned + requestedNightHours > maxNightHours) { + isAvailable = false; + reasons.push(`Ore notturne esaurite (${nightHoursAssigned}h lavorate, max ${maxNightHours}h/settimana)`); + } + + // Rest violation makes guard unavailable + if (hasRestViolation) { + isAvailable = false; + } + // Build guard name from new fields const guardName = guard.firstName && guard.lastName ? `${guard.firstName} ${guard.lastName}` @@ -795,17 +864,27 @@ export class DatabaseStorage implements IStorage { badgeNumber: guard.badgeNumber, weeklyHoursRemaining, weeklyHoursAssigned, - weeklyHoursMax: maxWeeklyHours, + weeklyHoursMax: maxTotalHours, + ordinaryHoursRemaining, + overtimeHoursRemaining, + nightHoursAssigned, + requiresOvertime, + hasRestViolation, + lastShiftEnd, isAvailable, conflicts, unavailabilityReasons: reasons, }); } - // Sort: available first, then by remaining hours (descending) + // Sort: available with ordinary hours first, then overtime, then unavailable guardsWithAvailability.sort((a, b) => { if (a.isAvailable && !b.isAvailable) return -1; if (!a.isAvailable && b.isAvailable) return 1; + if (a.isAvailable && b.isAvailable) { + if (!a.requiresOvertime && b.requiresOvertime) return -1; + if (a.requiresOvertime && !b.requiresOvertime) return 1; + } return b.weeklyHoursRemaining - a.weeklyHoursRemaining; }); diff --git a/shared/schema.ts b/shared/schema.ts index b10041e..9aac6d3 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -887,6 +887,12 @@ export const guardAvailabilitySchema = z.object({ weeklyHoursRemaining: z.number(), weeklyHoursAssigned: z.number(), weeklyHoursMax: z.number(), + ordinaryHoursRemaining: z.number(), // Ore ordinarie disponibili (max 40h) + overtimeHoursRemaining: z.number(), // Ore straordinario disponibili (max 8h) + nightHoursAssigned: z.number(), // Ore notturne lavorate (22:00-06:00) + requiresOvertime: z.boolean(), // True se richiede straordinario + hasRestViolation: z.boolean(), // True se viola riposo obbligatorio + lastShiftEnd: z.date().nullable(), // Fine ultimo turno (per calcolo riposo) isAvailable: z.boolean(), conflicts: z.array(guardConflictSchema), unavailabilityReasons: z.array(z.string()),