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
|
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
|
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 [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
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) => {
|
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 {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,9 +704,24 @@ 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({
|
||||||
@ -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
|
||||||
|
|||||||
10
version.json
@ -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",
|
||||||
|
|||||||