Improve guard assignment and availability checks for shift planning

Update storage interface and implementation to handle shift assignment deletion,
modify getGuardsAvailability to accept specific planned start and end times,
and introduce conflict detection logic for guard availability. Add new DTOs
(guardConflictSchema) and update guardAvailabilitySchema to include availability
status, conflicts, and unavailability reasons, enhancing shift planning accuracy.

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/ZZOTK7r
This commit is contained in:
marco370 2025-10-21 06:36:42 +00:00
parent 1caf5c4199
commit c72125c68f
3 changed files with 114 additions and 35 deletions

View File

@ -19,6 +19,10 @@ externalPort = 80
localPort = 33035
externalPort = 3001
[[ports]]
localPort = 38383
externalPort = 4200
[[ports]]
localPort = 41343
externalPort = 3000

View File

@ -157,7 +157,15 @@ export interface IStorage {
deleteCcnlSetting(key: string): Promise<void>;
// General Planning operations
getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]>;
getGuardsAvailability(
siteId: string,
location: string,
plannedStart: Date,
plannedEnd: Date
): Promise<GuardAvailability[]>;
// Shift Assignment operations with time slot management
deleteShiftAssignment(id: string): Promise<void>;
}
export class DatabaseStorage implements IStorage {
@ -668,9 +676,24 @@ export class DatabaseStorage implements IStorage {
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
}
// General Planning operations
async getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]> {
// General Planning operations with time slot conflict detection
async getGuardsAvailability(
siteId: string,
location: string,
plannedStart: Date,
plannedEnd: Date
): Promise<GuardAvailability[]> {
// Helper: Check if two time ranges overlap
const hasOverlap = (start1: Date, end1: Date, start2: Date, end2: Date): boolean => {
return start1 < end2 && end1 > start2;
};
// Calculate week boundaries for weekly hours calculation
const weekStart = new Date(plannedStart);
weekStart.setDate(plannedStart.getDate() - plannedStart.getDay() + (plannedStart.getDay() === 0 ? -6 : 1));
weekStart.setHours(0, 0, 0, 0);
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');
@ -689,64 +712,104 @@ export class DatabaseStorage implements IStorage {
.where(eq(guards.location, location as any));
// Filter guards by site requirements
const eligibleGuards = allGuards.filter(guard => {
const eligibleGuards = allGuards.filter((guard: Guard) => {
if (site.requiresArmed && !guard.isArmed) return false;
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
return true;
});
// Calculate weekly hours for each guard
const guardsWithHours: GuardAvailability[] = [];
// Analyze each guard's availability
const guardsWithAvailability: GuardAvailability[] = [];
for (const guard of eligibleGuards) {
// Get all shift assignments for this guard in the week
const assignments = await db
// Get all shift assignments for this guard in the week (for weekly hours)
const weeklyAssignments = await db
.select({
id: shiftAssignments.id,
shiftId: shiftAssignments.shiftId,
startTime: shifts.startTime,
endTime: shifts.endTime,
plannedStartTime: shiftAssignments.plannedStartTime,
plannedEndTime: shiftAssignments.plannedEndTime,
})
.from(shiftAssignments)
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
.where(
and(
eq(shiftAssignments.guardId, guard.id),
gte(shifts.startTime, weekStart),
lte(shifts.startTime, weekEnd)
gte(shiftAssignments.plannedStartTime, weekStart),
lte(shiftAssignments.plannedStartTime, weekEnd)
)
);
// Calculate total hours assigned
// Calculate total weekly hours assigned
let weeklyHoursAssigned = 0;
for (const assignment of assignments) {
const hours = differenceInHours(assignment.endTime, assignment.startTime);
for (const assignment of weeklyAssignments) {
const hours = differenceInHours(assignment.plannedEndTime, assignment.plannedStartTime);
weeklyHoursAssigned += hours;
}
const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned;
const requestedHours = differenceInHours(plannedEnd, plannedStart);
// Only include guards with remaining hours
if (weeklyHoursRemaining > 0) {
const user = guard.userId ? await this.getUser(guard.userId) : undefined;
const guardName = user
? `${user.firstName || ''} ${user.lastName || ''}`.trim() || 'N/A'
: 'N/A';
// Check for time conflicts with the requested slot
const conflicts = [];
const reasons: string[] = [];
guardsWithHours.push({
guardId: guard.id,
guardName,
badgeNumber: guard.badgeNumber,
weeklyHoursRemaining,
weeklyHoursAssigned,
weeklyHoursMax: maxWeeklyHours,
});
for (const assignment of weeklyAssignments) {
if (hasOverlap(plannedStart, plannedEnd, assignment.plannedStartTime, assignment.plannedEndTime)) {
// Get site name for conflict
const [shift] = await db
.select({ siteName: sites.name })
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.where(eq(shifts.id, assignment.shiftId));
conflicts.push({
from: assignment.plannedStartTime,
to: assignment.plannedEndTime,
siteName: shift?.siteName || 'Sito sconosciuto',
shiftId: assignment.shiftId,
});
}
}
// Determine availability
let isAvailable = true;
if (conflicts.length > 0) {
isAvailable = false;
reasons.push(`Già assegnata in ${conflicts.length} turno/i nello stesso orario`);
}
if (weeklyHoursRemaining < requestedHours) {
isAvailable = false;
reasons.push(`Ore settimanali insufficienti (${Math.max(0, weeklyHoursRemaining)}h disponibili, ${requestedHours}h richieste)`);
}
// Build guard name from new fields
const guardName = guard.firstName && guard.lastName
? `${guard.firstName} ${guard.lastName}`
: guard.badgeNumber;
guardsWithAvailability.push({
guardId: guard.id,
guardName,
badgeNumber: guard.badgeNumber,
weeklyHoursRemaining,
weeklyHoursAssigned,
weeklyHoursMax: maxWeeklyHours,
isAvailable,
conflicts,
unavailabilityReasons: reasons,
});
}
// Sort by remaining hours (descending)
guardsWithHours.sort((a, b) => b.weeklyHoursRemaining - a.weeklyHoursRemaining);
// Sort: available first, then by remaining hours (descending)
guardsWithAvailability.sort((a, b) => {
if (a.isAvailable && !b.isAvailable) return -1;
if (!a.isAvailable && b.isAvailable) return 1;
return b.weeklyHoursRemaining - a.weeklyHoursRemaining;
});
return guardsWithHours;
return guardsWithAvailability;
}
}

View File

@ -871,7 +871,15 @@ export type AbsenceWithDetails = Absence & {
// ============= DTOs FOR GENERAL PLANNING =============
// DTO per disponibilità guardia nella settimana
// DTO per conflitto orario guardia
export const guardConflictSchema = z.object({
from: z.date(),
to: z.date(),
siteName: z.string(),
shiftId: z.string(),
});
// DTO per disponibilità guardia con controllo conflitti orari
export const guardAvailabilitySchema = z.object({
guardId: z.string(),
guardName: z.string(),
@ -879,8 +887,12 @@ export const guardAvailabilitySchema = z.object({
weeklyHoursRemaining: z.number(),
weeklyHoursAssigned: z.number(),
weeklyHoursMax: z.number(),
isAvailable: z.boolean(),
conflicts: z.array(guardConflictSchema),
unavailabilityReasons: z.array(z.string()),
});
export type GuardConflict = z.infer<typeof guardConflictSchema>;
export type GuardAvailability = z.infer<typeof guardAvailabilitySchema>;
// DTO per creazione turno multi-giorno dal Planning Generale