Compare commits
7 Commits
18e219e118
...
62f8189e7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62f8189e7d | ||
|
|
a23b46b9fd | ||
|
|
052cc6896a | ||
|
|
3d80f75f43 | ||
|
|
c95bf04abf | ||
|
|
c72125c68f | ||
|
|
1caf5c4199 |
4
.replit
@ -23,6 +23,10 @@ externalPort = 3001
|
||||
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 |
@ -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,73 @@ 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) => {
|
||||
// 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 creare il turno",
|
||||
title: "Errore Assegnazione",
|
||||
description: errorMessage,
|
||||
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 +583,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 +637,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 +647,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 +659,47 @@ 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.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 && (
|
||||
<p className="text-yellow-600 dark:text-yellow-500">
|
||||
{guard.unavailabilityReasons.join(", ")}
|
||||
</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>
|
||||
|
||||
BIN
database-backups/vigilanzaturni_v1.0.25_20251021_141055.sql.gz
Normal file
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 |
212
server/routes.ts
@ -294,26 +294,33 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Get guards availability for general planning
|
||||
// Get guards availability for general planning with time slot conflict detection
|
||||
app.get("/api/guards/availability", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { weekStart, siteId, location } = req.query;
|
||||
const { start, end, siteId, location } = req.query;
|
||||
|
||||
if (!weekStart || !siteId || !location) {
|
||||
if (!start || !end || !siteId || !location) {
|
||||
return res.status(400).json({
|
||||
message: "Missing required parameters: weekStart, siteId, location"
|
||||
message: "Missing required parameters: start, end, siteId, location"
|
||||
});
|
||||
}
|
||||
|
||||
const weekStartDate = parseISO(weekStart as string);
|
||||
if (!isValid(weekStartDate)) {
|
||||
return res.status(400).json({ message: "Invalid weekStart date format" });
|
||||
const startDate = parseISO(start as string);
|
||||
const endDate = parseISO(end as string);
|
||||
|
||||
if (!isValid(startDate) || !isValid(endDate)) {
|
||||
return res.status(400).json({ message: "Invalid date format for start or end" });
|
||||
}
|
||||
|
||||
if (endDate <= startDate) {
|
||||
return res.status(400).json({ message: "End time must be after start time" });
|
||||
}
|
||||
|
||||
const availability = await storage.getGuardsAvailability(
|
||||
weekStartDate,
|
||||
siteId as string,
|
||||
location as string
|
||||
location as string,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
res.json(availability);
|
||||
@ -1170,6 +1177,151 @@ 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`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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 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();
|
||||
|
||||
return { assignment, shift };
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "Guard assigned successfully",
|
||||
assignment: result.assignment,
|
||||
shift: result.shift
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error assigning guard:", error);
|
||||
// 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) });
|
||||
}
|
||||
});
|
||||
|
||||
// ============= CERTIFICATION ROUTES =============
|
||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
@ -1681,6 +1833,48 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Create shift assignment with planned time slots
|
||||
app.post("/api/shifts/:shiftId/assignments", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { shiftId } = req.params;
|
||||
const { guardId, plannedStartTime, plannedEndTime } = req.body;
|
||||
|
||||
if (!guardId || !plannedStartTime || !plannedEndTime) {
|
||||
return res.status(400).json({
|
||||
message: "Missing required fields: guardId, plannedStartTime, plannedEndTime"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate times
|
||||
const startDate = new Date(plannedStartTime);
|
||||
const endDate = new Date(plannedEndTime);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return res.status(400).json({ message: "Invalid date format for plannedStartTime or plannedEndTime" });
|
||||
}
|
||||
|
||||
if (endDate <= startDate) {
|
||||
return res.status(400).json({ message: "plannedEndTime must be after plannedStartTime" });
|
||||
}
|
||||
|
||||
// Create assignment
|
||||
const assignment = await storage.createShiftAssignment({
|
||||
shiftId,
|
||||
guardId,
|
||||
plannedStartTime: startDate,
|
||||
plannedEndTime: endDate,
|
||||
});
|
||||
|
||||
res.json(assignment);
|
||||
} catch (error: any) {
|
||||
console.error("Error creating shift assignment with time slot:", 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 create shift assignment" });
|
||||
}
|
||||
});
|
||||
|
||||
// ============= NOTIFICATION ROUTES =============
|
||||
app.get("/api/notifications", isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
|
||||
@ -157,7 +157,15 @@ export interface IStorage {
|
||||
deleteCcnlSetting(key: string): Promise<void>;
|
||||
|
||||
// General Planning operations
|
||||
getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]>;
|
||||
getGuardsAvailability(
|
||||
siteId: string,
|
||||
location: string,
|
||||
plannedStart: Date,
|
||||
plannedEnd: Date
|
||||
): Promise<GuardAvailability[]>;
|
||||
|
||||
// Shift Assignment operations with time slot management
|
||||
deleteShiftAssignment(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class DatabaseStorage implements IStorage {
|
||||
@ -668,9 +676,24 @@ export class DatabaseStorage implements IStorage {
|
||||
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
|
||||
}
|
||||
|
||||
// General Planning operations
|
||||
async getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]> {
|
||||
// General Planning operations with time slot conflict detection
|
||||
async getGuardsAvailability(
|
||||
siteId: string,
|
||||
location: string,
|
||||
plannedStart: Date,
|
||||
plannedEnd: Date
|
||||
): Promise<GuardAvailability[]> {
|
||||
// Helper: Check if two time ranges overlap
|
||||
const hasOverlap = (start1: Date, end1: Date, start2: Date, end2: Date): boolean => {
|
||||
return start1 < end2 && end1 > start2;
|
||||
};
|
||||
|
||||
// Calculate week boundaries for weekly hours calculation
|
||||
const weekStart = new Date(plannedStart);
|
||||
weekStart.setDate(plannedStart.getDate() - plannedStart.getDay() + (plannedStart.getDay() === 0 ? -6 : 1));
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
const weekEnd = addDays(weekStart, 6);
|
||||
weekEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
// Get max weekly hours from CCNL settings (default 45h)
|
||||
const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours');
|
||||
@ -689,64 +712,104 @@ export class DatabaseStorage implements IStorage {
|
||||
.where(eq(guards.location, location as any));
|
||||
|
||||
// Filter guards by site requirements
|
||||
const eligibleGuards = allGuards.filter(guard => {
|
||||
const eligibleGuards = allGuards.filter((guard: Guard) => {
|
||||
if (site.requiresArmed && !guard.isArmed) return false;
|
||||
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Calculate weekly hours for each guard
|
||||
const guardsWithHours: GuardAvailability[] = [];
|
||||
// Analyze each guard's availability
|
||||
const guardsWithAvailability: GuardAvailability[] = [];
|
||||
|
||||
for (const guard of eligibleGuards) {
|
||||
// Get all shift assignments for this guard in the week
|
||||
const assignments = await db
|
||||
// Get all shift assignments for this guard in the week (for weekly hours)
|
||||
const weeklyAssignments = await db
|
||||
.select({
|
||||
id: shiftAssignments.id,
|
||||
shiftId: shiftAssignments.shiftId,
|
||||
startTime: shifts.startTime,
|
||||
endTime: shifts.endTime,
|
||||
plannedStartTime: shiftAssignments.plannedStartTime,
|
||||
plannedEndTime: shiftAssignments.plannedEndTime,
|
||||
})
|
||||
.from(shiftAssignments)
|
||||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(shiftAssignments.guardId, guard.id),
|
||||
gte(shifts.startTime, weekStart),
|
||||
lte(shifts.startTime, weekEnd)
|
||||
gte(shiftAssignments.plannedStartTime, weekStart),
|
||||
lte(shiftAssignments.plannedStartTime, weekEnd)
|
||||
)
|
||||
);
|
||||
|
||||
// Calculate total hours assigned
|
||||
// Calculate total weekly hours assigned
|
||||
let weeklyHoursAssigned = 0;
|
||||
for (const assignment of assignments) {
|
||||
const hours = differenceInHours(assignment.endTime, assignment.startTime);
|
||||
for (const assignment of weeklyAssignments) {
|
||||
const hours = differenceInHours(assignment.plannedEndTime, assignment.plannedStartTime);
|
||||
weeklyHoursAssigned += hours;
|
||||
}
|
||||
|
||||
const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned;
|
||||
const requestedHours = differenceInHours(plannedEnd, plannedStart);
|
||||
|
||||
// Only include guards with remaining hours
|
||||
if (weeklyHoursRemaining > 0) {
|
||||
const user = guard.userId ? await this.getUser(guard.userId) : undefined;
|
||||
const guardName = user
|
||||
? `${user.firstName || ''} ${user.lastName || ''}`.trim() || 'N/A'
|
||||
: 'N/A';
|
||||
// Check for time conflicts with the requested slot
|
||||
const conflicts = [];
|
||||
const reasons: string[] = [];
|
||||
|
||||
guardsWithHours.push({
|
||||
for (const assignment of weeklyAssignments) {
|
||||
if (hasOverlap(plannedStart, plannedEnd, assignment.plannedStartTime, assignment.plannedEndTime)) {
|
||||
// Get site name for conflict
|
||||
const [shift] = await db
|
||||
.select({ siteName: sites.name })
|
||||
.from(shifts)
|
||||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||||
.where(eq(shifts.id, assignment.shiftId));
|
||||
|
||||
conflicts.push({
|
||||
from: assignment.plannedStartTime,
|
||||
to: assignment.plannedEndTime,
|
||||
siteName: shift?.siteName || 'Sito sconosciuto',
|
||||
shiftId: assignment.shiftId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Determine availability
|
||||
let isAvailable = true;
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
isAvailable = false;
|
||||
reasons.push(`Già assegnata in ${conflicts.length} turno/i nello stesso orario`);
|
||||
}
|
||||
|
||||
if (weeklyHoursRemaining < requestedHours) {
|
||||
isAvailable = false;
|
||||
reasons.push(`Ore settimanali insufficienti (${Math.max(0, weeklyHoursRemaining)}h disponibili, ${requestedHours}h richieste)`);
|
||||
}
|
||||
|
||||
// Build guard name from new fields
|
||||
const guardName = guard.firstName && guard.lastName
|
||||
? `${guard.firstName} ${guard.lastName}`
|
||||
: guard.badgeNumber;
|
||||
|
||||
guardsWithAvailability.push({
|
||||
guardId: guard.id,
|
||||
guardName,
|
||||
badgeNumber: guard.badgeNumber,
|
||||
weeklyHoursRemaining,
|
||||
weeklyHoursAssigned,
|
||||
weeklyHoursMax: maxWeeklyHours,
|
||||
isAvailable,
|
||||
conflicts,
|
||||
unavailabilityReasons: reasons,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by remaining hours (descending)
|
||||
guardsWithHours.sort((a, b) => b.weeklyHoursRemaining - a.weeklyHoursRemaining);
|
||||
// Sort: available first, then by remaining hours (descending)
|
||||
guardsWithAvailability.sort((a, b) => {
|
||||
if (a.isAvailable && !b.isAvailable) return -1;
|
||||
if (!a.isAvailable && b.isAvailable) return 1;
|
||||
return b.weeklyHoursRemaining - a.weeklyHoursRemaining;
|
||||
});
|
||||
|
||||
return guardsWithHours;
|
||||
return guardsWithAvailability;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -257,10 +257,14 @@ export const shiftAssignments = pgTable("shift_assignments", {
|
||||
shiftId: varchar("shift_id").notNull().references(() => shifts.id, { onDelete: "cascade" }),
|
||||
guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }),
|
||||
|
||||
// Planned shift times (when the guard is scheduled to work)
|
||||
plannedStartTime: timestamp("planned_start_time").notNull(),
|
||||
plannedEndTime: timestamp("planned_end_time").notNull(),
|
||||
|
||||
assignedAt: timestamp("assigned_at").defaultNow(),
|
||||
confirmedAt: timestamp("confirmed_at"),
|
||||
|
||||
// Actual check-in/out times
|
||||
// Actual check-in/out times (recorded when guard clocks in/out)
|
||||
checkInTime: timestamp("check_in_time"),
|
||||
checkOutTime: timestamp("check_out_time"),
|
||||
});
|
||||
@ -700,10 +704,25 @@ export const insertShiftFormSchema = z.object({
|
||||
status: z.enum(["planned", "active", "completed", "cancelled"]).default("planned"),
|
||||
});
|
||||
|
||||
export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments).omit({
|
||||
export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments)
|
||||
.omit({
|
||||
id: true,
|
||||
assignedAt: true,
|
||||
});
|
||||
})
|
||||
.extend({
|
||||
plannedStartTime: z.union([z.string(), z.date()], {
|
||||
required_error: "Orario inizio richiesto",
|
||||
invalid_type_error: "Orario inizio non valido",
|
||||
}).transform((val) => new Date(val)),
|
||||
plannedEndTime: z.union([z.string(), z.date()], {
|
||||
required_error: "Orario fine richiesto",
|
||||
invalid_type_error: "Orario fine non valido",
|
||||
}).transform((val) => new Date(val)),
|
||||
})
|
||||
.refine((data) => data.plannedEndTime > data.plannedStartTime, {
|
||||
message: "L'orario di fine deve essere successivo all'orario di inizio",
|
||||
path: ["plannedEndTime"],
|
||||
});
|
||||
|
||||
export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({
|
||||
id: true,
|
||||
@ -852,7 +871,15 @@ export type AbsenceWithDetails = Absence & {
|
||||
|
||||
// ============= DTOs FOR GENERAL PLANNING =============
|
||||
|
||||
// DTO per disponibilità guardia nella settimana
|
||||
// DTO per conflitto orario guardia
|
||||
export const guardConflictSchema = z.object({
|
||||
from: z.date(),
|
||||
to: z.date(),
|
||||
siteName: z.string(),
|
||||
shiftId: z.string(),
|
||||
});
|
||||
|
||||
// DTO per disponibilità guardia con controllo conflitti orari
|
||||
export const guardAvailabilitySchema = z.object({
|
||||
guardId: z.string(),
|
||||
guardName: z.string(),
|
||||
@ -860,8 +887,12 @@ export const guardAvailabilitySchema = z.object({
|
||||
weeklyHoursRemaining: z.number(),
|
||||
weeklyHoursAssigned: z.number(),
|
||||
weeklyHoursMax: z.number(),
|
||||
isAvailable: z.boolean(),
|
||||
conflicts: z.array(guardConflictSchema),
|
||||
unavailabilityReasons: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type GuardConflict = z.infer<typeof guardConflictSchema>;
|
||||
export type GuardAvailability = z.infer<typeof guardAvailabilitySchema>;
|
||||
|
||||
// DTO per creazione turno multi-giorno dal Planning Generale
|
||||
|
||||
10
version.json
@ -1,7 +1,13 @@
|
||||
{
|
||||
"version": "1.0.24",
|
||||
"lastUpdate": "2025-10-18T10:25:34.931Z",
|
||||
"version": "1.0.25",
|
||||
"lastUpdate": "2025-10-21T14:11:11.154Z",
|
||||
"changelog": [
|
||||
{
|
||||
"version": "1.0.25",
|
||||
"date": "2025-10-21",
|
||||
"type": "patch",
|
||||
"description": "Deployment automatico v1.0.25"
|
||||
},
|
||||
{
|
||||
"version": "1.0.24",
|
||||
"date": "2025-10-18",
|
||||
|
||||