Add ability to assign guards for multiple consecutive days

Adds support for multi-day guard assignments by modifying the assign-guard API endpoint and client-side logic to accept and process a `consecutiveDays` parameter.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/U8icLKT
This commit is contained in:
marco370 2025-10-21 14:40:16 +00:00
parent 62f8189e7d
commit 100f20e422
3 changed files with 156 additions and 99 deletions

View File

@ -31,6 +31,10 @@ externalPort = 4200
localPort = 42175
externalPort = 3002
[[ports]]
localPort = 43169
externalPort = 5000
[[ports]]
localPort = 43267
externalPort = 3003

View File

@ -79,6 +79,7 @@ export default function GeneralPlanning() {
const [selectedGuardId, setSelectedGuardId] = useState<string>("");
const [startTime, setStartTime] = useState<string>("06:00");
const [durationHours, setDurationHours] = useState<number>(8);
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
// Query per dati planning settimanale
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
@ -123,9 +124,9 @@ export default function GeneralPlanning() {
enabled: !!selectedCell, // Query attiva solo se dialog è aperto
});
// Mutation per assegnare guardia con orari
// Mutation per assegnare guardia con orari (anche multi-giorno)
const assignGuardMutation = useMutation({
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number }) => {
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number }) => {
return apiRequest("POST", "/api/general-planning/assign-guard", data);
},
onSuccess: () => {
@ -201,6 +202,7 @@ export default function GeneralPlanning() {
guardId: selectedGuardId,
startTime,
durationHours,
consecutiveDays,
});
};
@ -493,7 +495,13 @@ export default function GeneralPlanning() {
</Card>
{/* Dialog dettagli cella */}
<Dialog open={!!selectedCell} onOpenChange={() => setSelectedCell(null)}>
<Dialog open={!!selectedCell} onOpenChange={() => {
setSelectedCell(null);
setSelectedGuardId("");
setStartTime("06:00");
setDurationHours(8);
setConsecutiveDays(1);
}}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@ -585,14 +593,31 @@ export default function GeneralPlanning() {
{/* Form assegnazione guardia */}
<div className="border-t pt-4 space-y-4">
{/* Mostra guardie già assegnate per questo giorno */}
{selectedCell.data.guards.length > 0 && (
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Guardie già assegnate per questa data:
</p>
<div className="grid gap-1.5">
{selectedCell.data.guards.map((guard, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-background p-2 rounded">
<span className="font-medium">{guard.guardName} <Badge variant="outline" className="ml-1 text-xs">#{guard.badgeNumber}</Badge></span>
<span className="text-muted-foreground">{guard.hours}h</span>
</div>
))}
</div>
</div>
)}
<div className="flex items-center gap-2 text-sm font-semibold">
<Plus className="h-4 w-4" />
Assegna Guardia
Assegna Nuova Guardia
</div>
<div className="grid gap-4">
{/* Ora Inizio e Durata */}
<div className="grid grid-cols-2 gap-4">
{/* Ora Inizio, Durata e Giorni */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="start-time">Ora Inizio</Label>
<Input
@ -617,6 +642,19 @@ export default function GeneralPlanning() {
data-testid="input-duration"
/>
</div>
<div className="space-y-2">
<Label htmlFor="consecutive-days">Giorni</Label>
<Input
id="consecutive-days"
type="number"
min={1}
max={30}
value={consecutiveDays}
onChange={(e) => setConsecutiveDays(Math.max(1, Math.min(30, parseInt(e.target.value) || 1)))}
disabled={assignGuardMutation.isPending}
data-testid="input-consecutive-days"
/>
</div>
</div>
{/* Ora fine calcolata */}

View File

@ -1177,10 +1177,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Assign guard to site/date with specific time slot
// Assign guard to site/date with specific time slot (supports multi-day assignments)
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
try {
const { siteId, date, guardId, startTime, durationHours } = req.body;
const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1 } = req.body;
if (!siteId || !date || !guardId || !startTime || !durationHours) {
return res.status(400).json({
@ -1188,6 +1188,10 @@ export async function registerRoutes(app: Express): Promise<Server> {
});
}
if (consecutiveDays < 1 || consecutiveDays > 30) {
return res.status(400).json({ message: "consecutiveDays must be between 1 and 30" });
}
// Get site to check contract and service details
const site = await storage.getSite(siteId);
if (!site) {
@ -1200,113 +1204,124 @@ export async function registerRoutes(app: Express): Promise<Server> {
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" });
// Parse start date WITHOUT timezone conversion (stay in local time)
const [year, month, day] = date.split("-").map(Number);
if (!year || !month || !day || month < 1 || month > 12 || day < 1 || day > 31) {
return res.status(400).json({ message: "Invalid date format. Expected YYYY-MM-DD" });
}
// 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
// Atomic transaction: create assignments for all consecutive days
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 createdAssignments = [];
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";
// Loop through each consecutive day
for (let dayOffset = 0; dayOffset < consecutiveDays; dayOffset++) {
// Calculate date for this iteration
const currentDate = new Date(year, month - 1, day + dayOffset);
const shiftDate = new Date(currentDate);
shiftDate.setHours(0, 0, 0, 0);
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);
// Check contract validity for this date
if (site.contractStartDate && site.contractEndDate) {
const contractStart = new Date(site.contractStartDate);
const contractEnd = new Date(site.contractEndDate);
if (shiftDate < contractStart || shiftDate > contractEnd) {
throw new Error(
`Cannot assign guard for date ${shiftDate.toLocaleDateString()}: outside contract period`
);
}
}
[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;
// Calculate planned start and end times for this day
const plannedStart = new Date(currentDate);
plannedStart.setHours(hours, minutes, 0, 0);
const plannedEnd = new Date(currentDate);
plannedEnd.setHours(hours + durationHours, minutes, 0, 0);
if (hasOverlap) {
throw new Error(
`Conflitto: guardia già assegnata ${existing.plannedStartTime.toLocaleString()} - ${existing.plannedEndTime.toLocaleString()}`
// 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 for this day
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 for this day
const [assignment] = await tx.insert(shiftAssignments).values({
shiftId: shift.id,
guardId: guard.id,
plannedStartTime: plannedStart,
plannedEndTime: plannedEnd,
}).returning();
createdAssignments.push(assignment);
}
// 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 };
return { assignments: createdAssignments, count: createdAssignments.length };
});
res.json({
message: "Guard assigned successfully",
assignment: result.assignment,
shift: result.shift
message: `Guard assigned successfully for ${result.count} day(s)`,
assignments: result.assignments,
count: result.count
});
} catch (error: any) {
console.error("Error assigning guard:", error);