Compare commits

..

7 Commits

Author SHA1 Message Date
Marco Lanzara
62f8189e7d 🚀 Release v1.0.25
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.25_20251021_141055.sql.gz
- Data: 2025-10-21 14:11:11
2025-10-21 14:11:11 +00:00
marco370
a23b46b9fd Improve the security and privacy of user data
Enhance data protection by implementing encryption for sensitive user information.

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/NBwOcnR
2025-10-21 07:45:29 +00:00
marco370
052cc6896a Improve error reporting and conflict visualization for shift assignments
Refactor shift assignment logic to use database transactions and improve error message parsing for assignment failures.

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/NBwOcnR
2025-10-21 07:44:30 +00:00
marco370
3d80f75f43 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
2025-10-21 06:42:54 +00:00
marco370
c95bf04abf Improve guard scheduling with time slot conflict detection and assignment
Add API endpoints for retrieving guard availability with date range filtering and for creating shift assignments with planned start and end times, including validation and conflict detection.

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
2025-10-21 06:38:25 +00:00
marco370
c72125c68f Improve guard assignment and availability checks for shift planning
Update storage interface and implementation to handle shift assignment deletion,
modify getGuardsAvailability to accept specific planned start and end times,
and introduce conflict detection logic for guard availability. Add new DTOs
(guardConflictSchema) and update guardAvailabilitySchema to include availability
status, conflicts, and unavailability reasons, enhancing shift planning accuracy.

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
2025-10-21 06:36:42 +00:00
marco370
1caf5c4199 Improve guard shift assignment with planned start and end times
Update the schema for shift assignments to include planned start and end times, and extend the insert schema to validate these times, ensuring guards are assigned to specific time slots.

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/McZSLgC
2025-10-21 06:33:40 +00:00
18 changed files with 496 additions and 101 deletions

View File

@ -23,6 +23,10 @@ externalPort = 3001
localPort = 41343 localPort = 41343
externalPort = 3000 externalPort = 3000
[[ports]]
localPort = 41803
externalPort = 4200
[[ports]] [[ports]]
localPort = 42175 localPort = 42175
externalPort = 3002 externalPort = 3002

BIN
after_assign_click.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
after_login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
after_sidebar_toggle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
cell_clicked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -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,73 @@ 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) => {
// 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({ toast({
title: "Errore", title: "Errore Assegnazione",
description: error.message || "Impossibile creare il turno", description: errorMessage,
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 +583,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 +637,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 +647,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 +659,47 @@ 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> </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> </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>

BIN
filled_assignment_form.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
guard_selected_by_eval.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
planning_page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -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) => { app.get("/api/guards/availability", isAuthenticated, async (req, res) => {
try { 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({ 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); const startDate = parseISO(start as string);
if (!isValid(weekStartDate)) { const endDate = parseISO(end as string);
return res.status(400).json({ message: "Invalid weekStart date format" });
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( const availability = await storage.getGuardsAvailability(
weekStartDate,
siteId as string, siteId as string,
location as string location as string,
startDate,
endDate
); );
res.json(availability); 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 ============= // ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => { app.post("/api/certifications", isAuthenticated, async (req, res) => {
try { 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 ============= // ============= NOTIFICATION ROUTES =============
app.get("/api/notifications", isAuthenticated, async (req: any, res) => { app.get("/api/notifications", isAuthenticated, async (req: any, res) => {
try { try {

View File

@ -157,7 +157,15 @@ export interface IStorage {
deleteCcnlSetting(key: string): Promise<void>; deleteCcnlSetting(key: string): Promise<void>;
// General Planning operations // 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 { export class DatabaseStorage implements IStorage {
@ -668,9 +676,24 @@ export class DatabaseStorage implements IStorage {
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key)); await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
} }
// General Planning operations // General Planning operations with time slot conflict detection
async getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]> { 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); const weekEnd = addDays(weekStart, 6);
weekEnd.setHours(23, 59, 59, 999);
// Get max weekly hours from CCNL settings (default 45h) // Get max weekly hours from CCNL settings (default 45h)
const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours'); const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours');
@ -689,64 +712,104 @@ export class DatabaseStorage implements IStorage {
.where(eq(guards.location, location as any)); .where(eq(guards.location, location as any));
// Filter guards by site requirements // 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.requiresArmed && !guard.isArmed) return false;
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false; if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
return true; return true;
}); });
// Calculate weekly hours for each guard // Analyze each guard's availability
const guardsWithHours: GuardAvailability[] = []; const guardsWithAvailability: GuardAvailability[] = [];
for (const guard of eligibleGuards) { for (const guard of eligibleGuards) {
// Get all shift assignments for this guard in the week // Get all shift assignments for this guard in the week (for weekly hours)
const assignments = await db const weeklyAssignments = await db
.select({ .select({
id: shiftAssignments.id,
shiftId: shiftAssignments.shiftId, shiftId: shiftAssignments.shiftId,
startTime: shifts.startTime, plannedStartTime: shiftAssignments.plannedStartTime,
endTime: shifts.endTime, plannedEndTime: shiftAssignments.plannedEndTime,
}) })
.from(shiftAssignments) .from(shiftAssignments)
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
.where( .where(
and( and(
eq(shiftAssignments.guardId, guard.id), eq(shiftAssignments.guardId, guard.id),
gte(shifts.startTime, weekStart), gte(shiftAssignments.plannedStartTime, weekStart),
lte(shifts.startTime, weekEnd) lte(shiftAssignments.plannedStartTime, weekEnd)
) )
); );
// Calculate total hours assigned // Calculate total weekly hours assigned
let weeklyHoursAssigned = 0; let weeklyHoursAssigned = 0;
for (const assignment of assignments) { for (const assignment of weeklyAssignments) {
const hours = differenceInHours(assignment.endTime, assignment.startTime); const hours = differenceInHours(assignment.plannedEndTime, assignment.plannedStartTime);
weeklyHoursAssigned += hours; weeklyHoursAssigned += hours;
} }
const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned; const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned;
const requestedHours = differenceInHours(plannedEnd, plannedStart);
// Only include guards with remaining hours // Check for time conflicts with the requested slot
if (weeklyHoursRemaining > 0) { const conflicts = [];
const user = guard.userId ? await this.getUser(guard.userId) : undefined; const reasons: string[] = [];
const guardName = user
? `${user.firstName || ''} ${user.lastName || ''}`.trim() || 'N/A'
: 'N/A';
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, guardId: guard.id,
guardName, guardName,
badgeNumber: guard.badgeNumber, badgeNumber: guard.badgeNumber,
weeklyHoursRemaining, weeklyHoursRemaining,
weeklyHoursAssigned, weeklyHoursAssigned,
weeklyHoursMax: maxWeeklyHours, weeklyHoursMax: maxWeeklyHours,
isAvailable,
conflicts,
unavailabilityReasons: reasons,
}); });
} }
}
// Sort by remaining hours (descending) // Sort: available first, then by remaining hours (descending)
guardsWithHours.sort((a, b) => b.weeklyHoursRemaining - a.weeklyHoursRemaining); 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;
} }
} }

View File

@ -257,10 +257,14 @@ export const shiftAssignments = pgTable("shift_assignments", {
shiftId: varchar("shift_id").notNull().references(() => shifts.id, { onDelete: "cascade" }), shiftId: varchar("shift_id").notNull().references(() => shifts.id, { onDelete: "cascade" }),
guardId: varchar("guard_id").notNull().references(() => guards.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(), assignedAt: timestamp("assigned_at").defaultNow(),
confirmedAt: timestamp("confirmed_at"), 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"), checkInTime: timestamp("check_in_time"),
checkOutTime: timestamp("check_out_time"), checkOutTime: timestamp("check_out_time"),
}); });
@ -700,10 +704,25 @@ export const insertShiftFormSchema = z.object({
status: z.enum(["planned", "active", "completed", "cancelled"]).default("planned"), status: z.enum(["planned", "active", "completed", "cancelled"]).default("planned"),
}); });
export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments).omit({ export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments)
.omit({
id: true, id: true,
assignedAt: 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({ export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({
id: true, id: true,
@ -852,7 +871,15 @@ export type AbsenceWithDetails = Absence & {
// ============= DTOs FOR GENERAL PLANNING ============= // ============= 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({ export const guardAvailabilitySchema = z.object({
guardId: z.string(), guardId: z.string(),
guardName: z.string(), guardName: z.string(),
@ -860,8 +887,12 @@ export const guardAvailabilitySchema = z.object({
weeklyHoursRemaining: z.number(), weeklyHoursRemaining: z.number(),
weeklyHoursAssigned: z.number(), weeklyHoursAssigned: z.number(),
weeklyHoursMax: 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>; export type GuardAvailability = z.infer<typeof guardAvailabilitySchema>;
// DTO per creazione turno multi-giorno dal Planning Generale // DTO per creazione turno multi-giorno dal Planning Generale

View File

@ -1,7 +1,13 @@
{ {
"version": "1.0.24", "version": "1.0.25",
"lastUpdate": "2025-10-18T10:25:34.931Z", "lastUpdate": "2025-10-21T14:11:11.154Z",
"changelog": [ "changelog": [
{
"version": "1.0.25",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.25"
},
{ {
"version": "1.0.24", "version": "1.0.24",
"date": "2025-10-18", "date": "2025-10-18",