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
|
||||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
localPort = 33659
|
||||
externalPort = 5000
|
||||
|
||||
[[ports]]
|
||||
localPort = 41343
|
||||
externalPort = 3000
|
||||
|
||||
[[ports]]
|
||||
localPort = 41803
|
||||
externalPort = 4200
|
||||
|
||||
[[ports]]
|
||||
localPort = 42175
|
||||
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);
|
||||
},
|
||||
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",
|
||||
description: error.message || "Impossibile assegnare la guardia",
|
||||
title: "Errore Assegnazione",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
@ -653,7 +670,9 @@ export default function GeneralPlanning() {
|
||||
</p>
|
||||
{guard.conflicts && guard.conflicts.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{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)
|
||||
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 db
|
||||
.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";
|
||||
// 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);
|
||||
|
||||
const [startHour, startMin] = serviceStart.split(":").map(Number);
|
||||
const [endHour, endMin] = serviceEnd.split(":").map(Number);
|
||||
let existingShifts = await tx
|
||||
.select()
|
||||
.from(shifts)
|
||||
.where(
|
||||
and(
|
||||
eq(shifts.siteId, siteId),
|
||||
gte(shifts.startTime, dayStart),
|
||||
lte(shifts.startTime, dayEnd)
|
||||
)
|
||||
);
|
||||
|
||||
const shiftStart = new Date(shiftDate);
|
||||
shiftStart.setHours(startHour, startMin, 0, 0);
|
||||
|
||||
const shiftEnd = new Date(shiftDate);
|
||||
shiftEnd.setHours(endHour, endMin, 0, 0);
|
||||
|
||||
// If service ends before it starts, it spans midnight
|
||||
if (shiftEnd <= shiftStart) {
|
||||
shiftEnd.setDate(shiftEnd.getDate() + 1);
|
||||
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();
|
||||
}
|
||||
|
||||
[shift] = await db.insert(shifts).values({
|
||||
siteId: site.id,
|
||||
startTime: shiftStart,
|
||||
endTime: shiftEnd,
|
||||
shiftType: site.shiftType || "fixed_post",
|
||||
status: "planned",
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Create shift assignment with planned time slot
|
||||
const assignment = await storage.createShiftAssignment({
|
||||
shiftId: shift.id,
|
||||
guardId: guard.id,
|
||||
plannedStartTime: plannedStart,
|
||||
plannedEndTime: plannedEnd,
|
||||
|
||||
return { assignment, shift };
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "Guard assigned successfully",
|
||||
assignment,
|
||||
shift
|
||||
assignment: result.assignment,
|
||||
shift: result.shift
|
||||
});
|
||||
} catch (error: any) {
|
||||
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 });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to assign guard", error: String(error) });
|
||||
|
||||