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:
parent
34221555d8
commit
4a1c21455b
@ -4,6 +4,8 @@ import { ShiftWithDetails, InsertShift, Site, GuardWithCertifications } from "@s
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@ -25,6 +27,8 @@ export default function Shifts() {
|
|||||||
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
|
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
|
||||||
const [editingShift, setEditingShift] = useState<ShiftWithDetails | null>(null);
|
const [editingShift, setEditingShift] = useState<ShiftWithDetails | null>(null);
|
||||||
const [selectedLocation, setSelectedLocation] = useState<string>("all");
|
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[]>({
|
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
|
||||||
queryKey: ["/api/shifts"],
|
queryKey: ["/api/shifts"],
|
||||||
@ -100,6 +104,47 @@ export default function Shifts() {
|
|||||||
createMutation.mutate(data);
|
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({
|
const assignGuardMutation = useMutation({
|
||||||
mutationFn: async ({ shiftId, guardId }: { shiftId: string; guardId: string }) => {
|
mutationFn: async ({ shiftId, guardId }: { shiftId: string; guardId: string }) => {
|
||||||
return await apiRequest("POST", "/api/shift-assignments", { shiftId, guardId });
|
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) => {
|
const handleRemoveAssignment = (assignmentId: string) => {
|
||||||
removeAssignmentMutation.mutate(assignmentId);
|
removeAssignmentMutation.mutate(assignmentId);
|
||||||
};
|
};
|
||||||
@ -719,6 +758,43 @@ export default function Shifts() {
|
|||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
169
server/routes.ts
169
server/routes.ts
@ -4,9 +4,9 @@ import { storage } from "./storage";
|
|||||||
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
||||||
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema } from "@shared/schema";
|
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } from "@shared/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc } from "drizzle-orm";
|
||||||
import { differenceInDays } from "date-fns";
|
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO } from "date-fns";
|
||||||
|
|
||||||
// Determina quale sistema auth usare basandosi sull'ambiente
|
// Determina quale sistema auth usare basandosi sull'ambiente
|
||||||
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
|
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 =============
|
// ============= SHIFT ASSIGNMENT ROUTES =============
|
||||||
app.post("/api/shift-assignments", isAuthenticated, async (req, res) => {
|
app.post("/api/shift-assignments", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,11 +1,41 @@
|
|||||||
import { db } from "./db";
|
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 { eq } from "drizzle-orm";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
console.log("🌱 Avvio seed database multi-sede...");
|
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
|
// Locations
|
||||||
const locations = ["roccapiemonte", "milano", "roma"] as const;
|
const locations = ["roccapiemonte", "milano", "roma"] as const;
|
||||||
const locationNames = {
|
const locationNames = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user