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 [durationHours, setDurationHours] = useState<number>(8);
|
||||
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
|
||||
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
|
||||
|
||||
// Query per dati planning settimanale
|
||||
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
|
||||
@ -517,7 +518,7 @@ export default function GeneralPlanning() {
|
||||
setDurationHours(8);
|
||||
setConsecutiveDays(1);
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
@ -529,7 +530,7 @@ export default function GeneralPlanning() {
|
||||
</DialogHeader>
|
||||
|
||||
{selectedCell && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 overflow-y-auto pr-2">
|
||||
{/* Info turni */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@ -542,27 +543,6 @@ export default function GeneralPlanning() {
|
||||
</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 */}
|
||||
{selectedCell.data.vehicles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
@ -706,33 +686,67 @@ export default function GeneralPlanning() {
|
||||
|
||||
{/* Select guardia disponibile */}
|
||||
<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 ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGuardId}
|
||||
onValueChange={setSelectedGuardId}
|
||||
disabled={assignGuardMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="guard-select" data-testid="select-guard">
|
||||
<SelectValue placeholder="Seleziona guardia..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableGuards && availableGuards.length > 0 ? (
|
||||
availableGuards.map((guard) => (
|
||||
<SelectItem key={guard.guardId} value={guard.guardId}>
|
||||
{guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili
|
||||
{guard.conflicts && guard.conflicts.length > 0 && " ⚠️"}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-guards" disabled>
|
||||
Nessuna guardia disponibile
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<>
|
||||
{(() => {
|
||||
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
|
||||
const filteredGuards = availableGuards?.filter(g =>
|
||||
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
|
||||
) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
value={selectedGuardId}
|
||||
onValueChange={setSelectedGuardId}
|
||||
disabled={assignGuardMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="guard-select" data-testid="select-guard">
|
||||
<SelectValue placeholder="Seleziona guardia..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredGuards.length > 0 ? (
|
||||
filteredGuards.map((guard) => (
|
||||
<SelectItem key={guard.guardId} value={guard.guardId}>
|
||||
{guard.guardName} ({guard.badgeNumber}) - {guard.ordinaryHoursRemaining}h ord.
|
||||
{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 && (
|
||||
<div className="text-xs space-y-1">
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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()),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user