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
This commit is contained in:
marco370 2025-10-21 06:42:54 +00:00
parent c95bf04abf
commit 3d80f75f43
3 changed files with 246 additions and 53 deletions

View File

@ -19,10 +19,6 @@ externalPort = 80
localPort = 33035
externalPort = 3001
[[ports]]
localPort = 38383
externalPort = 4200
[[ports]]
localPort = 41343
externalPort = 3000

View File

@ -75,9 +75,10 @@ export default function GeneralPlanning() {
const [weekStart, setWeekStart] = useState<Date>(() => 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<string>("");
const [days, setDays] = useState<number>(1);
const [startTime, setStartTime] = useState<string>("06:00");
const [durationHours, setDurationHours] = useState<number>(8);
// Query per dati planning settimanale
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
@ -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<GuardAvailability[]>({
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() {
</div>
)}
{/* Form creazione nuovo turno */}
{/* Form assegnazione guardia */}
<div className="border-t pt-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold">
<Plus className="h-4 w-4" />
Crea Nuovo Turno
Assegna Guardia
</div>
<div className="grid gap-4">
{/* Ora Inizio e Durata */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start-time">Ora Inizio</Label>
<Input
id="start-time"
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
disabled={assignGuardMutation.isPending}
data-testid="input-start-time"
/>
</div>
<div className="space-y-2">
<Label htmlFor="duration">Durata (ore)</Label>
<Input
id="duration"
type="number"
min={1}
max={24}
value={durationHours}
onChange={(e) => setDurationHours(Math.max(1, Math.min(24, parseInt(e.target.value) || 8)))}
disabled={assignGuardMutation.isPending}
data-testid="input-duration"
/>
</div>
</div>
{/* Ora fine calcolata */}
<div className="text-xs text-muted-foreground">
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")}`;
})()}
</div>
{/* Select guardia disponibile */}
<div className="space-y-2">
<Label htmlFor="guard-select">Guardia Disponibile</Label>
@ -542,7 +620,7 @@ export default function GeneralPlanning() {
<Select
value={selectedGuardId}
onValueChange={setSelectedGuardId}
disabled={createShiftMutation.isPending}
disabled={assignGuardMutation.isPending}
>
<SelectTrigger id="guard-select" data-testid="select-guard">
<SelectValue placeholder="Seleziona guardia..." />
@ -552,6 +630,7 @@ export default function GeneralPlanning() {
availableGuards.map((guard) => (
<SelectItem key={guard.guardId} value={guard.guardId}>
{guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili
{guard.conflicts && guard.conflicts.length > 0 && " ⚠️"}
</SelectItem>
))
) : (
@ -563,46 +642,45 @@ export default function GeneralPlanning() {
</Select>
)}
{availableGuards && availableGuards.length > 0 && selectedGuardId && (
<p className="text-xs text-muted-foreground">
<div className="text-xs space-y-1">
{(() => {
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 (
<>
<p className="text-muted-foreground">
Ore assegnate: {guard.weeklyHoursAssigned}h / {guard.weeklyHoursMax}h (rimangono {guard.weeklyHoursRemaining}h)
</p>
{guard.conflicts && guard.conflicts.length > 0 && (
<p className="text-destructive font-medium">
Conflitto: {guard.conflicts.join(", ")}
</p>
)}
{guard.unavailabilityReasons && guard.unavailabilityReasons.length > 0 && (
<p className="text-yellow-600 dark:text-yellow-500">
{guard.unavailabilityReasons.join(", ")}
</p>
)}
</>
);
})()}
</p>
</div>
)}
</div>
{/* Input numero giorni */}
<div className="space-y-2">
<Label htmlFor="days-input">Numero Giorni Consecutivi</Label>
<Input
id="days-input"
type="number"
min={1}
max={7}
value={days}
onChange={(e) => setDays(Math.max(1, Math.min(7, parseInt(e.target.value) || 1)))}
disabled={createShiftMutation.isPending}
data-testid="input-days"
/>
<p className="text-xs text-muted-foreground">
Il turno verrà creato a partire da {selectedCell && format(new Date(selectedCell.date), "dd/MM/yyyy")} per {days} {days === 1 ? "giorno" : "giorni"}
</p>
</div>
{/* Bottone crea turno */}
{/* Bottone assegna */}
<Button
onClick={handleCreateShift}
disabled={!selectedGuardId || createShiftMutation.isPending || (availableGuards && availableGuards.length === 0)}
data-testid="button-create-shift"
onClick={handleAssignGuard}
disabled={!selectedGuardId || assignGuardMutation.isPending || (availableGuards && availableGuards.length === 0)}
data-testid="button-assign-guard"
className="w-full"
>
{createShiftMutation.isPending ? (
"Creazione in corso..."
{assignGuardMutation.isPending ? (
"Assegnazione in corso..."
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Crea Turno ({days} {days === 1 ? "giorno" : "giorni"})
Assegna Guardia
</>
)}
</Button>

View File

@ -1177,6 +1177,125 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// 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 {