Introduce location filtering to operational planning, site management, and resource availability queries. This includes backend route modifications to handle location parameters and frontend updates for location selection and display. 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/5GnGQQ0
1526 lines
55 KiB
TypeScript
1526 lines
55 KiB
TypeScript
import type { Express } from "express";
|
|
import { createServer, type Server } from "http";
|
|
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, contractParameters } from "@shared/schema";
|
|
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
|
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid } 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;
|
|
|
|
// Helper per estrarre user ID in modo compatibile
|
|
function getUserId(req: any): string {
|
|
if (USE_LOCAL_AUTH) {
|
|
return req.user?.id || "";
|
|
} else {
|
|
return req.user?.claims?.sub || "";
|
|
}
|
|
}
|
|
|
|
// Usa il middleware auth appropriato
|
|
const isAuthenticated = USE_LOCAL_AUTH ? isAuthenticatedLocal : isAuthenticatedReplit;
|
|
|
|
export async function registerRoutes(app: Express): Promise<Server> {
|
|
// Setup auth system appropriato
|
|
if (USE_LOCAL_AUTH) {
|
|
console.log("🔐 Usando Local Auth (vt.alfacom.it)");
|
|
await setupLocalAuth(app);
|
|
} else {
|
|
console.log("🔐 Usando Replit OIDC Auth");
|
|
await setupReplitAuth(app);
|
|
}
|
|
|
|
// ============= AUTH ROUTES =============
|
|
app.get("/api/auth/user", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
const user = await storage.getUser(userId);
|
|
res.json(user);
|
|
} catch (error) {
|
|
console.error("Error fetching user:", error);
|
|
res.status(500).json({ message: "Failed to fetch user" });
|
|
}
|
|
});
|
|
|
|
// ============= USER MANAGEMENT ROUTES =============
|
|
app.get("/api/users", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins can view all users
|
|
if (currentUser?.role !== "admin") {
|
|
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
|
}
|
|
|
|
const allUsers = await storage.getAllUsers();
|
|
res.json(allUsers);
|
|
} catch (error) {
|
|
console.error("Error fetching users:", error);
|
|
res.status(500).json({ message: "Failed to fetch users" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/users", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins can create users
|
|
if (currentUser?.role !== "admin") {
|
|
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
|
}
|
|
|
|
const { email, firstName, lastName, password, role } = req.body;
|
|
|
|
if (!email || !firstName || !lastName || !password) {
|
|
return res.status(400).json({ message: "Missing required fields" });
|
|
}
|
|
|
|
// Hash password
|
|
const bcrypt = await import("bcrypt");
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
|
|
// Generate UUID
|
|
const crypto = await import("crypto");
|
|
const userId = crypto.randomUUID();
|
|
|
|
const newUser = await storage.upsertUser({
|
|
id: userId,
|
|
email,
|
|
firstName,
|
|
lastName,
|
|
profileImageUrl: null,
|
|
passwordHash,
|
|
});
|
|
|
|
// Set role if provided
|
|
if (role) {
|
|
await storage.updateUserRole(newUser.id, role);
|
|
}
|
|
|
|
res.json(newUser);
|
|
} catch (error: any) {
|
|
console.error("Error creating user:", error);
|
|
if (error.code === '23505') { // Unique violation
|
|
return res.status(409).json({ message: "Email già esistente" });
|
|
}
|
|
res.status(500).json({ message: "Failed to create user" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/users/:id", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins can update user roles
|
|
if (currentUser?.role !== "admin") {
|
|
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
|
}
|
|
|
|
// Prevent admins from changing their own role
|
|
if (req.params.id === currentUserId) {
|
|
return res.status(403).json({ message: "Cannot change your own role" });
|
|
}
|
|
|
|
const { role } = req.body;
|
|
if (!role || !["admin", "coordinator", "guard", "client"].includes(role)) {
|
|
return res.status(400).json({ message: "Invalid role" });
|
|
}
|
|
|
|
const updated = await storage.updateUserRole(req.params.id, role);
|
|
if (!updated) {
|
|
return res.status(404).json({ message: "User not found" });
|
|
}
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error("Error updating user role:", error);
|
|
res.status(500).json({ message: "Failed to update user role" });
|
|
}
|
|
});
|
|
|
|
app.put("/api/users/:id", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins can update users
|
|
if (currentUser?.role !== "admin") {
|
|
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
|
}
|
|
|
|
const { email, firstName, lastName, password, role } = req.body;
|
|
const existingUser = await storage.getUser(req.params.id);
|
|
|
|
if (!existingUser) {
|
|
return res.status(404).json({ message: "User not found" });
|
|
}
|
|
|
|
// Prepare update data
|
|
const updateData: any = {
|
|
id: req.params.id,
|
|
email: email || existingUser.email,
|
|
firstName: firstName || existingUser.firstName,
|
|
lastName: lastName || existingUser.lastName,
|
|
profileImageUrl: existingUser.profileImageUrl,
|
|
};
|
|
|
|
// Hash new password if provided
|
|
if (password) {
|
|
const bcrypt = await import("bcrypt");
|
|
updateData.passwordHash = await bcrypt.hash(password, 10);
|
|
}
|
|
|
|
const updated = await storage.upsertUser(updateData);
|
|
|
|
// Update role if provided and not changing own role
|
|
if (role && req.params.id !== currentUserId) {
|
|
await storage.updateUserRole(req.params.id, role);
|
|
}
|
|
|
|
res.json(updated);
|
|
} catch (error: any) {
|
|
console.error("Error updating user:", error);
|
|
if (error.code === '23505') {
|
|
return res.status(409).json({ message: "Email già esistente" });
|
|
}
|
|
res.status(500).json({ message: "Failed to update user" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/users/:id", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins can delete users
|
|
if (currentUser?.role !== "admin") {
|
|
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
|
}
|
|
|
|
// Prevent admins from deleting themselves
|
|
if (req.params.id === currentUserId) {
|
|
return res.status(403).json({ message: "Cannot delete your own account" });
|
|
}
|
|
|
|
await storage.deleteUser(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting user:", error);
|
|
res.status(500).json({ message: "Failed to delete user" });
|
|
}
|
|
});
|
|
|
|
// ============= GUARD ROUTES =============
|
|
app.get("/api/guards", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const allGuards = await storage.getAllGuards();
|
|
|
|
// Fetch related data for each guard
|
|
const guardsWithDetails = await Promise.all(
|
|
allGuards.map(async (guard) => {
|
|
const certs = await storage.getCertificationsByGuard(guard.id);
|
|
const user = guard.userId ? await storage.getUser(guard.userId) : undefined;
|
|
|
|
// Update certification status based on expiry date
|
|
for (const cert of certs) {
|
|
const daysUntilExpiry = differenceInDays(new Date(cert.expiryDate), new Date());
|
|
let newStatus: "valid" | "expiring_soon" | "expired" = "valid";
|
|
|
|
if (daysUntilExpiry < 0) {
|
|
newStatus = "expired";
|
|
} else if (daysUntilExpiry <= 30) {
|
|
newStatus = "expiring_soon";
|
|
}
|
|
|
|
if (cert.status !== newStatus) {
|
|
await storage.updateCertificationStatus(cert.id, newStatus);
|
|
cert.status = newStatus;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...guard,
|
|
certifications: certs,
|
|
user,
|
|
};
|
|
})
|
|
);
|
|
|
|
res.json(guardsWithDetails);
|
|
} catch (error) {
|
|
console.error("Error fetching guards:", error);
|
|
res.status(500).json({ message: "Failed to fetch guards" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/guards", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const guard = await storage.createGuard(req.body);
|
|
res.json(guard);
|
|
} catch (error) {
|
|
console.error("Error creating guard:", error);
|
|
res.status(500).json({ message: "Failed to create guard" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/guards/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const updated = await storage.updateGuard(req.params.id, req.body);
|
|
if (!updated) {
|
|
return res.status(404).json({ message: "Guard not found" });
|
|
}
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error("Error updating guard:", error);
|
|
res.status(500).json({ message: "Failed to update guard" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/guards/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const deleted = await storage.deleteGuard(req.params.id);
|
|
if (!deleted) {
|
|
return res.status(404).json({ message: "Guard not found" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting guard:", error);
|
|
res.status(500).json({ message: "Failed to delete guard" });
|
|
}
|
|
});
|
|
|
|
// ============= VEHICLE ROUTES =============
|
|
app.get("/api/vehicles", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const vehicles = await storage.getAllVehicles();
|
|
res.json(vehicles);
|
|
} catch (error) {
|
|
console.error("Error fetching vehicles:", error);
|
|
res.status(500).json({ message: "Failed to fetch vehicles" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/vehicles", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const vehicle = await storage.createVehicle(req.body);
|
|
res.json(vehicle);
|
|
} catch (error: any) {
|
|
console.error("Error creating vehicle:", error);
|
|
if (error.code === '23505') {
|
|
return res.status(409).json({ message: "Targa già esistente" });
|
|
}
|
|
res.status(500).json({ message: "Failed to create vehicle" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/vehicles/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const updated = await storage.updateVehicle(req.params.id, req.body);
|
|
if (!updated) {
|
|
return res.status(404).json({ message: "Vehicle not found" });
|
|
}
|
|
res.json(updated);
|
|
} catch (error: any) {
|
|
console.error("Error updating vehicle:", error);
|
|
if (error.code === '23505') {
|
|
return res.status(409).json({ message: "Targa già esistente" });
|
|
}
|
|
res.status(500).json({ message: "Failed to update vehicle" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/vehicles/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const deleted = await storage.deleteVehicle(req.params.id);
|
|
if (!deleted) {
|
|
return res.status(404).json({ message: "Vehicle not found" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting vehicle:", error);
|
|
res.status(500).json({ message: "Failed to delete vehicle" });
|
|
}
|
|
});
|
|
|
|
// ============= CONTRACT PARAMETERS ROUTES =============
|
|
app.get("/api/contract-parameters", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins and coordinators can view parameters
|
|
if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") {
|
|
return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" });
|
|
}
|
|
|
|
let params = await storage.getContractParameters();
|
|
|
|
// Se non esistono parametri, creali con valori di default CCNL
|
|
if (!params) {
|
|
params = await storage.createContractParameters({
|
|
contractType: "CCNL_VIGILANZA_2024",
|
|
});
|
|
}
|
|
|
|
res.json(params);
|
|
} catch (error) {
|
|
console.error("Error fetching contract parameters:", error);
|
|
res.status(500).json({ message: "Failed to fetch contract parameters" });
|
|
}
|
|
});
|
|
|
|
app.put("/api/contract-parameters/:id", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins can update parameters
|
|
if (currentUser?.role !== "admin") {
|
|
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
|
}
|
|
|
|
// Validate request body with insert schema
|
|
const { insertContractParametersSchema } = await import("@shared/schema");
|
|
const validationResult = insertContractParametersSchema.partial().safeParse(req.body);
|
|
|
|
if (!validationResult.success) {
|
|
return res.status(400).json({
|
|
message: "Invalid parameters data",
|
|
errors: validationResult.error.errors
|
|
});
|
|
}
|
|
|
|
const updated = await storage.updateContractParameters(req.params.id, validationResult.data);
|
|
if (!updated) {
|
|
return res.status(404).json({ message: "Contract parameters not found" });
|
|
}
|
|
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error("Error updating contract parameters:", error);
|
|
res.status(500).json({ message: "Failed to update contract parameters" });
|
|
}
|
|
});
|
|
|
|
// ============= CCNL SETTINGS ROUTES =============
|
|
app.get("/api/ccnl-settings", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins and coordinators can view CCNL settings
|
|
if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") {
|
|
return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" });
|
|
}
|
|
|
|
const settings = await storage.getAllCcnlSettings();
|
|
res.json(settings);
|
|
} catch (error) {
|
|
console.error("Error fetching CCNL settings:", error);
|
|
res.status(500).json({ message: "Failed to fetch CCNL settings" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/ccnl-settings/:key", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins and coordinators can view CCNL settings
|
|
if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") {
|
|
return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" });
|
|
}
|
|
|
|
const setting = await storage.getCcnlSetting(req.params.key);
|
|
if (!setting) {
|
|
return res.status(404).json({ message: "CCNL setting not found" });
|
|
}
|
|
res.json(setting);
|
|
} catch (error) {
|
|
console.error("Error fetching CCNL setting:", error);
|
|
res.status(500).json({ message: "Failed to fetch CCNL setting" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/ccnl-settings", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins can create/update CCNL settings
|
|
if (currentUser?.role !== "admin") {
|
|
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
|
}
|
|
|
|
const { insertCcnlSettingSchema } = await import("@shared/schema");
|
|
const validationResult = insertCcnlSettingSchema.safeParse(req.body);
|
|
|
|
if (!validationResult.success) {
|
|
return res.status(400).json({
|
|
message: "Invalid CCNL setting data",
|
|
errors: validationResult.error.errors
|
|
});
|
|
}
|
|
|
|
const setting = await storage.upsertCcnlSetting(validationResult.data);
|
|
res.json(setting);
|
|
} catch (error) {
|
|
console.error("Error creating/updating CCNL setting:", error);
|
|
res.status(500).json({ message: "Failed to create/update CCNL setting" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/ccnl-settings/:key", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const currentUserId = getUserId(req);
|
|
const currentUser = await storage.getUser(currentUserId);
|
|
|
|
// Only admins can delete CCNL settings
|
|
if (currentUser?.role !== "admin") {
|
|
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
|
}
|
|
|
|
await storage.deleteCcnlSetting(req.params.key);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting CCNL setting:", error);
|
|
res.status(500).json({ message: "Failed to delete CCNL setting" });
|
|
}
|
|
});
|
|
|
|
// ============= CCNL VALIDATION ROUTES =============
|
|
app.post("/api/ccnl/validate-shift", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const { validateShiftForGuard } = await import("./ccnlRules");
|
|
const { guardId, shiftStartTime, shiftEndTime, excludeShiftId } = req.body;
|
|
|
|
if (!guardId || !shiftStartTime || !shiftEndTime) {
|
|
return res.status(400).json({
|
|
message: "Missing required fields: guardId, shiftStartTime, shiftEndTime"
|
|
});
|
|
}
|
|
|
|
const result = await validateShiftForGuard(
|
|
guardId,
|
|
new Date(shiftStartTime),
|
|
new Date(shiftEndTime),
|
|
excludeShiftId
|
|
);
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error("Error validating shift:", error);
|
|
res.status(500).json({ message: "Failed to validate shift" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/ccnl/guard-availability/:guardId", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const { getGuardAvailabilityReport } = await import("./ccnlRules");
|
|
const { guardId } = req.params;
|
|
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : new Date();
|
|
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : new Date();
|
|
|
|
const report = await getGuardAvailabilityReport(guardId, startDate, endDate);
|
|
res.json(report);
|
|
} catch (error) {
|
|
console.error("Error fetching guard availability:", error);
|
|
res.status(500).json({ message: "Failed to fetch guard availability" });
|
|
}
|
|
});
|
|
|
|
// ============= OPERATIONAL PLANNING ROUTES =============
|
|
app.get("/api/operational-planning/availability", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const { getGuardAvailabilityReport } = await import("./ccnlRules");
|
|
|
|
// Sanitizza input: gestisce sia "2025-10-17" che "2025-10-17/2025-10-17"
|
|
const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
|
const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash
|
|
|
|
// Valida la data
|
|
const parsedDate = parseISO(normalizedDateStr);
|
|
if (!isValid(parsedDate)) {
|
|
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
|
}
|
|
|
|
const dateStr = format(parsedDate, "yyyy-MM-dd");
|
|
|
|
// Ottieni location dalla query (default: roccapiemonte)
|
|
const location = req.query.location as string || "roccapiemonte";
|
|
|
|
// Imposta inizio e fine giornata in UTC
|
|
const startOfDay = new Date(dateStr + "T00:00:00.000Z");
|
|
const endOfDay = new Date(dateStr + "T23:59:59.999Z");
|
|
|
|
// Ottieni tutti i veicoli della sede selezionata
|
|
const allVehicles = await storage.getAllVehicles();
|
|
const locationVehicles = allVehicles.filter(v => v.location === location);
|
|
|
|
// Ottieni turni del giorno SOLO della sede selezionata (join con sites per filtrare per location)
|
|
const dayShifts = await db
|
|
.select({
|
|
shift: shifts
|
|
})
|
|
.from(shifts)
|
|
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
|
.where(
|
|
and(
|
|
gte(shifts.startTime, startOfDay),
|
|
lte(shifts.startTime, endOfDay),
|
|
eq(sites.location, location)
|
|
)
|
|
);
|
|
|
|
// Mappa veicoli con disponibilità
|
|
const vehiclesWithAvailability = await Promise.all(
|
|
locationVehicles.map(async (vehicle) => {
|
|
const assignedShiftRecord = dayShifts.find((s: any) => s.shift.vehicleId === vehicle.id);
|
|
|
|
return {
|
|
...vehicle,
|
|
isAvailable: !assignedShiftRecord,
|
|
assignedShift: assignedShiftRecord ? {
|
|
id: assignedShiftRecord.shift.id,
|
|
startTime: assignedShiftRecord.shift.startTime,
|
|
endTime: assignedShiftRecord.shift.endTime,
|
|
siteId: assignedShiftRecord.shift.siteId
|
|
} : null
|
|
};
|
|
})
|
|
);
|
|
|
|
// Ottieni tutte le guardie della sede selezionata
|
|
const allGuards = await storage.getAllGuards();
|
|
const locationGuards = allGuards.filter(g => g.location === location);
|
|
|
|
// Ottieni assegnazioni turni del giorno SOLO della sede selezionata (join con sites per filtrare per location)
|
|
const dayShiftAssignments = await db
|
|
.select()
|
|
.from(shiftAssignments)
|
|
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
|
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
|
.where(
|
|
and(
|
|
gte(shifts.startTime, startOfDay),
|
|
lte(shifts.startTime, endOfDay),
|
|
eq(sites.location, location)
|
|
)
|
|
);
|
|
|
|
// Calcola disponibilità agenti con report CCNL
|
|
const guardsWithAvailability = await Promise.all(
|
|
locationGuards.map(async (guard) => {
|
|
const assignedShift = dayShiftAssignments.find(
|
|
(assignment: any) => assignment.shift_assignments.guardId === guard.id
|
|
);
|
|
|
|
// Calcola report disponibilità CCNL
|
|
const availabilityReport = await getGuardAvailabilityReport(
|
|
guard.id,
|
|
startOfDay,
|
|
endOfDay
|
|
);
|
|
|
|
return {
|
|
...guard,
|
|
isAvailable: !assignedShift,
|
|
assignedShift: assignedShift ? {
|
|
id: assignedShift.shifts.id,
|
|
startTime: assignedShift.shifts.startTime,
|
|
endTime: assignedShift.shifts.endTime,
|
|
siteId: assignedShift.shifts.siteId
|
|
} : null,
|
|
availability: {
|
|
weeklyHours: availabilityReport.weeklyHours.current,
|
|
remainingWeeklyHours: availabilityReport.remainingWeeklyHours,
|
|
remainingMonthlyHours: availabilityReport.remainingMonthlyHours,
|
|
consecutiveDaysWorked: availabilityReport.consecutiveDaysWorked
|
|
}
|
|
};
|
|
})
|
|
);
|
|
|
|
// Ordina veicoli: disponibili prima, poi per targa
|
|
const sortedVehicles = vehiclesWithAvailability.sort((a, b) => {
|
|
if (a.isAvailable && !b.isAvailable) return -1;
|
|
if (!a.isAvailable && b.isAvailable) return 1;
|
|
return a.licensePlate.localeCompare(b.licensePlate);
|
|
});
|
|
|
|
// Ordina agenti: disponibili prima, poi per ore settimanali (meno ore = più disponibili)
|
|
const sortedGuards = guardsWithAvailability.sort((a, b) => {
|
|
if (a.isAvailable && !b.isAvailable) return -1;
|
|
if (!a.isAvailable && b.isAvailable) return 1;
|
|
// Se entrambi disponibili, ordina per ore settimanali (meno ore = prima)
|
|
if (a.isAvailable && b.isAvailable) {
|
|
return a.availability.weeklyHours - b.availability.weeklyHours;
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
res.json({
|
|
date: dateStr,
|
|
vehicles: sortedVehicles,
|
|
guards: sortedGuards
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching operational planning availability:", error);
|
|
res.status(500).json({ message: "Failed to fetch availability", error: String(error) });
|
|
}
|
|
});
|
|
|
|
// Endpoint per ottenere siti non completamente coperti per una data e sede
|
|
app.get("/api/operational-planning/uncovered-sites", isAuthenticated, async (req, res) => {
|
|
try {
|
|
// Sanitizza input: gestisce sia "2025-10-17" che "2025-10-17/2025-10-17"
|
|
const rawDateStr = req.query.date as string || format(new Date(), "yyyy-MM-dd");
|
|
const normalizedDateStr = rawDateStr.split("/")[0]; // Prende solo la prima parte se c'è uno slash
|
|
|
|
// Valida la data
|
|
const parsedDate = parseISO(normalizedDateStr);
|
|
if (!isValid(parsedDate)) {
|
|
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
|
}
|
|
|
|
const dateStr = format(parsedDate, "yyyy-MM-dd");
|
|
|
|
// Ottieni location dalla query (default: roccapiemonte)
|
|
const location = req.query.location as string || "roccapiemonte";
|
|
|
|
// Ottieni tutti i siti attivi della sede selezionata
|
|
const allSites = await db
|
|
.select()
|
|
.from(sites)
|
|
.where(
|
|
and(
|
|
eq(sites.isActive, true),
|
|
eq(sites.location, location)
|
|
)
|
|
);
|
|
|
|
// Filtra siti con contratto valido per la data selezionata
|
|
const sitesWithValidContract = allSites.filter((site: any) => {
|
|
// Se il sito non ha date contrattuali, lo escludiamo
|
|
if (!site.contractStartDate || !site.contractEndDate) {
|
|
return false;
|
|
}
|
|
|
|
// Normalizza date per confronto day-only
|
|
const selectedDate = new Date(dateStr);
|
|
selectedDate.setHours(0, 0, 0, 0);
|
|
|
|
const contractStart = new Date(site.contractStartDate);
|
|
contractStart.setHours(0, 0, 0, 0);
|
|
|
|
const contractEnd = new Date(site.contractEndDate);
|
|
contractEnd.setHours(23, 59, 59, 999);
|
|
|
|
// Verifica che la data selezionata sia dentro il periodo contrattuale
|
|
return selectedDate >= contractStart && selectedDate <= contractEnd;
|
|
});
|
|
|
|
// Ottieni turni del giorno con assegnazioni SOLO della sede selezionata
|
|
const startOfDayDate = new Date(dateStr);
|
|
startOfDayDate.setHours(0, 0, 0, 0);
|
|
|
|
const endOfDayDate = new Date(dateStr);
|
|
endOfDayDate.setHours(23, 59, 59, 999);
|
|
|
|
const dayShifts = await db
|
|
.select({
|
|
shift: shifts,
|
|
assignmentCount: sql<number>`count(${shiftAssignments.id})::int`
|
|
})
|
|
.from(shifts)
|
|
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
|
.leftJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId))
|
|
.where(
|
|
and(
|
|
gte(shifts.startTime, startOfDayDate),
|
|
lte(shifts.startTime, endOfDayDate),
|
|
ne(shifts.status, "cancelled"),
|
|
eq(sites.location, location)
|
|
)
|
|
)
|
|
.groupBy(shifts.id);
|
|
|
|
// Calcola copertura per ogni sito con contratto valido
|
|
const sitesWithCoverage = sitesWithValidContract.map((site: any) => {
|
|
const siteShifts = dayShifts.filter((s: any) => s.shift.siteId === site.id);
|
|
|
|
// Verifica copertura per ogni turno
|
|
const shiftsWithCoverage = siteShifts.map((s: any) => ({
|
|
id: s.shift.id,
|
|
startTime: s.shift.startTime,
|
|
endTime: s.shift.endTime,
|
|
assignedGuardsCount: s.assignmentCount,
|
|
requiredGuards: site.minGuards,
|
|
isCovered: s.assignmentCount >= site.minGuards,
|
|
isPartial: s.assignmentCount > 0 && s.assignmentCount < site.minGuards
|
|
}));
|
|
|
|
// Un sito è completamente coperto solo se TUTTI i turni hanno il numero minimo di guardie
|
|
const allShiftsCovered = siteShifts.length > 0 && shiftsWithCoverage.every((s: any) => s.isCovered);
|
|
|
|
// Un sito è parzialmente coperto se ha turni ma non tutti sono completamente coperti
|
|
const hasPartialCoverage = siteShifts.length > 0 && !allShiftsCovered && shiftsWithCoverage.some((s: any) => s.assignedGuardsCount > 0);
|
|
|
|
// Calcola totale guardie assegnate per info
|
|
const totalAssignedGuards = siteShifts.reduce((sum: number, s: any) => sum + s.assignmentCount, 0);
|
|
|
|
return {
|
|
...site,
|
|
isCovered: allShiftsCovered,
|
|
isPartiallyCovered: hasPartialCoverage,
|
|
totalAssignedGuards,
|
|
requiredGuards: site.minGuards,
|
|
shiftsCount: siteShifts.length,
|
|
shifts: shiftsWithCoverage
|
|
};
|
|
});
|
|
|
|
// Filtra solo siti non completamente coperti
|
|
const uncoveredSites = sitesWithCoverage.filter(
|
|
(site: any) => !site.isCovered
|
|
);
|
|
|
|
// Ordina: parzialmente coperti prima, poi non coperti
|
|
const sortedUncoveredSites = uncoveredSites.sort((a: any, b: any) => {
|
|
if (a.isPartiallyCovered && !b.isPartiallyCovered) return -1;
|
|
if (!a.isPartiallyCovered && b.isPartiallyCovered) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
res.json({
|
|
date: dateStr,
|
|
uncoveredSites: sortedUncoveredSites,
|
|
totalSites: sitesWithValidContract.length,
|
|
totalUncovered: uncoveredSites.length
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching uncovered sites:", error);
|
|
res.status(500).json({ message: "Failed to fetch uncovered sites", error: String(error) });
|
|
}
|
|
});
|
|
|
|
// ============= CERTIFICATION ROUTES =============
|
|
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const cert = await storage.createCertification(req.body);
|
|
res.json(cert);
|
|
} catch (error) {
|
|
console.error("Error creating certification:", error);
|
|
res.status(500).json({ message: "Failed to create certification" });
|
|
}
|
|
});
|
|
|
|
// ============= SERVICE TYPE ROUTES =============
|
|
app.get("/api/service-types", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const serviceTypes = await storage.getAllServiceTypes();
|
|
res.json(serviceTypes);
|
|
} catch (error) {
|
|
console.error("Error fetching service types:", error);
|
|
res.status(500).json({ message: "Failed to fetch service types" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/service-types", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const serviceType = await storage.createServiceType(req.body);
|
|
res.json(serviceType);
|
|
} catch (error) {
|
|
console.error("Error creating service type:", error);
|
|
res.status(500).json({ message: "Failed to create service type" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/service-types/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const serviceType = await storage.updateServiceType(req.params.id, req.body);
|
|
if (!serviceType) {
|
|
return res.status(404).json({ message: "Service type not found" });
|
|
}
|
|
res.json(serviceType);
|
|
} catch (error) {
|
|
console.error("Error updating service type:", error);
|
|
res.status(500).json({ message: "Failed to update service type" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/service-types/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const serviceType = await storage.deleteServiceType(req.params.id);
|
|
if (!serviceType) {
|
|
return res.status(404).json({ message: "Service type not found" });
|
|
}
|
|
res.json(serviceType);
|
|
} catch (error) {
|
|
console.error("Error deleting service type:", error);
|
|
res.status(500).json({ message: "Failed to delete service type" });
|
|
}
|
|
});
|
|
|
|
// ============= SITE ROUTES =============
|
|
app.get("/api/sites", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const allSites = await storage.getAllSites();
|
|
res.json(allSites);
|
|
} catch (error) {
|
|
console.error("Error fetching sites:", error);
|
|
res.status(500).json({ message: "Failed to fetch sites" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/sites", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const site = await storage.createSite(req.body);
|
|
res.json(site);
|
|
} catch (error) {
|
|
console.error("Error creating site:", error);
|
|
res.status(500).json({ message: "Failed to create site" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/sites/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const updated = await storage.updateSite(req.params.id, req.body);
|
|
if (!updated) {
|
|
return res.status(404).json({ message: "Site not found" });
|
|
}
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error("Error updating site:", error);
|
|
res.status(500).json({ message: "Failed to update site" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/sites/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const deleted = await storage.deleteSite(req.params.id);
|
|
if (!deleted) {
|
|
return res.status(404).json({ message: "Site not found" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting site:", error);
|
|
res.status(500).json({ message: "Failed to delete site" });
|
|
}
|
|
});
|
|
|
|
// ============= SHIFT ROUTES =============
|
|
app.get("/api/shifts", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const allShifts = await storage.getAllShifts();
|
|
|
|
// Fetch related data for each shift
|
|
const shiftsWithDetails = await Promise.all(
|
|
allShifts.map(async (shift) => {
|
|
const site = await storage.getSite(shift.siteId);
|
|
const assignments = await storage.getShiftAssignments(shift.id);
|
|
|
|
// Fetch guard details for each assignment
|
|
const assignmentsWithGuards = await Promise.all(
|
|
assignments.map(async (assignment) => {
|
|
const guard = await storage.getGuard(assignment.guardId);
|
|
const certs = guard ? await storage.getCertificationsByGuard(guard.id) : [];
|
|
const user = guard?.userId ? await storage.getUser(guard.userId) : undefined;
|
|
|
|
return {
|
|
...assignment,
|
|
guard: guard ? {
|
|
...guard,
|
|
certifications: certs,
|
|
user,
|
|
} : null,
|
|
};
|
|
})
|
|
);
|
|
|
|
return {
|
|
...shift,
|
|
site: site!,
|
|
assignments: assignmentsWithGuards.filter(a => a.guard !== null),
|
|
};
|
|
})
|
|
);
|
|
|
|
res.json(shiftsWithDetails);
|
|
} catch (error) {
|
|
console.error("Error fetching shifts:", error);
|
|
res.status(500).json({ message: "Failed to fetch shifts" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/shifts/active", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const activeShifts = await storage.getActiveShifts();
|
|
|
|
// Fetch related data for each shift
|
|
const shiftsWithDetails = await Promise.all(
|
|
activeShifts.map(async (shift) => {
|
|
const site = await storage.getSite(shift.siteId);
|
|
const assignments = await storage.getShiftAssignments(shift.id);
|
|
|
|
// Fetch guard details for each assignment
|
|
const assignmentsWithGuards = await Promise.all(
|
|
assignments.map(async (assignment) => {
|
|
const guard = await storage.getGuard(assignment.guardId);
|
|
const certs = guard ? await storage.getCertificationsByGuard(guard.id) : [];
|
|
const user = guard?.userId ? await storage.getUser(guard.userId) : undefined;
|
|
|
|
return {
|
|
...assignment,
|
|
guard: guard ? {
|
|
...guard,
|
|
certifications: certs,
|
|
user,
|
|
} : null,
|
|
};
|
|
})
|
|
);
|
|
|
|
return {
|
|
...shift,
|
|
site: site!,
|
|
assignments: assignmentsWithGuards.filter(a => a.guard !== null),
|
|
};
|
|
})
|
|
);
|
|
|
|
res.json(shiftsWithDetails);
|
|
} catch (error) {
|
|
console.error("Error fetching active shifts:", error);
|
|
res.status(500).json({ message: "Failed to fetch active shifts" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/shifts", isAuthenticated, async (req, res) => {
|
|
try {
|
|
// Validate that required fields are present and dates are valid strings
|
|
if (!req.body.siteId || !req.body.startTime || !req.body.endTime) {
|
|
return res.status(400).json({ message: "Missing required fields" });
|
|
}
|
|
|
|
// Verifica stato contratto del sito
|
|
const site = await storage.getSite(req.body.siteId);
|
|
if (!site) {
|
|
return res.status(404).json({ message: "Sito non trovato" });
|
|
}
|
|
|
|
// Controllo validità contratto - richiesto per creare turni
|
|
if (!site.contractStartDate || !site.contractEndDate) {
|
|
return res.status(400).json({
|
|
message: `Impossibile creare turno: il sito "${site.name}" non ha un contratto attivo`
|
|
});
|
|
}
|
|
|
|
// Convert and validate shift dates first
|
|
const startTime = new Date(req.body.startTime);
|
|
const endTime = new Date(req.body.endTime);
|
|
|
|
if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) {
|
|
return res.status(400).json({ message: "Invalid date format" });
|
|
}
|
|
|
|
// Normalizza date contratto a giorno intero (00:00 - 23:59)
|
|
const contractStart = new Date(site.contractStartDate);
|
|
contractStart.setHours(0, 0, 0, 0);
|
|
|
|
const contractEnd = new Date(site.contractEndDate);
|
|
contractEnd.setHours(23, 59, 59, 999);
|
|
|
|
// Normalizza data turno a giorno (per confronto)
|
|
const shiftDate = new Date(startTime);
|
|
shiftDate.setHours(0, 0, 0, 0);
|
|
|
|
// Verifica che il turno sia dentro il periodo contrattuale
|
|
if (shiftDate > contractEnd) {
|
|
return res.status(400).json({
|
|
message: `Impossibile creare turno: il contratto per il sito "${site.name}" scade il ${new Date(site.contractEndDate).toLocaleDateString('it-IT')}`
|
|
});
|
|
}
|
|
|
|
if (shiftDate < contractStart) {
|
|
return res.status(400).json({
|
|
message: `Impossibile creare turno: il contratto per il sito "${site.name}" inizia il ${new Date(site.contractStartDate).toLocaleDateString('it-IT')}`
|
|
});
|
|
}
|
|
|
|
// Validate and transform the request body
|
|
const validatedData = insertShiftSchema.parse({
|
|
siteId: req.body.siteId,
|
|
startTime,
|
|
endTime,
|
|
status: req.body.status || "planned",
|
|
vehicleId: req.body.vehicleId || null,
|
|
});
|
|
|
|
const shift = await storage.createShift(validatedData);
|
|
|
|
// Se ci sono guardie da assegnare, crea le assegnazioni
|
|
if (req.body.guardIds && Array.isArray(req.body.guardIds) && req.body.guardIds.length > 0) {
|
|
for (const guardId of req.body.guardIds) {
|
|
await storage.createShiftAssignment({
|
|
shiftId: shift.id,
|
|
guardId,
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json(shift);
|
|
} catch (error) {
|
|
console.error("Error creating shift:", error);
|
|
res.status(500).json({ message: "Failed to create shift" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/shifts/:id/status", isAuthenticated, async (req, res) => {
|
|
try {
|
|
await storage.updateShiftStatus(req.params.id, req.body.status);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error updating shift status:", error);
|
|
res.status(500).json({ message: "Failed to update shift status" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/shifts/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const { startTime: startTimeStr, endTime: endTimeStr, ...rest } = req.body;
|
|
const updateData: any = { ...rest };
|
|
|
|
if (startTimeStr) {
|
|
const startTime = new Date(startTimeStr);
|
|
if (isNaN(startTime.getTime())) {
|
|
return res.status(400).json({ message: "Invalid start time format" });
|
|
}
|
|
updateData.startTime = startTime;
|
|
}
|
|
|
|
if (endTimeStr) {
|
|
const endTime = new Date(endTimeStr);
|
|
if (isNaN(endTime.getTime())) {
|
|
return res.status(400).json({ message: "Invalid end time format" });
|
|
}
|
|
updateData.endTime = endTime;
|
|
}
|
|
|
|
const updated = await storage.updateShift(req.params.id, updateData);
|
|
if (!updated) {
|
|
return res.status(404).json({ message: "Shift not found" });
|
|
}
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error("Error updating shift:", error);
|
|
res.status(500).json({ message: "Failed to update shift" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/shifts/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const deleted = await storage.deleteShift(req.params.id);
|
|
if (!deleted) {
|
|
return res.status(404).json({ message: "Shift not found" });
|
|
}
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting shift:", error);
|
|
res.status(500).json({ message: "Failed to delete shift" });
|
|
}
|
|
});
|
|
|
|
// ============= 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 {
|
|
const assignment = await storage.createShiftAssignment(req.body);
|
|
res.json(assignment);
|
|
} catch (error) {
|
|
console.error("Error creating shift assignment:", error);
|
|
res.status(500).json({ message: "Failed to create shift assignment" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/shift-assignments/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
await storage.deleteShiftAssignment(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting shift assignment:", error);
|
|
res.status(500).json({ message: "Failed to delete shift assignment" });
|
|
}
|
|
});
|
|
|
|
// ============= NOTIFICATION ROUTES =============
|
|
app.get("/api/notifications", isAuthenticated, async (req: any, res) => {
|
|
try {
|
|
const userId = getUserId(req);
|
|
const userNotifications = await storage.getNotificationsByUser(userId);
|
|
res.json(userNotifications);
|
|
} catch (error) {
|
|
console.error("Error fetching user notifications:", error);
|
|
res.status(500).json({ message: "Failed to fetch notifications" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/notifications/:id/read", isAuthenticated, async (req, res) => {
|
|
try {
|
|
await storage.markNotificationAsRead(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error marking notification as read:", error);
|
|
res.status(500).json({ message: "Failed to mark notification as read" });
|
|
}
|
|
});
|
|
|
|
// ============= GUARD CONSTRAINTS ROUTES =============
|
|
app.get("/api/guard-constraints/:guardId", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const constraints = await storage.getGuardConstraints(req.params.guardId);
|
|
res.json(constraints || null);
|
|
} catch (error) {
|
|
console.error("Error fetching guard constraints:", error);
|
|
res.status(500).json({ message: "Failed to fetch guard constraints" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/guard-constraints", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const constraints = await storage.upsertGuardConstraints(req.body);
|
|
res.json(constraints);
|
|
} catch (error) {
|
|
console.error("Error upserting guard constraints:", error);
|
|
res.status(500).json({ message: "Failed to save guard constraints" });
|
|
}
|
|
});
|
|
|
|
// ============= SITE PREFERENCES ROUTES =============
|
|
app.get("/api/site-preferences/:siteId", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const preferences = await storage.getSitePreferences(req.params.siteId);
|
|
res.json(preferences);
|
|
} catch (error) {
|
|
console.error("Error fetching site preferences:", error);
|
|
res.status(500).json({ message: "Failed to fetch site preferences" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/site-preferences", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const preference = await storage.createSitePreference(req.body);
|
|
res.json(preference);
|
|
} catch (error) {
|
|
console.error("Error creating site preference:", error);
|
|
res.status(500).json({ message: "Failed to create site preference" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/site-preferences/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
await storage.deleteSitePreference(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting site preference:", error);
|
|
res.status(500).json({ message: "Failed to delete site preference" });
|
|
}
|
|
});
|
|
|
|
// ============= TRAINING COURSES ROUTES =============
|
|
app.get("/api/training-courses", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const guardId = req.query.guardId as string | undefined;
|
|
const courses = guardId
|
|
? await storage.getTrainingCoursesByGuard(guardId)
|
|
: await storage.getAllTrainingCourses();
|
|
res.json(courses);
|
|
} catch (error) {
|
|
console.error("Error fetching training courses:", error);
|
|
res.status(500).json({ message: "Failed to fetch training courses" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/training-courses", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const course = await storage.createTrainingCourse(req.body);
|
|
res.json(course);
|
|
} catch (error) {
|
|
console.error("Error creating training course:", error);
|
|
res.status(500).json({ message: "Failed to create training course" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/training-courses/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const updated = await storage.updateTrainingCourse(req.params.id, req.body);
|
|
if (!updated) {
|
|
return res.status(404).json({ message: "Training course not found" });
|
|
}
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error("Error updating training course:", error);
|
|
res.status(500).json({ message: "Failed to update training course" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/training-courses/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
await storage.deleteTrainingCourse(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting training course:", error);
|
|
res.status(500).json({ message: "Failed to delete training course" });
|
|
}
|
|
});
|
|
|
|
// ============= HOLIDAYS ROUTES =============
|
|
app.get("/api/holidays", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
|
|
const holidays = await storage.getAllHolidays(year);
|
|
res.json(holidays);
|
|
} catch (error) {
|
|
console.error("Error fetching holidays:", error);
|
|
res.status(500).json({ message: "Failed to fetch holidays" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/holidays", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const holiday = await storage.createHoliday(req.body);
|
|
res.json(holiday);
|
|
} catch (error) {
|
|
console.error("Error creating holiday:", error);
|
|
res.status(500).json({ message: "Failed to create holiday" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/holidays/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
await storage.deleteHoliday(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting holiday:", error);
|
|
res.status(500).json({ message: "Failed to delete holiday" });
|
|
}
|
|
});
|
|
|
|
// ============= ABSENCES ROUTES =============
|
|
app.get("/api/absences", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const guardId = req.query.guardId as string | undefined;
|
|
const absences = guardId
|
|
? await storage.getAbsencesByGuard(guardId)
|
|
: await storage.getAllAbsences();
|
|
res.json(absences);
|
|
} catch (error) {
|
|
console.error("Error fetching absences:", error);
|
|
res.status(500).json({ message: "Failed to fetch absences" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/absences", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const absence = await storage.createAbsence(req.body);
|
|
res.json(absence);
|
|
} catch (error) {
|
|
console.error("Error creating absence:", error);
|
|
res.status(500).json({ message: "Failed to create absence" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/absences/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
const updated = await storage.updateAbsence(req.params.id, req.body);
|
|
if (!updated) {
|
|
return res.status(404).json({ message: "Absence not found" });
|
|
}
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error("Error updating absence:", error);
|
|
res.status(500).json({ message: "Failed to update absence" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/absences/:id", isAuthenticated, async (req, res) => {
|
|
try {
|
|
await storage.deleteAbsence(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting absence:", error);
|
|
res.status(500).json({ message: "Failed to delete absence" });
|
|
}
|
|
});
|
|
|
|
const httpServer = createServer(app);
|
|
return httpServer;
|
|
}
|