Compare commits

..

No commits in common. "10b543ebab4d42f70ecaefd2659fd84c96c8ee37" and "3a7f44f49ff16a027397e4205f8fc5fcbcbcc63a" have entirely different histories.

7 changed files with 88 additions and 207 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

View File

@ -95,7 +95,6 @@ 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>({
@ -518,7 +517,7 @@ export default function GeneralPlanning() {
setDurationHours(8); setDurationHours(8);
setConsecutiveDays(1); setConsecutiveDays(1);
}}> }}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col"> <DialogContent className="max-w-2xl">
<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" />
@ -530,7 +529,7 @@ export default function GeneralPlanning() {
</DialogHeader> </DialogHeader>
{selectedCell && ( {selectedCell && (
<div className="space-y-4 overflow-y-auto pr-2"> <div className="space-y-4">
{/* Info turni */} {/* Info turni */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
@ -543,6 +542,27 @@ 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">
@ -685,111 +705,63 @@ export default function GeneralPlanning() {
</div> </div>
{/* Select guardia disponibile */} {/* Select guardia disponibile */}
{(() => { <div className="space-y-2">
// Filtra guardie: mostra solo con ore ordinarie se toggle è off <Label htmlFor="guard-select">Guardia Disponibile</Label>
const filteredGuards = availableGuards?.filter(g => {isLoadingGuards ? (
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime) <Skeleton className="h-10 w-full" />
) || []; ) : (
<Select
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime && g.isAvailable) || false; value={selectedGuardId}
onValueChange={setSelectedGuardId}
return ( disabled={assignGuardMutation.isPending}
<div className="space-y-2"> >
<div className="flex items-center justify-between"> <SelectTrigger id="guard-select" data-testid="select-guard">
<Label htmlFor="guard-select">Guardia Disponibile</Label> <SelectValue placeholder="Seleziona guardia..." />
{!isLoadingGuards && hasOvertimeGuards && ( </SelectTrigger>
<Button <SelectContent>
variant="outline" {availableGuards && availableGuards.length > 0 ? (
size="sm" availableGuards.map((guard) => (
onClick={() => setShowOvertimeGuards(!showOvertimeGuards)} <SelectItem key={guard.guardId} value={guard.guardId}>
className="h-7 text-xs" {guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili
data-testid="button-toggle-overtime" {guard.conflicts && guard.conflicts.length > 0 && " ⚠️"}
> </SelectItem>
{showOvertimeGuards ? "Nascondi" : "Mostra"} Straordinario ))
</Button> ) : (
<SelectItem value="no-guards" disabled>
Nessuna guardia disponibile
</SelectItem>
)} )}
</div> </SelectContent>
{isLoadingGuards ? ( </Select>
<Skeleton className="h-10 w-full" /> )}
) : ( {availableGuards && availableGuards.length > 0 && selectedGuardId && (
<> <div className="text-xs space-y-1">
<Select {(() => {
value={selectedGuardId} const guard = availableGuards.find(g => g.guardId === selectedGuardId);
onValueChange={setSelectedGuardId} if (!guard) return null;
disabled={assignGuardMutation.isPending} return (
> <>
<SelectTrigger id="guard-select" data-testid="select-guard"> <p className="text-muted-foreground">
<SelectValue placeholder="Seleziona guardia..." /> Ore assegnate: {guard.weeklyHoursAssigned}h / {guard.weeklyHoursMax}h (rimangono {guard.weeklyHoursRemaining}h)
</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 && hasOvertimeGuards && (
<p className="text-xs text-muted-foreground">
Alcune guardie disponibili richiedono straordinario. Clicca "Mostra Straordinario" per vederle.
</p> </p>
)} {guard.conflicts && guard.conflicts.length > 0 && (
{filteredGuards.length > 0 && selectedGuardId && ( <p className="text-destructive font-medium">
<div className="text-xs space-y-1"> Conflitto: {guard.conflicts.map((c: any) =>
{(() => { `${c.siteName} (${new Date(c.from).toLocaleTimeString('it-IT', {hour: '2-digit', minute:'2-digit'})} - ${new Date(c.to).toLocaleTimeString('it-IT', {hour: '2-digit', minute:'2-digit'})})`
const guard = availableGuards?.find(g => g.guardId === selectedGuardId); ).join(", ")}
if (!guard) return null; </p>
return ( )}
<> {guard.unavailabilityReasons && guard.unavailabilityReasons.length > 0 && (
<p className="text-muted-foreground"> <p className="text-yellow-600 dark:text-yellow-500">
Ore ordinarie: {guard.ordinaryHoursRemaining}h / 40h disponibili {guard.unavailabilityReasons.join(", ")}
{guard.requiresOvertime && ` • Straordinario: ${guard.overtimeHoursRemaining}h / 8h`} </p>
</p> )}
<p className="text-muted-foreground"> </>
Ore assegnate: {guard.weeklyHoursAssigned}h / {guard.weeklyHoursMax}h (rimangono {guard.weeklyHoursRemaining}h) );
</p> })()}
{guard.nightHoursAssigned > 0 && (
<p className="text-muted-foreground">
Ore notturne: {guard.nightHoursAssigned}h / 48h settimanali
</p>
)}
{guard.hasRestViolation && (
<p className="text-yellow-600 dark:text-yellow-500 font-medium">
Attenzione: riposo insufficiente dall'ultimo turno
</p>
)}
{guard.conflicts && guard.conflicts.length > 0 && (
<p className="text-destructive font-medium">
Conflitto: {guard.conflicts.map((c: any) =>
`${c.siteName} (${new Date(c.from).toLocaleTimeString('it-IT', {hour: '2-digit', minute:'2-digit'})} - ${new Date(c.to).toLocaleTimeString('it-IT', {hour: '2-digit', minute:'2-digit'})})`
).join(", ")}
</p>
)}
{guard.unavailabilityReasons && guard.unavailabilityReasons.length > 0 && (
<p className="text-yellow-600 dark:text-yellow-500">
{guard.unavailabilityReasons.join(", ")}
</p>
)}
</>
);
})()}
</div>
)}
</>
)}
</div> </div>
); )}
})()} </div>
{/* Bottone assegna */} {/* Bottone assegna */}
<Button <Button

View File

@ -688,22 +688,6 @@ 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));
@ -711,19 +695,9 @@ 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 contract parameters // Get max weekly hours from CCNL settings (default 45h)
let contractParams = await this.getContractParameters(); const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours');
if (!contractParams) { const maxWeeklyHours = maxHoursSetting ? Number(maxHoursSetting.value) : 45;
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);
@ -744,9 +718,6 @@ 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[] = [];
@ -766,34 +737,17 @@ 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 and night hours assigned // Calculate total weekly 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;
}
} }
// Calculate ordinary and overtime hours const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned;
const ordinaryHoursAssigned = Math.min(weeklyHoursAssigned, maxOrdinaryHours); const requestedHours = differenceInHours(plannedEnd, plannedStart);
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 = [];
@ -817,42 +771,19 @@ 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}`
@ -864,27 +795,17 @@ export class DatabaseStorage implements IStorage {
badgeNumber: guard.badgeNumber, badgeNumber: guard.badgeNumber,
weeklyHoursRemaining, weeklyHoursRemaining,
weeklyHoursAssigned, weeklyHoursAssigned,
weeklyHoursMax: maxTotalHours, weeklyHoursMax: maxWeeklyHours,
ordinaryHoursRemaining,
overtimeHoursRemaining,
nightHoursAssigned,
requiresOvertime,
hasRestViolation,
lastShiftEnd,
isAvailable, isAvailable,
conflicts, conflicts,
unavailabilityReasons: reasons, unavailabilityReasons: reasons,
}); });
} }
// Sort: available with ordinary hours first, then overtime, then unavailable // Sort: available first, then by remaining hours (descending)
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,12 +887,6 @@ 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()),

View File

@ -1,13 +1,7 @@
{ {
"version": "1.0.28", "version": "1.0.27",
"lastUpdate": "2025-10-21T16:56:37.634Z", "lastUpdate": "2025-10-21T16:27:43.584Z",
"changelog": [ "changelog": [
{
"version": "1.0.28",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.28"
},
{ {
"version": "1.0.27", "version": "1.0.27",
"date": "2025-10-21", "date": "2025-10-21",