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
8
.replit
@ -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
|
After Width: | Height: | Size: 78 KiB |
BIN
after_login.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
after_programmatic_assign_click.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
after_sidebar_toggle.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
capture_after_assign_check.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
cell_clicked.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
@ -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
|
After Width: | Height: | Size: 75 KiB |
BIN
guard_selected_by_eval.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
overlap_form_filled_eval.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
planning_page.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
132
server/routes.ts
@ -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);
|
||||||
let existingShifts = await db
|
dayEnd.setHours(23, 59, 59, 999);
|
||||||
.select()
|
|
||||||
.from(shifts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(shifts.siteId, siteId),
|
|
||||||
gte(shifts.startTime, dayStart),
|
|
||||||
lte(shifts.startTime, dayEnd)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
let shift;
|
|
||||||
if (existingShifts.length > 0) {
|
|
||||||
// Use existing shift
|
|
||||||
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);
|
let existingShifts = await tx
|
||||||
const [endHour, endMin] = serviceEnd.split(":").map(Number);
|
.select()
|
||||||
|
.from(shifts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(shifts.siteId, siteId),
|
||||||
|
gte(shifts.startTime, dayStart),
|
||||||
|
lte(shifts.startTime, dayEnd)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const shiftStart = new Date(shiftDate);
|
let shift;
|
||||||
shiftStart.setHours(startHour, startMin, 0, 0);
|
if (existingShifts.length > 0) {
|
||||||
|
shift = existingShifts[0];
|
||||||
const shiftEnd = new Date(shiftDate);
|
} else {
|
||||||
shiftEnd.setHours(endHour, endMin, 0, 0);
|
// Create new shift for full service period
|
||||||
|
const serviceStart = site.serviceStartTime || "00:00";
|
||||||
// If service ends before it starts, it spans midnight
|
const serviceEnd = site.serviceEndTime || "23:59";
|
||||||
if (shiftEnd <= shiftStart) {
|
|
||||||
shiftEnd.setDate(shiftEnd.getDate() + 1);
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
[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",
|
|
||||||
|
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();
|
}).returning();
|
||||||
}
|
|
||||||
|
return { assignment, shift };
|
||||||
// Create shift assignment with planned time slot
|
|
||||||
const assignment = await storage.createShiftAssignment({
|
|
||||||
shiftId: shift.id,
|
|
||||||
guardId: guard.id,
|
|
||||||
plannedStartTime: plannedStart,
|
|
||||||
plannedEndTime: plannedEnd,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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) });
|
||||||
|
|||||||