Implement contract rules for shift assignments

Add CCNL (Contratto Collettivo Nazionale di Lavoro) validation logic on the server-side to check shift durations against defined contract parameters. This includes updating the client to handle potential violations and display them to the user via an alert dialog. The server now exposes a new API endpoint (/api/shifts/validate-ccnl) for this validation. Additionally, seeding script has been updated to include default contract parameters.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/IdDfihe
This commit is contained in:
marco370 2025-10-17 07:39:19 +00:00
parent 34221555d8
commit 4a1c21455b
3 changed files with 279 additions and 10 deletions

View File

@ -4,6 +4,8 @@ import { ShiftWithDetails, InsertShift, Site, GuardWithCertifications } from "@s
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useForm } from "react-hook-form";
@ -25,6 +27,8 @@ export default function Shifts() {
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const [editingShift, setEditingShift] = useState<ShiftWithDetails | null>(null);
const [selectedLocation, setSelectedLocation] = useState<string>("all");
const [ccnlViolations, setCcnlViolations] = useState<Array<{type: string, message: string}>>([]);
const [pendingAssignment, setPendingAssignment] = useState<{shiftId: string, guardId: string} | null>(null);
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
queryKey: ["/api/shifts"],
@ -100,6 +104,47 @@ export default function Shifts() {
createMutation.mutate(data);
};
// Validate CCNL before assignment
const validateCcnl = async (shiftId: string, guardId: string) => {
const shift = shifts?.find(s => s.id === shiftId);
if (!shift) return { violations: [] };
try {
const response = await apiRequest("POST", "/api/shifts/validate-ccnl", {
guardId,
shiftStartTime: shift.startTime,
shiftEndTime: shift.endTime
});
return response as { violations: Array<{type: string, message: string}> };
} catch (error) {
console.error("Errore validazione CCNL:", error);
return { violations: [] };
}
};
const handleAssignGuard = async (guardId: string) => {
if (!selectedShift) return;
// Validate CCNL
const { violations } = await validateCcnl(selectedShift.id, guardId);
if (violations.length > 0) {
setCcnlViolations(violations);
setPendingAssignment({ shiftId: selectedShift.id, guardId });
} else {
// No violations, assign directly
assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId });
}
};
const confirmAssignment = () => {
if (pendingAssignment) {
assignGuardMutation.mutate(pendingAssignment);
setPendingAssignment(null);
setCcnlViolations([]);
}
};
const assignGuardMutation = useMutation({
mutationFn: async ({ shiftId, guardId }: { shiftId: string; guardId: string }) => {
return await apiRequest("POST", "/api/shift-assignments", { shiftId, guardId });
@ -180,12 +225,6 @@ export default function Shifts() {
},
});
const handleAssignGuard = (guardId: string) => {
if (selectedShift) {
assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId });
}
};
const handleRemoveAssignment = (assignmentId: string) => {
removeAssignmentMutation.mutate(assignmentId);
};
@ -719,6 +758,43 @@ export default function Shifts() {
</Form>
</DialogContent>
</Dialog>
{/* CCNL Violations Alert Dialog */}
<AlertDialog open={pendingAssignment !== null} onOpenChange={(open) => !open && setPendingAssignment(null)}>
<AlertDialogContent data-testid="dialog-ccnl-violations">
<AlertDialogHeader>
<AlertDialogTitle> Violazioni CCNL Rilevate</AlertDialogTitle>
<AlertDialogDescription>
L'assegnazione di questa guardia al turno viola i seguenti limiti contrattuali:
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
{ccnlViolations.map((violation, index) => (
<Alert key={index} variant="destructive" data-testid={`alert-violation-${index}`}>
<AlertDescription>{violation.message}</AlertDescription>
</Alert>
))}
</div>
<AlertDialogFooter>
<AlertDialogCancel
data-testid="button-cancel-assignment"
onClick={() => {
setPendingAssignment(null);
setCcnlViolations([]);
}}
>
Annulla
</AlertDialogCancel>
<AlertDialogAction
data-testid="button-confirm-assignment"
onClick={confirmAssignment}
className="bg-destructive hover:bg-destructive/90"
>
Procedi Comunque
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -4,9 +4,9 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
import { db } from "./db";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema } from "@shared/schema";
import { eq } from "drizzle-orm";
import { differenceInDays } from "date-fns";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } from "@shared/schema";
import { eq, and, gte, lte, desc, asc } from "drizzle-orm";
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO } from "date-fns";
// Determina quale sistema auth usare basandosi sull'ambiente
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
@ -638,6 +638,169 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ============= CCNL VALIDATION =============
app.post("/api/shifts/validate-ccnl", isAuthenticated, async (req, res) => {
try {
const { guardId, shiftStartTime, shiftEndTime } = req.body;
if (!guardId || !shiftStartTime || !shiftEndTime) {
return res.status(400).json({ message: "Missing required fields" });
}
const startTime = new Date(shiftStartTime);
const endTime = new Date(shiftEndTime);
// Load CCNL parameters
const params = await db.select().from(contractParameters).limit(1);
if (params.length === 0) {
return res.status(500).json({ message: "CCNL parameters not found" });
}
const ccnl = params[0];
const violations: Array<{type: string, message: string}> = [];
// Calculate shift hours precisely (in minutes, then convert)
const shiftMinutes = differenceInMinutes(endTime, startTime);
const shiftHours = Math.round((shiftMinutes / 60) * 100) / 100; // Round to 2 decimals
// Check max daily hours
if (shiftHours > ccnl.maxHoursPerDay) {
violations.push({
type: "MAX_DAILY_HOURS",
message: `Turno di ${shiftHours}h supera il limite giornaliero di ${ccnl.maxHoursPerDay}h`
});
}
// Get guard's shifts for the week (overlapping with week window)
const weekStart = startOfWeek(startTime, { weekStartsOn: 1 }); // Monday
const weekEnd = endOfWeek(startTime, { weekStartsOn: 1 });
const weekShifts = await db
.select()
.from(shiftAssignments)
.innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
eq(shiftAssignments.guardId, guardId),
// Shift overlaps week if: (shift_start <= week_end) AND (shift_end >= week_start)
lte(shifts.startTime, weekEnd),
gte(shifts.endTime, weekStart)
)
);
// Calculate weekly hours precisely (only overlap with week window)
let weeklyMinutes = 0;
// Add overlap of new shift with week (clamp to 0 if outside window)
const newShiftStart = startTime < weekStart ? weekStart : startTime;
const newShiftEnd = endTime > weekEnd ? weekEnd : endTime;
weeklyMinutes += Math.max(0, differenceInMinutes(newShiftEnd, newShiftStart));
// Add overlap of existing shifts with week (clamp to 0 if outside window)
for (const { shifts: shift } of weekShifts) {
const shiftStart = shift.startTime < weekStart ? weekStart : shift.startTime;
const shiftEnd = shift.endTime > weekEnd ? weekEnd : shift.endTime;
weeklyMinutes += Math.max(0, differenceInMinutes(shiftEnd, shiftStart));
}
const weeklyHours = Math.round((weeklyMinutes / 60) * 100) / 100;
if (weeklyHours > ccnl.maxHoursPerWeek) {
violations.push({
type: "MAX_WEEKLY_HOURS",
message: `Ore settimanali (${weeklyHours}h) superano il limite di ${ccnl.maxHoursPerWeek}h`
});
}
// Check consecutive days (chronological order ASC, only past shifts before this one)
const pastShifts = await db
.select()
.from(shiftAssignments)
.innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
eq(shiftAssignments.guardId, guardId),
lte(shifts.startTime, startTime)
)
)
.orderBy(asc(shifts.startTime));
// Build consecutive days map
const shiftDays = new Set<string>();
for (const { shifts: shift } of pastShifts) {
shiftDays.add(startOfDay(shift.startTime).toISOString());
}
shiftDays.add(startOfDay(startTime).toISOString());
// Count consecutive days backwards from new shift
let consecutiveDays = 0;
let checkDate = startOfDay(startTime);
while (shiftDays.has(checkDate.toISOString())) {
consecutiveDays++;
checkDate = new Date(checkDate);
checkDate.setDate(checkDate.getDate() - 1);
}
if (consecutiveDays > 6) {
violations.push({
type: "MAX_CONSECUTIVE_DAYS",
message: `${consecutiveDays} giorni consecutivi superano il limite di 6 giorni`
});
}
// Check for overlapping shifts (guard already assigned to another shift at same time)
// Overlap if: (existing_start < new_end) AND (existing_end > new_start)
// Note: Use strict inequalities to allow back-to-back shifts (end == start is OK)
const allAssignments = await db
.select()
.from(shiftAssignments)
.innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId))
.where(eq(shiftAssignments.guardId, guardId));
const overlappingShifts = allAssignments.filter(({ shifts: shift }: any) => {
return shift.startTime < endTime && shift.endTime > startTime;
});
if (overlappingShifts.length > 0) {
violations.push({
type: "OVERLAPPING_SHIFT",
message: `Guardia già assegnata a un turno sovrapposto in questo orario`
});
}
// Check rest hours (only last shift that ended before this one)
const lastCompletedShifts = await db
.select()
.from(shiftAssignments)
.innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
eq(shiftAssignments.guardId, guardId),
lte(shifts.endTime, startTime)
)
)
.orderBy(desc(shifts.endTime))
.limit(1);
if (lastCompletedShifts.length > 0) {
const lastShift = lastCompletedShifts[0].shifts;
const restMinutes = differenceInMinutes(startTime, lastShift.endTime);
const restHours = Math.round((restMinutes / 60) * 100) / 100;
if (restHours < ccnl.minDailyRestHours) {
violations.push({
type: "MIN_REST_HOURS",
message: `Riposo di ${restHours}h inferiore al minimo di ${ccnl.minDailyRestHours}h`
});
}
}
res.json({ violations, ccnlParams: ccnl });
} catch (error) {
console.error("Error validating CCNL:", error);
res.status(500).json({ message: "Failed to validate CCNL" });
}
});
// ============= SHIFT ASSIGNMENT ROUTES =============
app.post("/api/shift-assignments", isAuthenticated, async (req, res) => {
try {

View File

@ -1,11 +1,41 @@
import { db } from "./db";
import { users, guards, sites, vehicles } from "@shared/schema";
import { users, guards, sites, vehicles, contractParameters } from "@shared/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcrypt";
async function seed() {
console.log("🌱 Avvio seed database multi-sede...");
// Create CCNL contract parameters
console.log("📋 Creazione parametri contrattuali CCNL...");
const existingParams = await db.select().from(contractParameters).limit(1);
if (existingParams.length === 0) {
await db.insert(contractParameters).values({
contractType: "CCNL Vigilanza Privata",
maxHoursPerDay: 9,
maxOvertimePerDay: 2,
maxHoursPerWeek: 48,
maxOvertimePerWeek: 8,
minDailyRestHours: 11,
minDailyRestHoursReduced: 9,
maxDailyRestReductionsPerMonth: 3,
maxDailyRestReductionsPerYear: 20,
minWeeklyRestHours: 24,
maxNightHoursPerWeek: 40,
pauseMinutesIfOver6Hours: 30,
holidayPayIncrease: 30,
nightPayIncrease: 15,
overtimePayIncrease: 20,
mealVoucherEnabled: true,
mealVoucherAfterHours: 6,
mealVoucherAmount: 8
});
console.log(" ✓ Parametri CCNL creati");
} else {
console.log(" ✓ Parametri CCNL già esistenti");
}
// Locations
const locations = ["roccapiemonte", "milano", "roma"] as const;
const locationNames = {