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:
parent
c95bf04abf
commit
3d80f75f43
4
.replit
4
.replit
@ -19,10 +19,6 @@ externalPort = 80
|
|||||||
localPort = 33035
|
localPort = 33035
|
||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 38383
|
|
||||||
externalPort = 4200
|
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 41343
|
localPort = 41343
|
||||||
externalPort = 3000
|
externalPort = 3000
|
||||||
|
|||||||
@ -75,9 +75,10 @@ export default function GeneralPlanning() {
|
|||||||
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
|
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||||
const [selectedCell, setSelectedCell] = useState<{ siteId: string; siteName: string; date: string; data: SiteData } | null>(null);
|
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 [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
|
// Query per dati planning settimanale
|
||||||
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
|
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)
|
// Query per guardie disponibili (solo quando dialog è aperto)
|
||||||
const { data: availableGuards, isLoading: isLoadingGuards } = useQuery<GuardAvailability[]>({
|
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 () => {
|
queryFn: async () => {
|
||||||
if (!selectedCell) return [];
|
if (!selectedCell) return [];
|
||||||
|
const { start, end } = getTimeSlot();
|
||||||
const response = await fetch(
|
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");
|
if (!response.ok) throw new Error("Failed to fetch guards availability");
|
||||||
return response.json();
|
return response.json();
|
||||||
@ -105,10 +123,10 @@ export default function GeneralPlanning() {
|
|||||||
enabled: !!selectedCell, // Query attiva solo se dialog è aperto
|
enabled: !!selectedCell, // Query attiva solo se dialog è aperto
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutation per creare turno multi-giorno
|
// Mutation per assegnare guardia con orari
|
||||||
const createShiftMutation = useMutation({
|
const assignGuardMutation = useMutation({
|
||||||
mutationFn: async (data: { siteId: string; startDate: string; days: number; guardId: string }) => {
|
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number }) => {
|
||||||
return apiRequest("POST", "/api/general-planning/shifts", data);
|
return apiRequest("POST", "/api/general-planning/assign-guard", data);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalida cache planning generale
|
// Invalida cache planning generale
|
||||||
@ -116,33 +134,56 @@ export default function GeneralPlanning() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Turno creato",
|
title: "Guardia assegnata",
|
||||||
description: "Il turno è stato creato con successo",
|
description: "La guardia è stata assegnata con successo",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset form e chiudi dialog
|
// Reset form
|
||||||
setSelectedGuardId("");
|
setSelectedGuardId("");
|
||||||
setDays(1);
|
|
||||||
setSelectedCell(null);
|
setSelectedCell(null);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Errore",
|
title: "Errore",
|
||||||
description: error.message || "Impossibile creare il turno",
|
description: error.message || "Impossibile assegnare la guardia",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handler per submit form creazione turno
|
// Mutation per deassegnare guardia
|
||||||
const handleCreateShift = () => {
|
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;
|
if (!selectedCell || !selectedGuardId) return;
|
||||||
|
|
||||||
createShiftMutation.mutate({
|
assignGuardMutation.mutate({
|
||||||
siteId: selectedCell.siteId,
|
siteId: selectedCell.siteId,
|
||||||
startDate: selectedCell.date,
|
date: selectedCell.date,
|
||||||
days,
|
|
||||||
guardId: selectedGuardId,
|
guardId: selectedGuardId,
|
||||||
|
startTime,
|
||||||
|
durationHours,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -525,14 +566,51 @@ export default function GeneralPlanning() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form creazione nuovo turno */}
|
{/* Form assegnazione guardia */}
|
||||||
<div className="border-t pt-4 space-y-4">
|
<div className="border-t pt-4 space-y-4">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Crea Nuovo Turno
|
Assegna Guardia
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<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 */}
|
{/* Select guardia disponibile */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="guard-select">Guardia Disponibile</Label>
|
<Label htmlFor="guard-select">Guardia Disponibile</Label>
|
||||||
@ -542,7 +620,7 @@ export default function GeneralPlanning() {
|
|||||||
<Select
|
<Select
|
||||||
value={selectedGuardId}
|
value={selectedGuardId}
|
||||||
onValueChange={setSelectedGuardId}
|
onValueChange={setSelectedGuardId}
|
||||||
disabled={createShiftMutation.isPending}
|
disabled={assignGuardMutation.isPending}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="guard-select" data-testid="select-guard">
|
<SelectTrigger id="guard-select" data-testid="select-guard">
|
||||||
<SelectValue placeholder="Seleziona guardia..." />
|
<SelectValue placeholder="Seleziona guardia..." />
|
||||||
@ -552,6 +630,7 @@ export default function GeneralPlanning() {
|
|||||||
availableGuards.map((guard) => (
|
availableGuards.map((guard) => (
|
||||||
<SelectItem key={guard.guardId} value={guard.guardId}>
|
<SelectItem key={guard.guardId} value={guard.guardId}>
|
||||||
{guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili
|
{guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili
|
||||||
|
{guard.conflicts && guard.conflicts.length > 0 && " ⚠️"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@ -563,46 +642,45 @@ export default function GeneralPlanning() {
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
{availableGuards && availableGuards.length > 0 && selectedGuardId && (
|
{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);
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Input numero giorni */}
|
{/* Bottone assegna */}
|
||||||
<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 */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateShift}
|
onClick={handleAssignGuard}
|
||||||
disabled={!selectedGuardId || createShiftMutation.isPending || (availableGuards && availableGuards.length === 0)}
|
disabled={!selectedGuardId || assignGuardMutation.isPending || (availableGuards && availableGuards.length === 0)}
|
||||||
data-testid="button-create-shift"
|
data-testid="button-assign-guard"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{createShiftMutation.isPending ? (
|
{assignGuardMutation.isPending ? (
|
||||||
"Creazione in corso..."
|
"Assegnazione in corso..."
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Crea Turno ({days} {days === 1 ? "giorno" : "giorni"})
|
Assegna Guardia
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
119
server/routes.ts
119
server/routes.ts
@ -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 =============
|
// ============= CERTIFICATION ROUTES =============
|
||||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user