Compare commits

..

No commits in common. "62f8189e7dbe116868e447dbf29004e1cc25f455" and "18e219e11815b21daaba400f85516cc2b11bfb6f" have entirely different histories.

18 changed files with 100 additions and 495 deletions

View File

@ -23,10 +23,6 @@ externalPort = 3001
localPort = 41343 localPort = 41343
externalPort = 3000 externalPort = 3000
[[ports]]
localPort = 41803
externalPort = 4200
[[ports]] [[ports]]
localPort = 42175 localPort = 42175
externalPort = 3002 externalPort = 3002

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View File

@ -75,10 +75,9 @@ export default function GeneralPlanning() {
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 })); const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
const [selectedCell, setSelectedCell] = useState<{ siteId: string; siteName: string; date: string; data: SiteData } | null>(null); const [selectedCell, setSelectedCell] = useState<{ siteId: string; siteName: string; date: string; data: SiteData } | null>(null);
// Form state per assegnazione guardia // Form state per creazione turno
const [selectedGuardId, setSelectedGuardId] = useState<string>(""); const [selectedGuardId, setSelectedGuardId] = useState<string>("");
const [startTime, setStartTime] = useState<string>("06:00"); const [days, setDays] = useState<number>(1);
const [durationHours, setDurationHours] = useState<number>(8);
// Query per dati planning settimanale // Query per dati planning settimanale
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({ const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
@ -92,30 +91,13 @@ export default function GeneralPlanning() {
}, },
}); });
// Calcola start e end time per la query availability
const getTimeSlot = () => {
if (!selectedCell) return { start: "", end: "" };
const [hours, minutes] = startTime.split(":").map(Number);
const startDateTime = new Date(selectedCell.date);
startDateTime.setHours(hours, minutes, 0, 0);
const endDateTime = new Date(startDateTime);
endDateTime.setHours(startDateTime.getHours() + durationHours);
return {
start: startDateTime.toISOString(),
end: endDateTime.toISOString()
};
};
// Query per guardie disponibili (solo quando dialog è aperto) // Query per guardie disponibili (solo quando dialog è aperto)
const { data: availableGuards, isLoading: isLoadingGuards } = useQuery<GuardAvailability[]>({ const { data: availableGuards, isLoading: isLoadingGuards } = useQuery<GuardAvailability[]>({
queryKey: ["/api/guards/availability", selectedCell?.siteId, selectedLocation, startTime, durationHours], queryKey: ["/api/guards/availability", format(weekStart, "yyyy-MM-dd"), selectedCell?.siteId, selectedLocation],
queryFn: async () => { queryFn: async () => {
if (!selectedCell) return []; if (!selectedCell) return [];
const { start, end } = getTimeSlot();
const response = await fetch( const response = await fetch(
`/api/guards/availability?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&siteId=${selectedCell.siteId}&location=${selectedLocation}` `/api/guards/availability?weekStart=${format(weekStart, "yyyy-MM-dd")}&siteId=${selectedCell.siteId}&location=${selectedLocation}`
); );
if (!response.ok) throw new Error("Failed to fetch guards availability"); if (!response.ok) throw new Error("Failed to fetch guards availability");
return response.json(); return response.json();
@ -123,10 +105,10 @@ export default function GeneralPlanning() {
enabled: !!selectedCell, // Query attiva solo se dialog è aperto enabled: !!selectedCell, // Query attiva solo se dialog è aperto
}); });
// Mutation per assegnare guardia con orari // Mutation per creare turno multi-giorno
const assignGuardMutation = useMutation({ const createShiftMutation = useMutation({
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number }) => { mutationFn: async (data: { siteId: string; startDate: string; days: number; guardId: string }) => {
return apiRequest("POST", "/api/general-planning/assign-guard", data); return apiRequest("POST", "/api/general-planning/shifts", data);
}, },
onSuccess: () => { onSuccess: () => {
// Invalida cache planning generale // Invalida cache planning generale
@ -134,73 +116,33 @@ export default function GeneralPlanning() {
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] }); queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
toast({ toast({
title: "Guardia assegnata", title: "Turno creato",
description: "La guardia è stata assegnata con successo", description: "Il turno è stato creato con successo",
}); });
// Reset form // Reset form e chiudi dialog
setSelectedGuardId(""); setSelectedGuardId("");
setDays(1);
setSelectedCell(null); setSelectedCell(null);
}, },
onError: (error: any) => {
// Parse error message from API response
let errorMessage = "Impossibile assegnare la guardia";
if (error.message) {
// Error format from apiRequest: "STATUS_CODE: {json_body}"
const match = error.message.match(/^\d+:\s*(.+)$/);
if (match) {
try {
const parsed = JSON.parse(match[1]);
errorMessage = parsed.message || errorMessage;
} catch {
errorMessage = match[1];
}
} else {
errorMessage = error.message;
}
}
toast({
title: "Errore Assegnazione",
description: errorMessage,
variant: "destructive",
});
},
});
// Mutation per deassegnare guardia
const unassignGuardMutation = useMutation({
mutationFn: async (assignmentId: string) => {
return apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`, {});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
toast({
title: "Guardia deassegnata",
description: "La guardia è stata rimossa dal turno",
});
},
onError: (error: any) => { onError: (error: any) => {
toast({ toast({
title: "Errore", title: "Errore",
description: error.message || "Impossibile deassegnare la guardia", description: error.message || "Impossibile creare il turno",
variant: "destructive", variant: "destructive",
}); });
}, },
}); });
// Handler per submit form assegnazione guardia // Handler per submit form creazione turno
const handleAssignGuard = () => { const handleCreateShift = () => {
if (!selectedCell || !selectedGuardId) return; if (!selectedCell || !selectedGuardId) return;
assignGuardMutation.mutate({ createShiftMutation.mutate({
siteId: selectedCell.siteId, siteId: selectedCell.siteId,
date: selectedCell.date, startDate: selectedCell.date,
days,
guardId: selectedGuardId, guardId: selectedGuardId,
startTime,
durationHours,
}); });
}; };
@ -583,51 +525,14 @@ export default function GeneralPlanning() {
</div> </div>
)} )}
{/* Form assegnazione guardia */} {/* Form creazione nuovo turno */}
<div className="border-t pt-4 space-y-4"> <div className="border-t pt-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold"> <div className="flex items-center gap-2 text-sm font-semibold">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Assegna Guardia Crea Nuovo Turno
</div> </div>
<div className="grid gap-4"> <div className="grid gap-4">
{/* Ora Inizio e Durata */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start-time">Ora Inizio</Label>
<Input
id="start-time"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
disabled={assignGuardMutation.isPending}
data-testid="input-start-time"
/>
</div>
<div className="space-y-2">
<Label htmlFor="duration">Durata (ore)</Label>
<Input
id="duration"
type="number"
min={1}
max={24}
value={durationHours}
onChange={(e) => setDurationHours(Math.max(1, Math.min(24, parseInt(e.target.value) || 8)))}
disabled={assignGuardMutation.isPending}
data-testid="input-duration"
/>
</div>
</div>
{/* Ora fine calcolata */}
<div className="text-xs text-muted-foreground">
Ora fine: {(() => {
const [hours, minutes] = startTime.split(":").map(Number);
const endHour = (hours + durationHours) % 24;
return `${String(endHour).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
})()}
</div>
{/* Select guardia disponibile */} {/* Select guardia disponibile */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="guard-select">Guardia Disponibile</Label> <Label htmlFor="guard-select">Guardia Disponibile</Label>
@ -637,7 +542,7 @@ export default function GeneralPlanning() {
<Select <Select
value={selectedGuardId} value={selectedGuardId}
onValueChange={setSelectedGuardId} onValueChange={setSelectedGuardId}
disabled={assignGuardMutation.isPending} disabled={createShiftMutation.isPending}
> >
<SelectTrigger id="guard-select" data-testid="select-guard"> <SelectTrigger id="guard-select" data-testid="select-guard">
<SelectValue placeholder="Seleziona guardia..." /> <SelectValue placeholder="Seleziona guardia..." />
@ -647,7 +552,6 @@ export default function GeneralPlanning() {
availableGuards.map((guard) => ( availableGuards.map((guard) => (
<SelectItem key={guard.guardId} value={guard.guardId}> <SelectItem key={guard.guardId} value={guard.guardId}>
{guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili {guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili
{guard.conflicts && guard.conflicts.length > 0 && " ⚠️"}
</SelectItem> </SelectItem>
)) ))
) : ( ) : (
@ -659,47 +563,46 @@ export default function GeneralPlanning() {
</Select> </Select>
)} )}
{availableGuards && availableGuards.length > 0 && selectedGuardId && ( {availableGuards && availableGuards.length > 0 && selectedGuardId && (
<div className="text-xs space-y-1"> <p className="text-xs text-muted-foreground">
{(() => { {(() => {
const guard = availableGuards.find(g => g.guardId === selectedGuardId); const guard = availableGuards.find(g => g.guardId === selectedGuardId);
if (!guard) return null; return guard ? `Ore assegnate: ${guard.weeklyHoursAssigned}h / ${guard.weeklyHoursMax}h (rimangono ${guard.weeklyHoursRemaining}h)` : "";
return (
<>
<p className="text-muted-foreground">
Ore assegnate: {guard.weeklyHoursAssigned}h / {guard.weeklyHoursMax}h (rimangono {guard.weeklyHoursRemaining}h)
</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> </p>
)} )}
</div> </div>
{/* Bottone assegna */} {/* Input numero giorni */}
<div className="space-y-2">
<Label htmlFor="days-input">Numero Giorni Consecutivi</Label>
<Input
id="days-input"
type="number"
min={1}
max={7}
value={days}
onChange={(e) => setDays(Math.max(1, Math.min(7, parseInt(e.target.value) || 1)))}
disabled={createShiftMutation.isPending}
data-testid="input-days"
/>
<p className="text-xs text-muted-foreground">
Il turno verrà creato a partire da {selectedCell && format(new Date(selectedCell.date), "dd/MM/yyyy")} per {days} {days === 1 ? "giorno" : "giorni"}
</p>
</div>
{/* Bottone crea turno */}
<Button <Button
onClick={handleAssignGuard} onClick={handleCreateShift}
disabled={!selectedGuardId || assignGuardMutation.isPending || (availableGuards && availableGuards.length === 0)} disabled={!selectedGuardId || createShiftMutation.isPending || (availableGuards && availableGuards.length === 0)}
data-testid="button-assign-guard" data-testid="button-create-shift"
className="w-full" className="w-full"
> >
{assignGuardMutation.isPending ? ( {createShiftMutation.isPending ? (
"Assegnazione in corso..." "Creazione in corso..."
) : ( ) : (
<> <>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Assegna Guardia Crea Turno ({days} {days === 1 ? "giorno" : "giorni"})
</> </>
)} )}
</Button> </Button>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@ -294,33 +294,26 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// Get guards availability for general planning with time slot conflict detection // Get guards availability for general planning
app.get("/api/guards/availability", isAuthenticated, async (req, res) => { app.get("/api/guards/availability", isAuthenticated, async (req, res) => {
try { try {
const { start, end, siteId, location } = req.query; const { weekStart, siteId, location } = req.query;
if (!start || !end || !siteId || !location) { if (!weekStart || !siteId || !location) {
return res.status(400).json({ return res.status(400).json({
message: "Missing required parameters: start, end, siteId, location" message: "Missing required parameters: weekStart, siteId, location"
}); });
} }
const startDate = parseISO(start as string); const weekStartDate = parseISO(weekStart as string);
const endDate = parseISO(end as string); if (!isValid(weekStartDate)) {
return res.status(400).json({ message: "Invalid weekStart date format" });
if (!isValid(startDate) || !isValid(endDate)) {
return res.status(400).json({ message: "Invalid date format for start or end" });
}
if (endDate <= startDate) {
return res.status(400).json({ message: "End time must be after start time" });
} }
const availability = await storage.getGuardsAvailability( const availability = await storage.getGuardsAvailability(
weekStartDate,
siteId as string, siteId as string,
location as string, location as string
startDate,
endDate
); );
res.json(availability); res.json(availability);
@ -1177,151 +1170,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// Assign guard to site/date with specific time slot
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
try {
const { siteId, date, guardId, startTime, durationHours } = req.body;
if (!siteId || !date || !guardId || !startTime || !durationHours) {
return res.status(400).json({
message: "Missing required fields: siteId, date, guardId, startTime, durationHours"
});
}
// Get site to check contract and service details
const site = await storage.getSite(siteId);
if (!site) {
return res.status(404).json({ message: "Site not found" });
}
// Get guard to verify it exists
const guard = await storage.getGuard(guardId);
if (!guard) {
return res.status(404).json({ message: "Guard not found" });
}
// Parse date and time
const shiftDate = parseISO(date);
if (!isValid(shiftDate)) {
return res.status(400).json({ message: "Invalid date format" });
}
// Calculate planned start and end times
const [hours, minutes] = startTime.split(":").map(Number);
const plannedStart = new Date(shiftDate);
plannedStart.setHours(hours, minutes, 0, 0);
const plannedEnd = new Date(plannedStart);
plannedEnd.setHours(plannedStart.getHours() + durationHours);
// Check contract validity
if (site.contractStartDate && site.contractEndDate) {
const contractStart = new Date(site.contractStartDate);
const contractEnd = new Date(site.contractEndDate);
if (shiftDate < contractStart || shiftDate > contractEnd) {
return res.status(400).json({
message: `Cannot assign guard: date outside contract period`
});
}
}
// Atomic transaction: find/create shift + verify no overlaps + create assignment
const result = await db.transaction(async (tx) => {
// Find or create shift for this site/date
const dayStart = new Date(shiftDate);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(shiftDate);
dayEnd.setHours(23, 59, 59, 999);
let existingShifts = await tx
.select()
.from(shifts)
.where(
and(
eq(shifts.siteId, siteId),
gte(shifts.startTime, dayStart),
lte(shifts.startTime, dayEnd)
)
);
let shift;
if (existingShifts.length > 0) {
shift = existingShifts[0];
} else {
// Create new shift for full service period
const serviceStart = site.serviceStartTime || "00:00";
const serviceEnd = site.serviceEndTime || "23:59";
const [startHour, startMin] = serviceStart.split(":").map(Number);
const [endHour, endMin] = serviceEnd.split(":").map(Number);
const shiftStart = new Date(shiftDate);
shiftStart.setHours(startHour, startMin, 0, 0);
const shiftEnd = new Date(shiftDate);
shiftEnd.setHours(endHour, endMin, 0, 0);
if (shiftEnd <= shiftStart) {
shiftEnd.setDate(shiftEnd.getDate() + 1);
}
[shift] = await tx.insert(shifts).values({
siteId: site.id,
startTime: shiftStart,
endTime: shiftEnd,
shiftType: site.shiftType || "fixed_post",
status: "planned",
}).returning();
}
// Recheck overlaps within transaction to prevent race conditions
const existingAssignments = await tx
.select()
.from(shiftAssignments)
.where(eq(shiftAssignments.guardId, guard.id));
for (const existing of existingAssignments) {
const hasOverlap =
plannedStart < existing.plannedEndTime &&
plannedEnd > existing.plannedStartTime;
if (hasOverlap) {
throw new Error(
`Conflitto: guardia già assegnata ${existing.plannedStartTime.toLocaleString()} - ${existing.plannedEndTime.toLocaleString()}`
);
}
}
// Create assignment within transaction
const [assignment] = await tx.insert(shiftAssignments).values({
shiftId: shift.id,
guardId: guard.id,
plannedStartTime: plannedStart,
plannedEndTime: plannedEnd,
}).returning();
return { assignment, shift };
});
res.json({
message: "Guard assigned successfully",
assignment: result.assignment,
shift: result.shift
});
} catch (error: any) {
console.error("Error assigning guard:", error);
// Check for overlap/conflict errors (both English and Italian)
const errorMessage = error.message?.toLowerCase() || '';
if (errorMessage.includes('overlap') ||
errorMessage.includes('conflict') ||
errorMessage.includes('conflitto') ||
errorMessage.includes('già assegnata')) {
return res.status(409).json({ message: error.message });
}
res.status(500).json({ message: "Failed to assign guard", error: String(error) });
}
});
// ============= CERTIFICATION ROUTES ============= // ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => { app.post("/api/certifications", isAuthenticated, async (req, res) => {
try { try {
@ -1833,48 +1681,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// Create shift assignment with planned time slots
app.post("/api/shifts/:shiftId/assignments", isAuthenticated, async (req, res) => {
try {
const { shiftId } = req.params;
const { guardId, plannedStartTime, plannedEndTime } = req.body;
if (!guardId || !plannedStartTime || !plannedEndTime) {
return res.status(400).json({
message: "Missing required fields: guardId, plannedStartTime, plannedEndTime"
});
}
// Validate times
const startDate = new Date(plannedStartTime);
const endDate = new Date(plannedEndTime);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return res.status(400).json({ message: "Invalid date format for plannedStartTime or plannedEndTime" });
}
if (endDate <= startDate) {
return res.status(400).json({ message: "plannedEndTime must be after plannedStartTime" });
}
// Create assignment
const assignment = await storage.createShiftAssignment({
shiftId,
guardId,
plannedStartTime: startDate,
plannedEndTime: endDate,
});
res.json(assignment);
} catch (error: any) {
console.error("Error creating shift assignment with time slot:", error);
if (error.message?.includes('overlap') || error.message?.includes('conflict')) {
return res.status(409).json({ message: error.message });
}
res.status(500).json({ message: "Failed to create shift assignment" });
}
});
// ============= NOTIFICATION ROUTES ============= // ============= NOTIFICATION ROUTES =============
app.get("/api/notifications", isAuthenticated, async (req: any, res) => { app.get("/api/notifications", isAuthenticated, async (req: any, res) => {
try { try {

View File

@ -157,15 +157,7 @@ export interface IStorage {
deleteCcnlSetting(key: string): Promise<void>; deleteCcnlSetting(key: string): Promise<void>;
// General Planning operations // General Planning operations
getGuardsAvailability( getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]>;
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 { export class DatabaseStorage implements IStorage {
@ -676,24 +668,9 @@ export class DatabaseStorage implements IStorage {
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key)); await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
} }
// General Planning operations with time slot conflict detection // General Planning operations
async getGuardsAvailability( async getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]> {
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); const weekEnd = addDays(weekStart, 6);
weekEnd.setHours(23, 59, 59, 999);
// Get max weekly hours from CCNL settings (default 45h) // Get max weekly hours from CCNL settings (default 45h)
const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours'); const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours');
@ -712,104 +689,64 @@ export class DatabaseStorage implements IStorage {
.where(eq(guards.location, location as any)); .where(eq(guards.location, location as any));
// Filter guards by site requirements // Filter guards by site requirements
const eligibleGuards = allGuards.filter((guard: Guard) => { const eligibleGuards = allGuards.filter(guard => {
if (site.requiresArmed && !guard.isArmed) return false; if (site.requiresArmed && !guard.isArmed) return false;
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false; if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
return true; return true;
}); });
// Analyze each guard's availability // Calculate weekly hours for each guard
const guardsWithAvailability: GuardAvailability[] = []; const guardsWithHours: GuardAvailability[] = [];
for (const guard of eligibleGuards) { for (const guard of eligibleGuards) {
// Get all shift assignments for this guard in the week (for weekly hours) // Get all shift assignments for this guard in the week
const weeklyAssignments = await db const assignments = await db
.select({ .select({
id: shiftAssignments.id,
shiftId: shiftAssignments.shiftId, shiftId: shiftAssignments.shiftId,
plannedStartTime: shiftAssignments.plannedStartTime, startTime: shifts.startTime,
plannedEndTime: shiftAssignments.plannedEndTime, endTime: shifts.endTime,
}) })
.from(shiftAssignments) .from(shiftAssignments)
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
.where( .where(
and( and(
eq(shiftAssignments.guardId, guard.id), eq(shiftAssignments.guardId, guard.id),
gte(shiftAssignments.plannedStartTime, weekStart), gte(shifts.startTime, weekStart),
lte(shiftAssignments.plannedStartTime, weekEnd) lte(shifts.startTime, weekEnd)
) )
); );
// Calculate total weekly hours assigned // Calculate total hours assigned
let weeklyHoursAssigned = 0; let weeklyHoursAssigned = 0;
for (const assignment of weeklyAssignments) { for (const assignment of assignments) {
const hours = differenceInHours(assignment.plannedEndTime, assignment.plannedStartTime); const hours = differenceInHours(assignment.endTime, assignment.startTime);
weeklyHoursAssigned += hours; weeklyHoursAssigned += hours;
} }
const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned; const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned;
const requestedHours = differenceInHours(plannedEnd, plannedStart);
// Check for time conflicts with the requested slot // Only include guards with remaining hours
const conflicts = []; if (weeklyHoursRemaining > 0) {
const reasons: string[] = []; const user = guard.userId ? await this.getUser(guard.userId) : undefined;
const guardName = user
? `${user.firstName || ''} ${user.lastName || ''}`.trim() || 'N/A'
: 'N/A';
for (const assignment of weeklyAssignments) { guardsWithHours.push({
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, guardId: guard.id,
guardName, guardName,
badgeNumber: guard.badgeNumber, badgeNumber: guard.badgeNumber,
weeklyHoursRemaining, weeklyHoursRemaining,
weeklyHoursAssigned, weeklyHoursAssigned,
weeklyHoursMax: maxWeeklyHours, weeklyHoursMax: maxWeeklyHours,
isAvailable,
conflicts,
unavailabilityReasons: reasons,
}); });
} }
}
// Sort: available first, then by remaining hours (descending) // Sort by remaining hours (descending)
guardsWithAvailability.sort((a, b) => { guardsWithHours.sort((a, b) => b.weeklyHoursRemaining - a.weeklyHoursRemaining);
if (a.isAvailable && !b.isAvailable) return -1;
if (!a.isAvailable && b.isAvailable) return 1;
return b.weeklyHoursRemaining - a.weeklyHoursRemaining;
});
return guardsWithAvailability; return guardsWithHours;
} }
} }

View File

@ -257,14 +257,10 @@ export const shiftAssignments = pgTable("shift_assignments", {
shiftId: varchar("shift_id").notNull().references(() => shifts.id, { onDelete: "cascade" }), shiftId: varchar("shift_id").notNull().references(() => shifts.id, { onDelete: "cascade" }),
guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }), guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }),
// Planned shift times (when the guard is scheduled to work)
plannedStartTime: timestamp("planned_start_time").notNull(),
plannedEndTime: timestamp("planned_end_time").notNull(),
assignedAt: timestamp("assigned_at").defaultNow(), assignedAt: timestamp("assigned_at").defaultNow(),
confirmedAt: timestamp("confirmed_at"), confirmedAt: timestamp("confirmed_at"),
// Actual check-in/out times (recorded when guard clocks in/out) // Actual check-in/out times
checkInTime: timestamp("check_in_time"), checkInTime: timestamp("check_in_time"),
checkOutTime: timestamp("check_out_time"), checkOutTime: timestamp("check_out_time"),
}); });
@ -704,24 +700,9 @@ export const insertShiftFormSchema = z.object({
status: z.enum(["planned", "active", "completed", "cancelled"]).default("planned"), status: z.enum(["planned", "active", "completed", "cancelled"]).default("planned"),
}); });
export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments) export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments).omit({
.omit({
id: true, id: true,
assignedAt: true, assignedAt: true,
})
.extend({
plannedStartTime: z.union([z.string(), z.date()], {
required_error: "Orario inizio richiesto",
invalid_type_error: "Orario inizio non valido",
}).transform((val) => new Date(val)),
plannedEndTime: z.union([z.string(), z.date()], {
required_error: "Orario fine richiesto",
invalid_type_error: "Orario fine non valido",
}).transform((val) => new Date(val)),
})
.refine((data) => data.plannedEndTime > data.plannedStartTime, {
message: "L'orario di fine deve essere successivo all'orario di inizio",
path: ["plannedEndTime"],
}); });
export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({ export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({
@ -871,15 +852,7 @@ export type AbsenceWithDetails = Absence & {
// ============= DTOs FOR GENERAL PLANNING ============= // ============= DTOs FOR GENERAL PLANNING =============
// DTO per conflitto orario guardia // DTO per disponibilità guardia nella settimana
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({ export const guardAvailabilitySchema = z.object({
guardId: z.string(), guardId: z.string(),
guardName: z.string(), guardName: z.string(),
@ -887,12 +860,8 @@ export const guardAvailabilitySchema = z.object({
weeklyHoursRemaining: z.number(), weeklyHoursRemaining: z.number(),
weeklyHoursAssigned: z.number(), weeklyHoursAssigned: z.number(),
weeklyHoursMax: 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>; export type GuardAvailability = z.infer<typeof guardAvailabilitySchema>;
// DTO per creazione turno multi-giorno dal Planning Generale // DTO per creazione turno multi-giorno dal Planning Generale

View File

@ -1,13 +1,7 @@
{ {
"version": "1.0.25", "version": "1.0.24",
"lastUpdate": "2025-10-21T14:11:11.154Z", "lastUpdate": "2025-10-18T10:25:34.931Z",
"changelog": [ "changelog": [
{
"version": "1.0.25",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.25"
},
{ {
"version": "1.0.24", "version": "1.0.24",
"date": "2025-10-18", "date": "2025-10-18",