Improve guard assignment logic and display in the planning tool

Implement real-time filtering of available guards based on standard and overtime hours, including night hour constraints and daily rest periods. Enhance UI to toggle overtime guard visibility and improve dialog scrollability.

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/G1ZUdV2
This commit is contained in:
marco370 2025-10-21 16:45:18 +00:00
parent 3a7f44f49f
commit b782f16797
4 changed files with 155 additions and 56 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -95,6 +95,7 @@ export default function GeneralPlanning() {
const [startTime, setStartTime] = useState<string>("06:00"); const [startTime, setStartTime] = useState<string>("06:00");
const [durationHours, setDurationHours] = useState<number>(8); const [durationHours, setDurationHours] = useState<number>(8);
const [consecutiveDays, setConsecutiveDays] = useState<number>(1); const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
// Query per dati planning settimanale // Query per dati planning settimanale
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({ const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
@ -517,7 +518,7 @@ export default function GeneralPlanning() {
setDurationHours(8); setDurationHours(8);
setConsecutiveDays(1); setConsecutiveDays(1);
}}> }}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" /> <Calendar className="h-5 w-5" />
@ -529,7 +530,7 @@ export default function GeneralPlanning() {
</DialogHeader> </DialogHeader>
{selectedCell && ( {selectedCell && (
<div className="space-y-4"> <div className="space-y-4 overflow-y-auto pr-2">
{/* Info turni */} {/* Info turni */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
@ -542,27 +543,6 @@ export default function GeneralPlanning() {
</div> </div>
</div> </div>
{/* Guardie assegnate */}
{selectedCell.data.guards.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4" />
Guardie Assegnate ({selectedCell.data.guards.length})
</div>
<div className="grid gap-2">
{selectedCell.data.guards.map((guard, idx) => (
<div key={idx} className="flex items-center justify-between p-2 bg-accent/10 rounded-md">
<div>
<p className="font-medium">{guard.guardName}</p>
<p className="text-xs text-muted-foreground">{guard.badgeNumber}</p>
</div>
<Badge variant="secondary">{guard.hours}h</Badge>
</div>
))}
</div>
</div>
)}
{/* Veicoli */} {/* Veicoli */}
{selectedCell.data.vehicles.length > 0 && ( {selectedCell.data.vehicles.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
@ -706,10 +686,32 @@ export default function GeneralPlanning() {
{/* Select guardia disponibile */} {/* Select guardia disponibile */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="guard-select">Guardia Disponibile</Label> <Label htmlFor="guard-select">Guardia Disponibile</Label>
{!isLoadingGuards && availableGuards && availableGuards.some(g => g.requiresOvertime && g.isAvailable) && (
<Button
variant="outline"
size="sm"
onClick={() => setShowOvertimeGuards(!showOvertimeGuards)}
className="h-7 text-xs"
data-testid="button-toggle-overtime"
>
{showOvertimeGuards ? "Nascondi" : "Mostra"} Straordinario
</Button>
)}
</div>
{isLoadingGuards ? ( {isLoadingGuards ? (
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
) : ( ) : (
<>
{(() => {
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
const filteredGuards = availableGuards?.filter(g =>
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
) || [];
return (
<>
<Select <Select
value={selectedGuardId} value={selectedGuardId}
onValueChange={setSelectedGuardId} onValueChange={setSelectedGuardId}
@ -719,20 +721,32 @@ export default function GeneralPlanning() {
<SelectValue placeholder="Seleziona guardia..." /> <SelectValue placeholder="Seleziona guardia..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableGuards && availableGuards.length > 0 ? ( {filteredGuards.length > 0 ? (
availableGuards.map((guard) => ( filteredGuards.map((guard) => (
<SelectItem key={guard.guardId} value={guard.guardId}> <SelectItem key={guard.guardId} value={guard.guardId}>
{guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili {guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
{guard.conflicts && guard.conflicts.length > 0 && " ⚠️"} {guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
{guard.requiresOvertime && " 🔸"}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
<SelectItem value="no-guards" disabled> <SelectItem value="no-guards" disabled>
Nessuna guardia disponibile {showOvertimeGuards
? "Nessuna guardia disponibile"
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
</SelectItem> </SelectItem>
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
{filteredGuards.length === 0 && !showOvertimeGuards && availableGuards && availableGuards.some(g => g.isAvailable && g.requiresOvertime) && (
<p className="text-xs text-muted-foreground">
Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
</p>
)}
</>
);
})()}
</>
)} )}
{availableGuards && availableGuards.length > 0 && selectedGuardId && ( {availableGuards && availableGuards.length > 0 && selectedGuardId && (
<div className="text-xs space-y-1"> <div className="text-xs space-y-1">

View File

@ -688,6 +688,22 @@ export class DatabaseStorage implements IStorage {
return start1 < end2 && end1 > start2; 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 // Calculate week boundaries for weekly hours calculation
const weekStart = new Date(plannedStart); const weekStart = new Date(plannedStart);
weekStart.setDate(plannedStart.getDate() - plannedStart.getDay() + (plannedStart.getDay() === 0 ? -6 : 1)); 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); const weekEnd = addDays(weekStart, 6);
weekEnd.setHours(23, 59, 59, 999); weekEnd.setHours(23, 59, 59, 999);
// Get max weekly hours from CCNL settings (default 45h) // Get contract parameters
const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours'); let contractParams = await this.getContractParameters();
const maxWeeklyHours = maxHoursSetting ? Number(maxHoursSetting.value) : 45; 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 // Get site to check requirements
const site = await this.getSite(siteId); const site = await this.getSite(siteId);
@ -718,6 +744,9 @@ export class DatabaseStorage implements IStorage {
return true; return true;
}); });
const requestedHours = differenceInHours(plannedEnd, plannedStart);
const requestedNightHours = calculateNightHours(plannedStart, plannedEnd);
// Analyze each guard's availability // Analyze each guard's availability
const guardsWithAvailability: GuardAvailability[] = []; const guardsWithAvailability: GuardAvailability[] = [];
@ -737,17 +766,34 @@ export class DatabaseStorage implements IStorage {
gte(shiftAssignments.plannedStartTime, weekStart), gte(shiftAssignments.plannedStartTime, weekStart),
lte(shiftAssignments.plannedStartTime, weekEnd) 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 weeklyHoursAssigned = 0;
let nightHoursAssigned = 0;
let lastShiftEnd: Date | null = null;
for (const assignment of weeklyAssignments) { for (const assignment of weeklyAssignments) {
const hours = differenceInHours(assignment.plannedEndTime, assignment.plannedStartTime); const hours = differenceInHours(assignment.plannedEndTime, assignment.plannedStartTime);
weeklyHoursAssigned += hours; 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; // Calculate ordinary and overtime hours
const requestedHours = differenceInHours(plannedEnd, plannedStart); 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 // Check for time conflicts with the requested slot
const conflicts = []; 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 // Determine availability
let isAvailable = true; let isAvailable = true;
// EXCLUDE guards already assigned on same day/time
if (conflicts.length > 0) { if (conflicts.length > 0) {
isAvailable = false; isAvailable = false;
reasons.push(`Già assegnata in ${conflicts.length} turno/i nello stesso orario`); reasons.push(`Già assegnata in ${conflicts.length} turno/i nello stesso orario`);
} }
// Check if enough hours available (total)
if (weeklyHoursRemaining < requestedHours) { if (weeklyHoursRemaining < requestedHours) {
isAvailable = false; isAvailable = false;
reasons.push(`Ore settimanali insufficienti (${Math.max(0, weeklyHoursRemaining)}h disponibili, ${requestedHours}h richieste)`); 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 // Build guard name from new fields
const guardName = guard.firstName && guard.lastName const guardName = guard.firstName && guard.lastName
? `${guard.firstName} ${guard.lastName}` ? `${guard.firstName} ${guard.lastName}`
@ -795,17 +864,27 @@ export class DatabaseStorage implements IStorage {
badgeNumber: guard.badgeNumber, badgeNumber: guard.badgeNumber,
weeklyHoursRemaining, weeklyHoursRemaining,
weeklyHoursAssigned, weeklyHoursAssigned,
weeklyHoursMax: maxWeeklyHours, weeklyHoursMax: maxTotalHours,
ordinaryHoursRemaining,
overtimeHoursRemaining,
nightHoursAssigned,
requiresOvertime,
hasRestViolation,
lastShiftEnd,
isAvailable, isAvailable,
conflicts, conflicts,
unavailabilityReasons: reasons, unavailabilityReasons: reasons,
}); });
} }
// Sort: available first, then by remaining hours (descending) // Sort: available with ordinary hours first, then overtime, then unavailable
guardsWithAvailability.sort((a, b) => { guardsWithAvailability.sort((a, b) => {
if (a.isAvailable && !b.isAvailable) return -1; if (a.isAvailable && !b.isAvailable) return -1;
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; return b.weeklyHoursRemaining - a.weeklyHoursRemaining;
}); });

View File

@ -887,6 +887,12 @@ export const guardAvailabilitySchema = z.object({
weeklyHoursRemaining: z.number(), weeklyHoursRemaining: z.number(),
weeklyHoursAssigned: z.number(), weeklyHoursAssigned: z.number(),
weeklyHoursMax: 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(), isAvailable: z.boolean(),
conflicts: z.array(guardConflictSchema), conflicts: z.array(guardConflictSchema),
unavailabilityReasons: z.array(z.string()), unavailabilityReasons: z.array(z.string()),