Improve error reporting and conflict visualization for shift assignments

Refactor shift assignment logic to use database transactions and improve error message parsing for assignment failures.

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/NBwOcnR
This commit is contained in:
marco370 2025-10-21 07:44:30 +00:00
parent 3d80f75f43
commit 052cc6896a
13 changed files with 109 additions and 56 deletions

View File

@ -19,10 +19,18 @@ externalPort = 80
localPort = 33035 localPort = 33035
externalPort = 3001 externalPort = 3001
[[ports]]
localPort = 33659
externalPort = 5000
[[ports]] [[ports]]
localPort = 41343 localPort = 41343
externalPort = 3000 externalPort = 3000
[[ports]]
localPort = 41803
externalPort = 4200
[[ports]] [[ports]]
localPort = 42175 localPort = 42175
externalPort = 3002 externalPort = 3002

BIN
after_assign_click.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
after_login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
after_sidebar_toggle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
cell_clicked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -143,9 +143,26 @@ export default function GeneralPlanning() {
setSelectedCell(null); setSelectedCell(null);
}, },
onError: (error: any) => { 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({ toast({
title: "Errore", title: "Errore Assegnazione",
description: error.message || "Impossibile assegnare la guardia", description: errorMessage,
variant: "destructive", variant: "destructive",
}); });
}, },
@ -653,7 +670,9 @@ export default function GeneralPlanning() {
</p> </p>
{guard.conflicts && guard.conflicts.length > 0 && ( {guard.conflicts && guard.conflicts.length > 0 && (
<p className="text-destructive font-medium"> <p className="text-destructive font-medium">
Conflitto: {guard.conflicts.join(", ")} 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> </p>
)} )}
{guard.unavailabilityReasons && guard.unavailabilityReasons.length > 0 && ( {guard.unavailabilityReasons && guard.unavailabilityReasons.length > 0 && (

BIN
filled_assignment_form.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
guard_selected_by_eval.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
planning_page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -1225,71 +1225,97 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
} }
// Find or create shift for this site/date (spanning the full service period) // Atomic transaction: find/create shift + verify no overlaps + create assignment
const dayStart = new Date(shiftDate); const result = await db.transaction(async (tx) => {
dayStart.setHours(0, 0, 0, 0); // Find or create shift for this site/date
const dayEnd = new Date(shiftDate); const dayStart = new Date(shiftDate);
dayEnd.setHours(23, 59, 59, 999); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(shiftDate);
dayEnd.setHours(23, 59, 59, 999);
let existingShifts = await db let existingShifts = await tx
.select() .select()
.from(shifts) .from(shifts)
.where( .where(
and( and(
eq(shifts.siteId, siteId), eq(shifts.siteId, siteId),
gte(shifts.startTime, dayStart), gte(shifts.startTime, dayStart),
lte(shifts.startTime, dayEnd) lte(shifts.startTime, dayEnd)
) )
); );
let shift; let shift;
if (existingShifts.length > 0) { if (existingShifts.length > 0) {
// Use existing shift shift = existingShifts[0];
shift = existingShifts[0]; } else {
} else { // Create new shift for full service period
// Create new shift for full service period const serviceStart = site.serviceStartTime || "00:00";
const serviceStart = site.serviceStartTime || "00:00"; const serviceEnd = site.serviceEndTime || "23:59";
const serviceEnd = site.serviceEndTime || "23:59";
const [startHour, startMin] = serviceStart.split(":").map(Number); const [startHour, startMin] = serviceStart.split(":").map(Number);
const [endHour, endMin] = serviceEnd.split(":").map(Number); const [endHour, endMin] = serviceEnd.split(":").map(Number);
const shiftStart = new Date(shiftDate); const shiftStart = new Date(shiftDate);
shiftStart.setHours(startHour, startMin, 0, 0); shiftStart.setHours(startHour, startMin, 0, 0);
const shiftEnd = new Date(shiftDate); const shiftEnd = new Date(shiftDate);
shiftEnd.setHours(endHour, endMin, 0, 0); shiftEnd.setHours(endHour, endMin, 0, 0);
// If service ends before it starts, it spans midnight if (shiftEnd <= shiftStart) {
if (shiftEnd <= shiftStart) { shiftEnd.setDate(shiftEnd.getDate() + 1);
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();
} }
[shift] = await db.insert(shifts).values({ // Recheck overlaps within transaction to prevent race conditions
siteId: site.id, const existingAssignments = await tx
startTime: shiftStart, .select()
endTime: shiftEnd, .from(shiftAssignments)
shiftType: site.shiftType || "fixed_post", .where(eq(shiftAssignments.guardId, guard.id));
status: "planned",
}).returning();
}
// Create shift assignment with planned time slot for (const existing of existingAssignments) {
const assignment = await storage.createShiftAssignment({ const hasOverlap =
shiftId: shift.id, plannedStart < existing.plannedEndTime &&
guardId: guard.id, plannedEnd > existing.plannedStartTime;
plannedStartTime: plannedStart,
plannedEndTime: plannedEnd, 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({ res.json({
message: "Guard assigned successfully", message: "Guard assigned successfully",
assignment, assignment: result.assignment,
shift shift: result.shift
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error assigning guard:", error); console.error("Error assigning guard:", error);
if (error.message?.includes('overlap') || error.message?.includes('conflict')) { // 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 }); return res.status(409).json({ message: error.message });
} }
res.status(500).json({ message: "Failed to assign guard", error: String(error) }); res.status(500).json({ message: "Failed to assign guard", error: String(error) });