From 3d80f75f435cdac6966ebd4f34c65c083e53ad33 Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Tue, 21 Oct 2025 06:42:54 +0000 Subject: [PATCH] Update planning to assign guards with specific times and durations Introduce new functionality to assign guards to specific time slots within shifts, modifying the UI and backend to handle startTime and durationHours. 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/ZZOTK7r --- .replit | 4 - client/src/pages/general-planning.tsx | 176 +++++++++++++++++++------- server/routes.ts | 119 +++++++++++++++++ 3 files changed, 246 insertions(+), 53 deletions(-) diff --git a/.replit b/.replit index 269b307..c50bc15 100644 --- a/.replit +++ b/.replit @@ -19,10 +19,6 @@ externalPort = 80 localPort = 33035 externalPort = 3001 -[[ports]] -localPort = 38383 -externalPort = 4200 - [[ports]] localPort = 41343 externalPort = 3000 diff --git a/client/src/pages/general-planning.tsx b/client/src/pages/general-planning.tsx index db0b198..e7283b6 100644 --- a/client/src/pages/general-planning.tsx +++ b/client/src/pages/general-planning.tsx @@ -75,9 +75,10 @@ export default function GeneralPlanning() { const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date(), { weekStartsOn: 1 })); const [selectedCell, setSelectedCell] = useState<{ siteId: string; siteName: string; date: string; data: SiteData } | null>(null); - // Form state per creazione turno + // Form state per assegnazione guardia const [selectedGuardId, setSelectedGuardId] = useState(""); - const [days, setDays] = useState(1); + const [startTime, setStartTime] = useState("06:00"); + const [durationHours, setDurationHours] = useState(8); // Query per dati planning settimanale const { data: planningData, isLoading } = useQuery({ @@ -91,13 +92,30 @@ 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) const { data: availableGuards, isLoading: isLoadingGuards } = useQuery({ - queryKey: ["/api/guards/availability", format(weekStart, "yyyy-MM-dd"), selectedCell?.siteId, selectedLocation], + queryKey: ["/api/guards/availability", selectedCell?.siteId, selectedLocation, startTime, durationHours], queryFn: async () => { if (!selectedCell) return []; + const { start, end } = getTimeSlot(); const response = await fetch( - `/api/guards/availability?weekStart=${format(weekStart, "yyyy-MM-dd")}&siteId=${selectedCell.siteId}&location=${selectedLocation}` + `/api/guards/availability?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&siteId=${selectedCell.siteId}&location=${selectedLocation}` ); if (!response.ok) throw new Error("Failed to fetch guards availability"); return response.json(); @@ -105,10 +123,10 @@ export default function GeneralPlanning() { enabled: !!selectedCell, // Query attiva solo se dialog è aperto }); - // Mutation per creare turno multi-giorno - const createShiftMutation = useMutation({ - mutationFn: async (data: { siteId: string; startDate: string; days: number; guardId: string }) => { - return apiRequest("POST", "/api/general-planning/shifts", data); + // Mutation per assegnare guardia con orari + const assignGuardMutation = useMutation({ + mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number }) => { + return apiRequest("POST", "/api/general-planning/assign-guard", data); }, onSuccess: () => { // Invalida cache planning generale @@ -116,33 +134,56 @@ export default function GeneralPlanning() { queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] }); toast({ - title: "Turno creato", - description: "Il turno è stato creato con successo", + title: "Guardia assegnata", + description: "La guardia è stata assegnata con successo", }); - // Reset form e chiudi dialog + // Reset form setSelectedGuardId(""); - setDays(1); setSelectedCell(null); }, onError: (error: any) => { toast({ title: "Errore", - description: error.message || "Impossibile creare il turno", + description: error.message || "Impossibile assegnare la guardia", variant: "destructive", }); }, }); - // Handler per submit form creazione turno - const handleCreateShift = () => { + // 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) => { + toast({ + title: "Errore", + description: error.message || "Impossibile deassegnare la guardia", + variant: "destructive", + }); + }, + }); + + // Handler per submit form assegnazione guardia + const handleAssignGuard = () => { if (!selectedCell || !selectedGuardId) return; - createShiftMutation.mutate({ + assignGuardMutation.mutate({ siteId: selectedCell.siteId, - startDate: selectedCell.date, - days, + date: selectedCell.date, guardId: selectedGuardId, + startTime, + durationHours, }); }; @@ -525,14 +566,51 @@ export default function GeneralPlanning() { )} - {/* Form creazione nuovo turno */} + {/* Form assegnazione guardia */}
- Crea Nuovo Turno + Assegna Guardia
+ {/* Ora Inizio e Durata */} +
+
+ + setStartTime(e.target.value)} + disabled={assignGuardMutation.isPending} + data-testid="input-start-time" + /> +
+
+ + setDurationHours(Math.max(1, Math.min(24, parseInt(e.target.value) || 8)))} + disabled={assignGuardMutation.isPending} + data-testid="input-duration" + /> +
+
+ + {/* Ora fine calcolata */} +
+ 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")}`; + })()} +
+ {/* Select guardia disponibile */}
@@ -542,7 +620,7 @@ export default function GeneralPlanning() { )} {availableGuards && availableGuards.length > 0 && selectedGuardId && ( -

+

{(() => { const guard = availableGuards.find(g => g.guardId === selectedGuardId); - return guard ? `Ore assegnate: ${guard.weeklyHoursAssigned}h / ${guard.weeklyHoursMax}h (rimangono ${guard.weeklyHoursRemaining}h)` : ""; + if (!guard) return null; + return ( + <> +

+ Ore assegnate: {guard.weeklyHoursAssigned}h / {guard.weeklyHoursMax}h (rimangono {guard.weeklyHoursRemaining}h) +

+ {guard.conflicts && guard.conflicts.length > 0 && ( +

+ ⚠️ Conflitto: {guard.conflicts.join(", ")} +

+ )} + {guard.unavailabilityReasons && guard.unavailabilityReasons.length > 0 && ( +

+ {guard.unavailabilityReasons.join(", ")} +

+ )} + + ); })()} -

+
)}
- {/* Input numero giorni */} -
- - setDays(Math.max(1, Math.min(7, parseInt(e.target.value) || 1)))} - disabled={createShiftMutation.isPending} - data-testid="input-days" - /> -

- Il turno verrà creato a partire da {selectedCell && format(new Date(selectedCell.date), "dd/MM/yyyy")} per {days} {days === 1 ? "giorno" : "giorni"} -

-
- - {/* Bottone crea turno */} + {/* Bottone assegna */} diff --git a/server/routes.ts b/server/routes.ts index f288d0b..4cdde8d 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1177,6 +1177,125 @@ export async function registerRoutes(app: Express): Promise { } }); + // 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` + }); + } + } + + // 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"; + + 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 service ends before it starts, it spans midnight + if (shiftEnd <= shiftStart) { + shiftEnd.setDate(shiftEnd.getDate() + 1); + } + + [shift] = await db.insert(shifts).values({ + siteId: site.id, + startTime: shiftStart, + endTime: shiftEnd, + shiftType: site.shiftType || "fixed_post", + status: "planned", + }).returning(); + } + + // Create shift assignment with planned time slot + const assignment = await storage.createShiftAssignment({ + shiftId: shift.id, + guardId: guard.id, + plannedStartTime: plannedStart, + plannedEndTime: plannedEnd, + }); + + res.json({ + message: "Guard assigned successfully", + assignment, + shift + }); + } catch (error: any) { + console.error("Error assigning guard:", 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 assign guard", error: String(error) }); + } + }); + // ============= CERTIFICATION ROUTES ============= app.post("/api/certifications", isAuthenticated, async (req, res) => { try {