Compare commits

..

4 Commits

Author SHA1 Message Date
Marco Lanzara
10b543ebab 🚀 Release v1.0.28
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.28_20251021_165619.sql.gz
- Data: 2025-10-21 16:56:37
2025-10-21 16:56:37 +00:00
marco370
a84f21bf24 Improve guard assignment logic in shift scheduling
Refactor shift assignment dialog to remove duplicate guard listings, enable scrolling for full visibility, and implement real-time filtering of available guards based on contractual hours, with an option to include guards eligible for overtime.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/qoWuIE4
2025-10-21 16:53:06 +00:00
marco370
da547137b7 Improve guard selection by filtering based on availability and overtime
Refactors the guard selection UI to dynamically filter available guards, showing regular hours first and providing an option to display those requiring overtime.

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/qoWuIE4
2025-10-21 16:51:34 +00:00
marco370
b782f16797 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
2025-10-21 16:45:18 +00:00
7 changed files with 207 additions and 88 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 [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">
@ -705,63 +685,111 @@ export default function GeneralPlanning() {
</div>
{/* Select guardia disponibile */}
<div className="space-y-2">
<Label htmlFor="guard-select">Guardia Disponibile</Label>
{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>
{(() => {
// Filtra guardie: mostra solo con ore ordinarie se toggle è off
const filteredGuards = availableGuards?.filter(g =>
g.isAvailable && (showOvertimeGuards || !g.requiresOvertime)
) || [];
const hasOvertimeGuards = availableGuards?.some(g => g.requiresOvertime && g.isAvailable) || false;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="guard-select">Guardia Disponibile</Label>
{!isLoadingGuards && hasOvertimeGuards && (
<Button
variant="outline"
size="sm"
onClick={() => setShowOvertimeGuards(!showOvertimeGuards)}
className="h-7 text-xs"
data-testid="button-toggle-overtime"
>
{showOvertimeGuards ? "Nascondi" : "Mostra"} Straordinario
</Button>
)}
</SelectContent>
</Select>
)}
{availableGuards && availableGuards.length > 0 && selectedGuardId && (
<div className="text-xs space-y-1">
{(() => {
const guard = availableGuards.find(g => g.guardId === selectedGuardId);
if (!guard) return null;
return (
<>
<p className="text-muted-foreground">
Ore assegnate: {guard.weeklyHoursAssigned}h / {guard.weeklyHoursMax}h (rimangono {guard.weeklyHoursRemaining}h)
</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>
{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>
{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>
)}
</>
);
})()}
)}
{filteredGuards.length > 0 && selectedGuardId && (
<div className="text-xs space-y-1">
{(() => {
const guard = availableGuards?.find(g => g.guardId === selectedGuardId);
if (!guard) return null;
return (
<>
<p className="text-muted-foreground">
Ore ordinarie: {guard.ordinaryHoursRemaining}h / 40h disponibili
{guard.requiresOvertime && ` • Straordinario: ${guard.overtimeHoursRemaining}h / 8h`}
</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>
);
})()}
{/* Bottone assegna */}
<Button

View File

@ -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;
});

View File

@ -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()),

View File

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