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:
parent
3a7f44f49f
commit
b782f16797
BIN
attached_assets/immagine_1761064454333.png
Normal file
BIN
attached_assets/immagine_1761064454333.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@ -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,33 +686,67 @@ export default function GeneralPlanning() {
|
|||||||
|
|
||||||
{/* Select guardia disponibile */}
|
{/* Select guardia disponibile */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="guard-select">Guardia Disponibile</Label>
|
<div className="flex items-center justify-between">
|
||||||
|
<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" />
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<>
|
||||||
value={selectedGuardId}
|
{(() => {
|
||||||
onValueChange={setSelectedGuardId}
|
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
|
||||||
disabled={assignGuardMutation.isPending}
|
const filteredGuards = availableGuards?.filter(g =>
|
||||||
>
|
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
|
||||||
<SelectTrigger id="guard-select" data-testid="select-guard">
|
) || [];
|
||||||
<SelectValue placeholder="Seleziona guardia..." />
|
|
||||||
</SelectTrigger>
|
return (
|
||||||
<SelectContent>
|
<>
|
||||||
{availableGuards && availableGuards.length > 0 ? (
|
<Select
|
||||||
availableGuards.map((guard) => (
|
value={selectedGuardId}
|
||||||
<SelectItem key={guard.guardId} value={guard.guardId}>
|
onValueChange={setSelectedGuardId}
|
||||||
{guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili
|
disabled={assignGuardMutation.isPending}
|
||||||
{guard.conflicts && guard.conflicts.length > 0 && " ⚠️"}
|
>
|
||||||
</SelectItem>
|
<SelectTrigger id="guard-select" data-testid="select-guard">
|
||||||
))
|
<SelectValue placeholder="Seleziona guardia..." />
|
||||||
) : (
|
</SelectTrigger>
|
||||||
<SelectItem value="no-guards" disabled>
|
<SelectContent>
|
||||||
Nessuna guardia disponibile
|
{filteredGuards.length > 0 ? (
|
||||||
</SelectItem>
|
filteredGuards.map((guard) => (
|
||||||
)}
|
<SelectItem key={guard.guardId} value={guard.guardId}>
|
||||||
</SelectContent>
|
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
|
||||||
</Select>
|
{guard.requiresOvertime && ` + ${guard.overtimeHoursRemaining}h strao.`}
|
||||||
|
{guard.requiresOvertime && " 🔸"}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value="no-guards" disabled>
|
||||||
|
{showOvertimeGuards
|
||||||
|
? "Nessuna guardia disponibile"
|
||||||
|
: "Nessuna guardia con ore ordinarie (prova 'Mostra Straordinario')"}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</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">
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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()),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user