Implement new API endpoints and UI components to generate and display monthly reports for guard hours (including overtime and meal vouchers) and billable site hours, with filtering by month and location. 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/KiuJzNf
2936 lines
109 KiB
TypeScript
2936 lines
109 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, vehicles, serviceTypes, createMultiDayShiftSchema } 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, addDays } 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" });
|
||
}
|
||
});
|
||
|
||
// Get guards availability for general planning with time slot conflict detection
|
||
app.get("/api/guards/availability", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const { start, end, siteId, location } = req.query;
|
||
|
||
if (!start || !end || !siteId || !location) {
|
||
return res.status(400).json({
|
||
message: "Missing required parameters: start, end, siteId, location"
|
||
});
|
||
}
|
||
|
||
const startDate = parseISO(start as string);
|
||
const endDate = parseISO(end as string);
|
||
|
||
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(
|
||
siteId as string,
|
||
location as string,
|
||
startDate,
|
||
endDate
|
||
);
|
||
|
||
res.json(availability);
|
||
} catch (error) {
|
||
console.error("Error fetching guards availability:", error);
|
||
res.status(500).json({ message: "Failed to fetch guards availability" });
|
||
}
|
||
});
|
||
|
||
// Get vehicles available for a location
|
||
app.get("/api/vehicles/available", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const { location } = req.query;
|
||
|
||
if (!location) {
|
||
return res.status(400).json({ message: "Missing required parameter: location" });
|
||
}
|
||
|
||
// Get all vehicles for this location with status 'available'
|
||
const availableVehicles = await db
|
||
.select()
|
||
.from(vehicles)
|
||
.where(
|
||
and(
|
||
eq(vehicles.location, location as any),
|
||
eq(vehicles.status, "available")
|
||
)
|
||
)
|
||
.orderBy(vehicles.licensePlate);
|
||
|
||
res.json(availableVehicles);
|
||
} catch (error) {
|
||
console.error("Error fetching available vehicles:", error);
|
||
res.status(500).json({ message: "Failed to fetch available vehicles" });
|
||
}
|
||
});
|
||
|
||
// ============= 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) });
|
||
}
|
||
});
|
||
|
||
// Endpoint per Planning Generale - vista settimanale con calcolo guardie mancanti
|
||
app.get("/api/general-planning", isAuthenticated, async (req, res) => {
|
||
try {
|
||
// Sanitizza input data inizio settimana
|
||
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
|
||
const normalizedWeekStart = rawWeekStart.split("/")[0];
|
||
|
||
// Valida la data
|
||
const parsedWeekStart = parseISO(normalizedWeekStart);
|
||
if (!isValid(parsedWeekStart)) {
|
||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||
}
|
||
|
||
const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd");
|
||
|
||
// Ottieni location dalla query (default: roccapiemonte)
|
||
const location = req.query.location as string || "roccapiemonte";
|
||
|
||
// Calcola fine settimana (weekStart + 6 giorni)
|
||
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
|
||
|
||
// Timestamp per filtro contratti
|
||
const weekStartTimestampForContract = new Date(weekStartDate);
|
||
const weekEndTimestampForContract = new Date(weekEndDate);
|
||
|
||
// Ottieni tutti i siti attivi della sede con contratto valido nelle date della settimana
|
||
const activeSites = await db
|
||
.select()
|
||
.from(sites)
|
||
.leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id))
|
||
.where(
|
||
and(
|
||
eq(sites.isActive, true),
|
||
eq(sites.location, location as any),
|
||
// Contratto deve essere valido in almeno un giorno della settimana
|
||
// contractStartDate <= weekEnd AND contractEndDate >= weekStart
|
||
lte(sites.contractStartDate, weekEndTimestampForContract),
|
||
gte(sites.contractEndDate, weekStartTimestampForContract)
|
||
)
|
||
);
|
||
|
||
// Ottieni tutti i turni della settimana per la sede
|
||
const weekStartTimestamp = new Date(weekStartDate);
|
||
weekStartTimestamp.setHours(0, 0, 0, 0);
|
||
|
||
const weekEndTimestamp = new Date(weekEndDate);
|
||
weekEndTimestamp.setHours(23, 59, 59, 999);
|
||
|
||
const weekShifts = await db
|
||
.select({
|
||
shift: shifts,
|
||
site: sites,
|
||
})
|
||
.from(shifts)
|
||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||
.where(
|
||
and(
|
||
gte(shifts.startTime, weekStartTimestamp),
|
||
lte(shifts.startTime, weekEndTimestamp),
|
||
ne(shifts.status, "cancelled"),
|
||
eq(sites.location, location as any)
|
||
)
|
||
);
|
||
|
||
// Ottieni tutte le assegnazioni dei turni della settimana
|
||
const shiftIds = weekShifts.map((s: any) => s.shift.id);
|
||
|
||
const assignments = shiftIds.length > 0 ? await db
|
||
.select({
|
||
assignment: shiftAssignments,
|
||
guard: guards,
|
||
shift: shifts,
|
||
})
|
||
.from(shiftAssignments)
|
||
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
|
||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||
.where(
|
||
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
|
||
) : [];
|
||
|
||
// Ottieni veicoli assegnati
|
||
const vehicleAssignments = weekShifts
|
||
.filter((s: any) => s.shift.vehicleId)
|
||
.map((s: any) => s.shift.vehicleId);
|
||
|
||
const assignedVehicles = vehicleAssignments.length > 0 ? await db
|
||
.select()
|
||
.from(vehicles)
|
||
.where(
|
||
sql`${vehicles.id} IN (${sql.join(vehicleAssignments.map((id: string) => sql`${id}`), sql`, `)})`
|
||
) : [];
|
||
|
||
// Costruisci struttura dati per 7 giorni
|
||
const weekData = [];
|
||
|
||
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
||
const currentDay = addDays(parsedWeekStart, dayOffset);
|
||
const dayStr = format(currentDay, "yyyy-MM-dd");
|
||
|
||
const dayStartTimestamp = new Date(dayStr);
|
||
dayStartTimestamp.setHours(0, 0, 0, 0);
|
||
|
||
const dayEndTimestamp = new Date(dayStr);
|
||
dayEndTimestamp.setHours(23, 59, 59, 999);
|
||
|
||
const sitesData = activeSites.map(({ sites: site, service_types: serviceType }: any) => {
|
||
// Trova turni del giorno per questo sito
|
||
const dayShifts = weekShifts.filter((s: any) =>
|
||
s.shift.siteId === site.id &&
|
||
s.shift.startTime >= dayStartTimestamp &&
|
||
s.shift.startTime <= dayEndTimestamp
|
||
);
|
||
|
||
// Ottieni assegnazioni guardie per i turni del giorno
|
||
const dayAssignments = assignments.filter((a: any) =>
|
||
dayShifts.some((ds: any) => ds.shift.id === a.shift.id)
|
||
);
|
||
|
||
// Calcola ore per ogni guardia con orari e assignmentId
|
||
const guardsWithHours = dayAssignments.map((a: any) => {
|
||
const plannedStart = new Date(a.assignment.plannedStartTime);
|
||
const plannedEnd = new Date(a.assignment.plannedEndTime);
|
||
const hours = differenceInHours(plannedEnd, plannedStart);
|
||
|
||
return {
|
||
assignmentId: a.assignment.id,
|
||
guardId: a.guard.id,
|
||
guardName: a.guard.fullName,
|
||
badgeNumber: a.guard.badgeNumber,
|
||
hours,
|
||
plannedStartTime: a.assignment.plannedStartTime,
|
||
plannedEndTime: a.assignment.plannedEndTime,
|
||
};
|
||
});
|
||
|
||
// Veicoli assegnati ai turni del giorno
|
||
const dayVehicles = dayShifts
|
||
.filter((ds: any) => ds.shift.vehicleId)
|
||
.map((ds: any) => {
|
||
const vehicle = assignedVehicles.find((v: any) => v.id === ds.shift.vehicleId);
|
||
return vehicle ? {
|
||
vehicleId: vehicle.id,
|
||
licensePlate: vehicle.licensePlate,
|
||
brand: vehicle.brand,
|
||
model: vehicle.model,
|
||
} : null;
|
||
})
|
||
.filter(Boolean);
|
||
|
||
// Calcolo guardie mancanti
|
||
const maxOreGuardia = 9; // Max ore per guardia
|
||
const minGuardie = site.minGuards || 1;
|
||
|
||
// Somma ore totali dei turni del giorno
|
||
const totalShiftHours = dayShifts.reduce((sum: number, ds: any) => {
|
||
const start = new Date(ds.shift.startTime);
|
||
const end = new Date(ds.shift.endTime);
|
||
return sum + differenceInHours(end, start);
|
||
}, 0);
|
||
|
||
// Guardie uniche assegnate (conta ogni guardia una volta anche se ha più turni)
|
||
const uniqueGuardsAssigned = new Set(guardsWithHours.map((g: any) => g.guardId)).size;
|
||
|
||
// Calcolo guardie necessarie e mancanti
|
||
let totalGuardsNeeded: number;
|
||
let missingGuards: number;
|
||
|
||
if (totalShiftHours > 0) {
|
||
// Se ci sono turni: calcola basandosi sulle ore
|
||
// Slot necessari per coprire le ore totali
|
||
const slotsNeeded = Math.ceil(totalShiftHours / maxOreGuardia);
|
||
// Guardie totali necessarie (slot × min guardie contemporanee)
|
||
totalGuardsNeeded = slotsNeeded * minGuardie;
|
||
missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned);
|
||
} else {
|
||
// Se NON ci sono turni: serve almeno la copertura minima
|
||
totalGuardsNeeded = minGuardie;
|
||
missingGuards = minGuardie; // Tutte mancanti perché non ci sono turni
|
||
}
|
||
|
||
return {
|
||
siteId: site.id,
|
||
siteName: site.name,
|
||
serviceType: serviceType?.label || "N/A",
|
||
minGuards: site.minGuards,
|
||
guards: guardsWithHours,
|
||
vehicles: dayVehicles,
|
||
totalShiftHours,
|
||
guardsAssigned: uniqueGuardsAssigned,
|
||
missingGuards,
|
||
shiftsCount: dayShifts.length,
|
||
};
|
||
});
|
||
|
||
weekData.push({
|
||
date: dayStr,
|
||
dayOfWeek: format(currentDay, "EEEE"),
|
||
sites: sitesData,
|
||
});
|
||
}
|
||
|
||
// Calcola guardie totali necessarie per l'intera settimana
|
||
let totalGuardsNeededForWeek = 0;
|
||
let totalGuardsAssignedForWeek = 0;
|
||
|
||
for (const day of weekData) {
|
||
for (const siteData of day.sites) {
|
||
// Somma guardie necessarie (già calcolate per sito/giorno)
|
||
// totalGuardsNeeded per sito = guardsAssigned + missingGuards
|
||
totalGuardsNeededForWeek += (siteData.guardsAssigned + siteData.missingGuards);
|
||
|
||
// Somma slot guardia assegnati (non guardie uniche)
|
||
// Questo conta ogni assegnazione, anche se la stessa guardia lavora più turni
|
||
totalGuardsAssignedForWeek += siteData.guardsAssigned;
|
||
}
|
||
}
|
||
|
||
const totalGuardsMissingForWeek = Math.max(0, totalGuardsNeededForWeek - totalGuardsAssignedForWeek);
|
||
|
||
res.json({
|
||
weekStart: weekStartDate,
|
||
weekEnd: weekEndDate,
|
||
location,
|
||
days: weekData,
|
||
summary: {
|
||
totalGuardsNeeded: totalGuardsNeededForWeek,
|
||
totalGuardsAssigned: totalGuardsAssignedForWeek,
|
||
totalGuardsMissing: totalGuardsMissingForWeek,
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error("Error fetching general planning:", error);
|
||
res.status(500).json({ message: "Failed to fetch general planning", error: String(error) });
|
||
}
|
||
});
|
||
|
||
// Create multi-day shift from general planning
|
||
app.post("/api/general-planning/shifts", isAuthenticated, async (req, res) => {
|
||
try {
|
||
// Validate request body
|
||
const validationResult = createMultiDayShiftSchema.safeParse(req.body);
|
||
if (!validationResult.success) {
|
||
return res.status(400).json({
|
||
message: "Invalid request data",
|
||
errors: validationResult.error.errors
|
||
});
|
||
}
|
||
|
||
const { siteId, startDate, days, guardId, shiftType } = validationResult.data;
|
||
|
||
// 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" });
|
||
}
|
||
|
||
// Pre-validate all dates are within contract period
|
||
const startDateParsed = parseISO(startDate);
|
||
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
|
||
const shiftDate = addDays(startDateParsed, dayOffset);
|
||
const shiftDateStr = format(shiftDate, "yyyy-MM-dd");
|
||
|
||
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 create shift for ${shiftDateStr}: outside contract period`
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create shifts atomically in a transaction
|
||
const createdShifts = await db.transaction(async (tx) => {
|
||
const createdShiftsInTx = [];
|
||
|
||
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
|
||
const shiftDate = addDays(startDateParsed, dayOffset);
|
||
|
||
// Use site service schedule or default 24h
|
||
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 service ends before it starts, it spans midnight (add 1 day to end)
|
||
if (shiftEnd <= shiftStart) {
|
||
shiftEnd.setDate(shiftEnd.getDate() + 1);
|
||
}
|
||
|
||
// Create shift in transaction
|
||
const [shift] = await tx.insert(shifts).values({
|
||
siteId: site.id,
|
||
startTime: shiftStart,
|
||
endTime: shiftEnd,
|
||
shiftType: shiftType || site.shiftType || "fixed_post",
|
||
status: "planned",
|
||
}).returning();
|
||
|
||
// Create shift assignment in transaction
|
||
await tx.insert(shiftAssignments).values({
|
||
shiftId: shift.id,
|
||
guardId: guard.id,
|
||
});
|
||
|
||
createdShiftsInTx.push(shift);
|
||
}
|
||
|
||
return createdShiftsInTx;
|
||
});
|
||
|
||
res.json({
|
||
message: `Created ${createdShifts.length} shifts`,
|
||
shifts: createdShifts
|
||
});
|
||
} catch (error) {
|
||
console.error("Error creating multi-day shifts:", error);
|
||
res.status(500).json({ message: "Failed to create shifts", error: String(error) });
|
||
}
|
||
});
|
||
|
||
// Delete a shift assignment
|
||
app.delete("/api/shift-assignments/:assignmentId", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const { assignmentId } = req.params;
|
||
|
||
if (!assignmentId) {
|
||
return res.status(400).json({ message: "Assignment ID is required" });
|
||
}
|
||
|
||
// Delete the assignment
|
||
const deleted = await db
|
||
.delete(shiftAssignments)
|
||
.where(eq(shiftAssignments.id, assignmentId))
|
||
.returning();
|
||
|
||
if (deleted.length === 0) {
|
||
return res.status(404).json({ message: "Assignment not found" });
|
||
}
|
||
|
||
res.json({
|
||
message: "Assignment deleted successfully",
|
||
assignment: deleted[0]
|
||
});
|
||
} catch (error: any) {
|
||
console.error("Error deleting assignment:", error);
|
||
res.status(500).json({ message: "Failed to delete assignment", error: String(error) });
|
||
}
|
||
});
|
||
|
||
// Assign guard to site/date with specific time slot (supports multi-day assignments)
|
||
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId } = req.body;
|
||
|
||
if (!siteId || !date || !guardId || !startTime || !durationHours) {
|
||
return res.status(400).json({
|
||
message: "Missing required fields: siteId, date, guardId, startTime, durationHours"
|
||
});
|
||
}
|
||
|
||
if (consecutiveDays < 1 || consecutiveDays > 30) {
|
||
return res.status(400).json({ message: "consecutiveDays must be between 1 and 30" });
|
||
}
|
||
|
||
// 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 start date components
|
||
const [year, month, day] = date.split("-").map(Number);
|
||
if (!year || !month || !day || month < 1 || month > 12 || day < 1 || day > 31) {
|
||
return res.status(400).json({ message: "Invalid date format. Expected YYYY-MM-DD" });
|
||
}
|
||
const [hours, minutes] = startTime.split(":").map(Number);
|
||
|
||
// Atomic transaction: create assignments for all consecutive days
|
||
const result = await db.transaction(async (tx) => {
|
||
const createdAssignments = [];
|
||
|
||
// Loop through each consecutive day
|
||
for (let dayOffset = 0; dayOffset < consecutiveDays; dayOffset++) {
|
||
// Calculate date components for this iteration (avoid timezone issues)
|
||
const targetDay = day + dayOffset;
|
||
const baseDate = new Date(year, month - 1, 1); // First day of month
|
||
baseDate.setDate(targetDay); // Set to target day (handles month overflow)
|
||
|
||
// Extract actual date components after overflow handling
|
||
const actualYear = baseDate.getFullYear();
|
||
const actualMonth = baseDate.getMonth();
|
||
const actualDay = baseDate.getDate();
|
||
|
||
// Build dates in UTC to avoid timezone shifts
|
||
const shiftDate = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0));
|
||
|
||
// Check contract validity for this date
|
||
if (site.contractStartDate && site.contractEndDate) {
|
||
const contractStart = new Date(site.contractStartDate);
|
||
const contractEnd = new Date(site.contractEndDate);
|
||
if (shiftDate < contractStart || shiftDate > contractEnd) {
|
||
throw new Error(
|
||
`Cannot assign guard for date ${shiftDate.toLocaleDateString()}: outside contract period`
|
||
);
|
||
}
|
||
}
|
||
|
||
// Calculate planned start and end times in UTC
|
||
const plannedStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours, minutes, 0, 0));
|
||
const plannedEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours + durationHours, minutes, 0, 0));
|
||
|
||
// Find or create shift for this site/date (full day boundaries in UTC)
|
||
const dayStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0));
|
||
const dayEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, 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(Date.UTC(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0));
|
||
let shiftEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, endHour, endMin, 0, 0));
|
||
|
||
// If end time is before/equal to start time, shift extends to next day
|
||
if (shiftEnd <= shiftStart) {
|
||
shiftEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay + 1, endHour, endMin, 0, 0));
|
||
}
|
||
|
||
[shift] = await tx.insert(shifts).values({
|
||
siteId: site.id,
|
||
startTime: shiftStart,
|
||
endTime: shiftEnd,
|
||
shiftType: site.shiftType || "fixed_post",
|
||
status: "planned",
|
||
vehicleId: vehicleId || null,
|
||
}).returning();
|
||
}
|
||
|
||
// Recheck overlaps within transaction for this day
|
||
const existingAssignments = await tx
|
||
.select()
|
||
.from(shiftAssignments)
|
||
.where(eq(shiftAssignments.guardId, guard.id));
|
||
|
||
// Check for time overlaps
|
||
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()}`
|
||
);
|
||
}
|
||
}
|
||
|
||
// CCNL: Check daily hour limit (max 9h/day)
|
||
const maxDailyHours = 9;
|
||
let dailyHoursAlreadyAssigned = 0;
|
||
|
||
for (const existing of existingAssignments) {
|
||
// Check if assignment is on the same day
|
||
const existingDate = new Date(existing.plannedStartTime);
|
||
if (
|
||
existingDate.getUTCFullYear() === actualYear &&
|
||
existingDate.getUTCMonth() === actualMonth &&
|
||
existingDate.getUTCDate() === actualDay
|
||
) {
|
||
const assignmentHours = differenceInHours(
|
||
existing.plannedEndTime,
|
||
existing.plannedStartTime
|
||
);
|
||
dailyHoursAlreadyAssigned += assignmentHours;
|
||
}
|
||
}
|
||
|
||
// Check if new assignment would exceed daily limit
|
||
if (dailyHoursAlreadyAssigned + durationHours > maxDailyHours) {
|
||
throw new Error(
|
||
`Limite giornaliero superato: la guardia ha già ${dailyHoursAlreadyAssigned}h assegnate il ${shiftDate.toLocaleDateString('it-IT')}. ` +
|
||
`Aggiungendo ${durationHours}h si supererebbero le ${maxDailyHours}h massime giornaliere (CCNL).`
|
||
);
|
||
}
|
||
|
||
// Create assignment for this day
|
||
const [assignment] = await tx.insert(shiftAssignments).values({
|
||
shiftId: shift.id,
|
||
guardId: guard.id,
|
||
plannedStartTime: plannedStart,
|
||
plannedEndTime: plannedEnd,
|
||
}).returning();
|
||
|
||
createdAssignments.push(assignment);
|
||
}
|
||
|
||
return { assignments: createdAssignments, count: createdAssignments.length };
|
||
});
|
||
|
||
res.json({
|
||
message: `Guard assigned successfully for ${result.count} day(s)`,
|
||
assignments: result.assignments,
|
||
count: result.count
|
||
});
|
||
} 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) });
|
||
}
|
||
});
|
||
|
||
// ============= SERVICE PLANNING ROUTES =============
|
||
|
||
// Vista per Guardia - mostra orari e dotazioni per ogni guardia
|
||
app.get("/api/service-planning/by-guard", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
|
||
const normalizedWeekStart = rawWeekStart.split("/")[0];
|
||
|
||
const parsedWeekStart = parseISO(normalizedWeekStart);
|
||
if (!isValid(parsedWeekStart)) {
|
||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||
}
|
||
|
||
const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd");
|
||
const location = req.query.location as string || "roccapiemonte";
|
||
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
|
||
|
||
const weekStartTimestamp = new Date(weekStartDate);
|
||
weekStartTimestamp.setHours(0, 0, 0, 0);
|
||
|
||
const weekEndTimestamp = new Date(weekEndDate);
|
||
weekEndTimestamp.setHours(23, 59, 59, 999);
|
||
|
||
// Ottieni tutte le guardie della sede
|
||
const allGuards = await db
|
||
.select()
|
||
.from(guards)
|
||
.where(eq(guards.location, location as any))
|
||
.orderBy(guards.fullName);
|
||
|
||
// Ottieni tutti i turni della settimana per la sede (con JOIN su sites per filtrare location)
|
||
const weekShifts = await db
|
||
.select({
|
||
shift: shifts,
|
||
site: sites,
|
||
vehicle: vehicles,
|
||
})
|
||
.from(shifts)
|
||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||
.leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id))
|
||
.where(
|
||
and(
|
||
gte(shifts.startTime, weekStartTimestamp),
|
||
lte(shifts.startTime, weekEndTimestamp),
|
||
ne(shifts.status, "cancelled"),
|
||
eq(sites.location, location as any)
|
||
)
|
||
);
|
||
|
||
// Ottieni tutte le assegnazioni per i turni della settimana
|
||
const shiftIds = weekShifts.map((s: any) => s.shift.id);
|
||
const assignments = shiftIds.length > 0 ? await db
|
||
.select({
|
||
assignment: shiftAssignments,
|
||
guard: guards,
|
||
})
|
||
.from(shiftAssignments)
|
||
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
|
||
.where(
|
||
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
|
||
) : [];
|
||
|
||
// Costruisci dati per ogni guardia
|
||
const guardSchedules = allGuards.map((guard: any) => {
|
||
// Trova assegnazioni della guardia
|
||
const guardAssignments = assignments.filter((a: any) => a.guard.id === guard.id);
|
||
|
||
// Costruisci lista turni con dettagli
|
||
const shifts = guardAssignments.map((a: any) => {
|
||
const shiftData = weekShifts.find((s: any) => s.shift.id === a.assignment.shiftId);
|
||
if (!shiftData) return null;
|
||
|
||
const plannedStart = new Date(a.assignment.plannedStartTime);
|
||
const plannedEnd = new Date(a.assignment.plannedEndTime);
|
||
const minutes = differenceInMinutes(plannedEnd, plannedStart);
|
||
const hours = Math.round((minutes / 60) * 10) / 10; // Arrotonda a 1 decimale
|
||
|
||
return {
|
||
shiftId: shiftData.shift.id,
|
||
date: format(plannedStart, "yyyy-MM-dd"),
|
||
from: format(plannedStart, "HH:mm"),
|
||
to: format(plannedEnd, "HH:mm"),
|
||
siteName: shiftData.site.name,
|
||
siteId: shiftData.site.id,
|
||
vehicle: shiftData.vehicle ? {
|
||
licensePlate: shiftData.vehicle.licensePlate,
|
||
brand: shiftData.vehicle.brand,
|
||
model: shiftData.vehicle.model,
|
||
} : undefined,
|
||
hours,
|
||
};
|
||
}).filter(Boolean);
|
||
|
||
const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.hours, 0) * 10) / 10;
|
||
|
||
return {
|
||
guardId: guard.id,
|
||
guardName: guard.fullName,
|
||
badgeNumber: guard.badgeNumber,
|
||
shifts,
|
||
totalHours,
|
||
};
|
||
});
|
||
|
||
// Filtra solo guardie con turni assegnati
|
||
const guardsWithShifts = guardSchedules.filter((g: any) => g.shifts.length > 0);
|
||
|
||
res.json(guardsWithShifts);
|
||
} catch (error) {
|
||
console.error("Error fetching guard schedules:", error);
|
||
res.status(500).json({ message: "Failed to fetch guard schedules", error: String(error) });
|
||
}
|
||
});
|
||
|
||
// Vista per Sito - mostra agenti e dotazioni per ogni sito
|
||
app.get("/api/service-planning/by-site", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
|
||
const normalizedWeekStart = rawWeekStart.split("/")[0];
|
||
|
||
const parsedWeekStart = parseISO(normalizedWeekStart);
|
||
if (!isValid(parsedWeekStart)) {
|
||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||
}
|
||
|
||
const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd");
|
||
const location = req.query.location as string || "roccapiemonte";
|
||
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
|
||
|
||
const weekStartTimestamp = new Date(weekStartDate);
|
||
weekStartTimestamp.setHours(0, 0, 0, 0);
|
||
|
||
const weekEndTimestamp = new Date(weekEndDate);
|
||
weekEndTimestamp.setHours(23, 59, 59, 999);
|
||
|
||
// Ottieni tutti i siti attivi della sede
|
||
const activeSites = await db
|
||
.select()
|
||
.from(sites)
|
||
.where(
|
||
and(
|
||
eq(sites.isActive, true),
|
||
eq(sites.location, location as any)
|
||
)
|
||
)
|
||
.orderBy(sites.name);
|
||
|
||
// Ottieni tutti i turni della settimana per la sede
|
||
const weekShifts = await db
|
||
.select({
|
||
shift: shifts,
|
||
site: sites,
|
||
vehicle: vehicles,
|
||
})
|
||
.from(shifts)
|
||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||
.leftJoin(vehicles, eq(shifts.vehicleId, vehicles.id))
|
||
.where(
|
||
and(
|
||
gte(shifts.startTime, weekStartTimestamp),
|
||
lte(shifts.startTime, weekEndTimestamp),
|
||
ne(shifts.status, "cancelled"),
|
||
eq(sites.location, location as any)
|
||
)
|
||
);
|
||
|
||
// Ottieni tutte le assegnazioni per i turni della settimana
|
||
const shiftIds = weekShifts.map((s: any) => s.shift.id);
|
||
const assignments = shiftIds.length > 0 ? await db
|
||
.select({
|
||
assignment: shiftAssignments,
|
||
guard: guards,
|
||
})
|
||
.from(shiftAssignments)
|
||
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
|
||
.where(
|
||
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
|
||
) : [];
|
||
|
||
// Costruisci dati per ogni sito
|
||
const siteSchedules = activeSites.map((site: any) => {
|
||
// Trova turni del sito
|
||
const siteShifts = weekShifts.filter((s: any) => s.site.id === site.id);
|
||
|
||
// Costruisci lista turni con guardie e veicoli
|
||
const shifts = siteShifts.map((shiftData: any) => {
|
||
const shiftAssignments = assignments.filter((a: any) => a.assignment.shiftId === shiftData.shift.id);
|
||
|
||
const guards = shiftAssignments.map((a: any) => {
|
||
const plannedStart = new Date(a.assignment.plannedStartTime);
|
||
const plannedEnd = new Date(a.assignment.plannedEndTime);
|
||
const minutes = differenceInMinutes(plannedEnd, plannedStart);
|
||
const hours = Math.round((minutes / 60) * 10) / 10; // Arrotonda a 1 decimale
|
||
|
||
return {
|
||
guardName: a.guard.fullName,
|
||
badgeNumber: a.guard.badgeNumber,
|
||
hours,
|
||
};
|
||
});
|
||
|
||
const shiftStart = new Date(shiftData.shift.startTime);
|
||
const shiftEnd = new Date(shiftData.shift.endTime);
|
||
const minutes = differenceInMinutes(shiftEnd, shiftStart);
|
||
const totalHours = Math.round((minutes / 60) * 10) / 10;
|
||
|
||
return {
|
||
shiftId: shiftData.shift.id,
|
||
date: format(shiftStart, "yyyy-MM-dd"),
|
||
from: format(shiftStart, "HH:mm"),
|
||
to: format(shiftEnd, "HH:mm"),
|
||
guards,
|
||
vehicle: shiftData.vehicle ? {
|
||
licensePlate: shiftData.vehicle.licensePlate,
|
||
brand: shiftData.vehicle.brand,
|
||
model: shiftData.vehicle.model,
|
||
} : undefined,
|
||
totalGuards: guards.length,
|
||
totalHours,
|
||
};
|
||
});
|
||
|
||
const totalHours = Math.round(shifts.reduce((sum: number, s: any) => sum + s.totalHours, 0) * 10) / 10;
|
||
|
||
return {
|
||
siteId: site.id,
|
||
siteName: site.name,
|
||
location: site.location,
|
||
shifts,
|
||
totalShifts: shifts.length,
|
||
totalHours,
|
||
};
|
||
});
|
||
|
||
// Filtra solo siti con turni programmati
|
||
const sitesWithShifts = siteSchedules.filter((s: any) => s.shifts.length > 0);
|
||
|
||
res.json(sitesWithShifts);
|
||
} catch (error) {
|
||
console.error("Error fetching site schedules:", error);
|
||
res.status(500).json({ message: "Failed to fetch site schedules", error: String(error) });
|
||
}
|
||
});
|
||
|
||
// ============= REPORTS ROUTES =============
|
||
|
||
// Report mensile ore per guardia (ordinarie/straordinarie + buoni pasto)
|
||
app.get("/api/reports/monthly-guard-hours", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const rawMonth = req.query.month as string || format(new Date(), "yyyy-MM");
|
||
const location = req.query.location as string || "roccapiemonte";
|
||
|
||
// Parse mese (formato: YYYY-MM)
|
||
const [year, month] = rawMonth.split("-").map(Number);
|
||
const monthStart = new Date(year, month - 1, 1);
|
||
monthStart.setHours(0, 0, 0, 0);
|
||
|
||
const monthEnd = new Date(year, month, 0); // ultimo giorno del mese
|
||
monthEnd.setHours(23, 59, 59, 999);
|
||
|
||
// Ottieni tutte le guardie della sede
|
||
const allGuards = await db
|
||
.select()
|
||
.from(guards)
|
||
.where(eq(guards.location, location as any))
|
||
.orderBy(guards.fullName);
|
||
|
||
// Ottieni tutti i turni del mese per la sede
|
||
const monthShifts = await db
|
||
.select({
|
||
shift: shifts,
|
||
site: sites,
|
||
})
|
||
.from(shifts)
|
||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||
.where(
|
||
and(
|
||
gte(shifts.startTime, monthStart),
|
||
lte(shifts.startTime, monthEnd),
|
||
ne(shifts.status, "cancelled"),
|
||
eq(sites.location, location as any)
|
||
)
|
||
);
|
||
|
||
// Ottieni tutte le assegnazioni del mese
|
||
const shiftIds = monthShifts.map((s: any) => s.shift.id);
|
||
const assignments = shiftIds.length > 0 ? await db
|
||
.select({
|
||
assignment: shiftAssignments,
|
||
guard: guards,
|
||
})
|
||
.from(shiftAssignments)
|
||
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
|
||
.where(
|
||
sql`${shiftAssignments.shiftId} IN (${sql.join(shiftIds.map((id: string) => sql`${id}`), sql`, `)})`
|
||
) : [];
|
||
|
||
// Calcola statistiche per ogni guardia
|
||
const guardReports = allGuards.map((guard: any) => {
|
||
const guardAssignments = assignments.filter((a: any) => a.guard.id === guard.id);
|
||
|
||
// Raggruppa assegnazioni per settimana (lunedì = inizio settimana)
|
||
const weeklyHours: Record<string, number> = {};
|
||
const dailyHours: Record<string, number> = {}; // Per calcolare buoni pasto
|
||
|
||
guardAssignments.forEach((a: any) => {
|
||
const plannedStart = new Date(a.assignment.plannedStartTime);
|
||
const plannedEnd = new Date(a.assignment.plannedEndTime);
|
||
const minutes = differenceInMinutes(plannedEnd, plannedStart);
|
||
const hours = minutes / 60;
|
||
|
||
// Settimana (ISO week - lunedì come primo giorno)
|
||
const weekStart = startOfWeek(plannedStart, { weekStartsOn: 1 });
|
||
const weekKey = format(weekStart, "yyyy-MM-dd");
|
||
weeklyHours[weekKey] = (weeklyHours[weekKey] || 0) + hours;
|
||
|
||
// Giorno (per buoni pasto)
|
||
const dayKey = format(plannedStart, "yyyy-MM-dd");
|
||
dailyHours[dayKey] = (dailyHours[dayKey] || 0) + hours;
|
||
});
|
||
|
||
// Calcola ore ordinarie e straordinarie
|
||
let ordinaryHours = 0;
|
||
let overtimeHours = 0;
|
||
|
||
Object.values(weeklyHours).forEach((weekHours: number) => {
|
||
if (weekHours <= 40) {
|
||
ordinaryHours += weekHours;
|
||
} else {
|
||
ordinaryHours += 40;
|
||
overtimeHours += (weekHours - 40);
|
||
}
|
||
});
|
||
|
||
// Calcola buoni pasto (giorni con ore ≥ 6)
|
||
const mealVouchers = Object.values(dailyHours).filter(
|
||
(dayHours: number) => dayHours >= 6
|
||
).length;
|
||
|
||
const totalHours = ordinaryHours + overtimeHours;
|
||
|
||
return {
|
||
guardId: guard.id,
|
||
guardName: guard.fullName,
|
||
badgeNumber: guard.badgeNumber,
|
||
ordinaryHours: Math.round(ordinaryHours * 10) / 10,
|
||
overtimeHours: Math.round(overtimeHours * 10) / 10,
|
||
totalHours: Math.round(totalHours * 10) / 10,
|
||
mealVouchers,
|
||
workingDays: Object.keys(dailyHours).length,
|
||
};
|
||
});
|
||
|
||
// Filtra solo guardie con ore lavorate
|
||
const guardsWithHours = guardReports.filter((g: any) => g.totalHours > 0);
|
||
|
||
res.json({
|
||
month: rawMonth,
|
||
location,
|
||
guards: guardsWithHours,
|
||
summary: {
|
||
totalGuards: guardsWithHours.length,
|
||
totalOrdinaryHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.ordinaryHours, 0) * 10) / 10,
|
||
totalOvertimeHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.overtimeHours, 0) * 10) / 10,
|
||
totalHours: Math.round(guardsWithHours.reduce((sum: number, g: any) => sum + g.totalHours, 0) * 10) / 10,
|
||
totalMealVouchers: guardsWithHours.reduce((sum: number, g: any) => sum + g.mealVouchers, 0),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error("Error fetching monthly guard hours:", error);
|
||
res.status(500).json({ message: "Failed to fetch monthly guard hours", error: String(error) });
|
||
}
|
||
});
|
||
|
||
// Report ore fatturabili per sito per tipologia servizio
|
||
app.get("/api/reports/billable-site-hours", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const rawMonth = req.query.month as string || format(new Date(), "yyyy-MM");
|
||
const location = req.query.location as string || "roccapiemonte";
|
||
|
||
// Parse mese (formato: YYYY-MM)
|
||
const [year, month] = rawMonth.split("-").map(Number);
|
||
const monthStart = new Date(year, month - 1, 1);
|
||
monthStart.setHours(0, 0, 0, 0);
|
||
|
||
const monthEnd = new Date(year, month, 0);
|
||
monthEnd.setHours(23, 59, 59, 999);
|
||
|
||
// Ottieni tutti i turni del mese per la sede con dettagli sito e servizio
|
||
const monthShifts = await db
|
||
.select({
|
||
shift: shifts,
|
||
site: sites,
|
||
serviceType: serviceTypes,
|
||
})
|
||
.from(shifts)
|
||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||
.leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id))
|
||
.where(
|
||
and(
|
||
gte(shifts.startTime, monthStart),
|
||
lte(shifts.startTime, monthEnd),
|
||
ne(shifts.status, "cancelled"),
|
||
eq(sites.location, location as any)
|
||
)
|
||
);
|
||
|
||
// Raggruppa per sito
|
||
const siteHoursMap: Record<string, any> = {};
|
||
|
||
monthShifts.forEach((shiftData: any) => {
|
||
const siteId = shiftData.site.id;
|
||
const siteName = shiftData.site.name;
|
||
const serviceTypeName = shiftData.serviceType?.name || "Non specificato";
|
||
|
||
const shiftStart = new Date(shiftData.shift.startTime);
|
||
const shiftEnd = new Date(shiftData.shift.endTime);
|
||
const minutes = differenceInMinutes(shiftEnd, shiftStart);
|
||
const hours = minutes / 60;
|
||
|
||
if (!siteHoursMap[siteId]) {
|
||
siteHoursMap[siteId] = {
|
||
siteId,
|
||
siteName,
|
||
serviceTypes: {},
|
||
totalHours: 0,
|
||
totalShifts: 0,
|
||
};
|
||
}
|
||
|
||
if (!siteHoursMap[siteId].serviceTypes[serviceTypeName]) {
|
||
siteHoursMap[siteId].serviceTypes[serviceTypeName] = {
|
||
hours: 0,
|
||
shifts: 0,
|
||
};
|
||
}
|
||
|
||
siteHoursMap[siteId].serviceTypes[serviceTypeName].hours += hours;
|
||
siteHoursMap[siteId].serviceTypes[serviceTypeName].shifts += 1;
|
||
siteHoursMap[siteId].totalHours += hours;
|
||
siteHoursMap[siteId].totalShifts += 1;
|
||
});
|
||
|
||
// Converti mappa in array e arrotonda ore
|
||
const siteReports = Object.values(siteHoursMap).map((site: any) => {
|
||
const serviceTypesArray = Object.entries(site.serviceTypes).map(([name, data]: [string, any]) => ({
|
||
name,
|
||
hours: Math.round(data.hours * 10) / 10,
|
||
shifts: data.shifts,
|
||
}));
|
||
|
||
return {
|
||
siteId: site.siteId,
|
||
siteName: site.siteName,
|
||
serviceTypes: serviceTypesArray,
|
||
totalHours: Math.round(site.totalHours * 10) / 10,
|
||
totalShifts: site.totalShifts,
|
||
};
|
||
});
|
||
|
||
// Ordina per ore totali (decrescente)
|
||
siteReports.sort((a: any, b: any) => b.totalHours - a.totalHours);
|
||
|
||
res.json({
|
||
month: rawMonth,
|
||
location,
|
||
sites: siteReports,
|
||
summary: {
|
||
totalSites: siteReports.length,
|
||
totalHours: Math.round(siteReports.reduce((sum: number, s: any) => sum + s.totalHours, 0) * 10) / 10,
|
||
totalShifts: siteReports.reduce((sum: number, s: any) => sum + s.totalShifts, 0),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error("Error fetching billable site hours:", error);
|
||
res.status(500).json({ message: "Failed to fetch billable site hours", 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" });
|
||
}
|
||
});
|
||
|
||
// 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 =============
|
||
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" });
|
||
}
|
||
});
|
||
|
||
// ============= DEV UTILITIES (Reset & Seed Data) =============
|
||
|
||
// DELETE all sites and guards (for testing)
|
||
app.delete("/api/dev/reset-data", isAuthenticated, async (req, res) => {
|
||
try {
|
||
// Delete all shift assignments first (foreign key constraints)
|
||
await db.delete(shiftAssignments);
|
||
|
||
// Delete all shifts
|
||
await db.delete(shifts);
|
||
|
||
// Delete all sites
|
||
await db.delete(sites);
|
||
|
||
// Delete all certifications
|
||
await db.delete(certifications);
|
||
|
||
// Delete all guards
|
||
await db.delete(guards);
|
||
|
||
// Delete all vehicles
|
||
await db.delete(vehicles);
|
||
|
||
res.json({
|
||
success: true,
|
||
message: "Tutti i dati (siti, guardie, turni, veicoli) sono stati eliminati"
|
||
});
|
||
} catch (error) {
|
||
console.error("Error resetting data:", error);
|
||
res.status(500).json({ message: "Errore durante reset dati" });
|
||
}
|
||
});
|
||
|
||
// Create sample data (3 sites Milano + 3 Roccapiemonte, 10 guards each)
|
||
app.post("/api/dev/seed-data", isAuthenticated, async (req, res) => {
|
||
try {
|
||
// Create service types first
|
||
const [serviceTypePresidioFisso] = await db.insert(serviceTypes).values({
|
||
name: "Presidio Fisso",
|
||
description: "Servizio di presidio fisso con guardia armata",
|
||
shiftType: "fixed_post",
|
||
fixedPostHours: 24,
|
||
}).returning();
|
||
|
||
const [serviceTypePattuglia] = await db.insert(serviceTypes).values({
|
||
name: "Pattuglia Mobile",
|
||
description: "Servizio di pattuglia mobile con passaggi programmati",
|
||
shiftType: "patrol",
|
||
patrolPassages: 4,
|
||
}).returning();
|
||
|
||
// Create 3 sites for Milano
|
||
const [siteMilano1] = await db.insert(sites).values({
|
||
name: "Banca Centrale Milano",
|
||
address: "Via Dante 45, Milano",
|
||
city: "Milano",
|
||
location: "milano",
|
||
serviceTypeId: serviceTypePresidioFisso.id,
|
||
shiftType: "fixed_post",
|
||
requiresArmed: true,
|
||
requiresDriverLicense: false,
|
||
minGuardsRequired: 1,
|
||
serviceStartTime: "00:00",
|
||
serviceEndTime: "24:00",
|
||
contractReference: "CTR-MI-001-2025",
|
||
contractStartDate: "2025-01-01",
|
||
contractEndDate: "2025-12-31",
|
||
}).returning();
|
||
|
||
const [siteMilano2] = await db.insert(sites).values({
|
||
name: "Museo Arte Moderna Milano",
|
||
address: "Corso Magenta 12, Milano",
|
||
city: "Milano",
|
||
location: "milano",
|
||
serviceTypeId: serviceTypePattuglia.id,
|
||
shiftType: "patrol",
|
||
requiresArmed: false,
|
||
requiresDriverLicense: true,
|
||
minGuardsRequired: 1,
|
||
serviceStartTime: "08:00",
|
||
serviceEndTime: "20:00",
|
||
contractReference: "CTR-MI-002-2025",
|
||
contractStartDate: "2025-01-01",
|
||
contractEndDate: "2025-06-30",
|
||
}).returning();
|
||
|
||
const [siteMilano3] = await db.insert(sites).values({
|
||
name: "Centro Commerciale Porta Nuova",
|
||
address: "Piazza Gae Aulenti 1, Milano",
|
||
city: "Milano",
|
||
location: "milano",
|
||
serviceTypeId: serviceTypePresidioFisso.id,
|
||
shiftType: "fixed_post",
|
||
requiresArmed: true,
|
||
requiresDriverLicense: false,
|
||
minGuardsRequired: 2,
|
||
serviceStartTime: "06:00",
|
||
serviceEndTime: "22:00",
|
||
contractReference: "CTR-MI-003-2025",
|
||
contractStartDate: "2025-01-01",
|
||
contractEndDate: "2025-12-31",
|
||
}).returning();
|
||
|
||
// Create 3 sites for Roccapiemonte
|
||
const [siteRocca1] = await db.insert(sites).values({
|
||
name: "Deposito Logistica Roccapiemonte",
|
||
address: "Via Industriale 23, Roccapiemonte",
|
||
city: "Roccapiemonte",
|
||
location: "roccapiemonte",
|
||
serviceTypeId: serviceTypePresidioFisso.id,
|
||
shiftType: "fixed_post",
|
||
requiresArmed: true,
|
||
requiresDriverLicense: false,
|
||
minGuardsRequired: 1,
|
||
serviceStartTime: "00:00",
|
||
serviceEndTime: "24:00",
|
||
contractReference: "CTR-RC-001-2025",
|
||
contractStartDate: "2025-01-01",
|
||
contractEndDate: "2025-12-31",
|
||
}).returning();
|
||
|
||
const [siteRocca2] = await db.insert(sites).values({
|
||
name: "Cantiere Edile Salerno Nord",
|
||
address: "SS 18 km 45, Roccapiemonte",
|
||
city: "Roccapiemonte",
|
||
location: "roccapiemonte",
|
||
serviceTypeId: serviceTypePattuglia.id,
|
||
shiftType: "patrol",
|
||
requiresArmed: false,
|
||
requiresDriverLicense: true,
|
||
minGuardsRequired: 1,
|
||
serviceStartTime: "18:00",
|
||
serviceEndTime: "06:00",
|
||
contractReference: "CTR-RC-002-2025",
|
||
contractStartDate: "2025-01-15",
|
||
contractEndDate: "2025-07-15",
|
||
}).returning();
|
||
|
||
const [siteRocca3] = await db.insert(sites).values({
|
||
name: "Stabilimento Farmaceutico",
|
||
address: "Via delle Industrie 89, Roccapiemonte",
|
||
city: "Roccapiemonte",
|
||
location: "roccapiemonte",
|
||
serviceTypeId: serviceTypePresidioFisso.id,
|
||
shiftType: "fixed_post",
|
||
requiresArmed: true,
|
||
requiresDriverLicense: false,
|
||
minGuardsRequired: 2,
|
||
serviceStartTime: "00:00",
|
||
serviceEndTime: "24:00",
|
||
contractReference: "CTR-RC-003-2025",
|
||
contractStartDate: "2025-01-01",
|
||
contractEndDate: "2025-12-31",
|
||
}).returning();
|
||
|
||
// Create 10 guards for Milano
|
||
const milanNames = [
|
||
{ firstName: "Marco", lastName: "Rossi", badgeNumber: "MI-001" },
|
||
{ firstName: "Giulia", lastName: "Bianchi", badgeNumber: "MI-002" },
|
||
{ firstName: "Luca", lastName: "Ferrari", badgeNumber: "MI-003" },
|
||
{ firstName: "Sara", lastName: "Romano", badgeNumber: "MI-004" },
|
||
{ firstName: "Andrea", lastName: "Colombo", badgeNumber: "MI-005" },
|
||
{ firstName: "Elena", lastName: "Ricci", badgeNumber: "MI-006" },
|
||
{ firstName: "Francesco", lastName: "Marino", badgeNumber: "MI-007" },
|
||
{ firstName: "Chiara", lastName: "Greco", badgeNumber: "MI-008" },
|
||
{ firstName: "Matteo", lastName: "Bruno", badgeNumber: "MI-009" },
|
||
{ firstName: "Alessia", lastName: "Gallo", badgeNumber: "MI-010" },
|
||
];
|
||
|
||
for (let i = 0; i < milanNames.length; i++) {
|
||
await db.insert(guards).values({
|
||
...milanNames[i],
|
||
location: "milano",
|
||
isArmed: i % 2 === 0, // Alternare armati/non armati
|
||
hasDriverLicense: i % 3 === 0, // 1 su 3 con patente
|
||
hasFireSafety: true,
|
||
hasFirstAid: i % 2 === 1,
|
||
phone: `+39 333 ${String(i).padStart(3, '0')}${String(i).padStart(4, '0')}`,
|
||
email: `${milanNames[i].firstName.toLowerCase()}.${milanNames[i].lastName.toLowerCase()}@vigilanza.it`,
|
||
});
|
||
}
|
||
|
||
// Create 10 guards for Roccapiemonte
|
||
const roccaNames = [
|
||
{ firstName: "Antonio", lastName: "Esposito", badgeNumber: "RC-001" },
|
||
{ firstName: "Maria", lastName: "De Luca", badgeNumber: "RC-002" },
|
||
{ firstName: "Giuseppe", lastName: "Russo", badgeNumber: "RC-003" },
|
||
{ firstName: "Anna", lastName: "Costa", badgeNumber: "RC-004" },
|
||
{ firstName: "Vincenzo", lastName: "Ferrara", badgeNumber: "RC-005" },
|
||
{ firstName: "Rosa", lastName: "Gatti", badgeNumber: "RC-006" },
|
||
{ firstName: "Salvatore", lastName: "Leone", badgeNumber: "RC-007" },
|
||
{ firstName: "Lucia", lastName: "Longo", badgeNumber: "RC-008" },
|
||
{ firstName: "Michele", lastName: "Martino", badgeNumber: "RC-009" },
|
||
{ firstName: "Carmela", lastName: "Moretti", badgeNumber: "RC-010" },
|
||
];
|
||
|
||
for (let i = 0; i < roccaNames.length; i++) {
|
||
await db.insert(guards).values({
|
||
...roccaNames[i],
|
||
location: "roccapiemonte",
|
||
isArmed: i % 2 === 0,
|
||
hasDriverLicense: i % 3 === 0,
|
||
hasFireSafety: true,
|
||
hasFirstAid: i % 2 === 1,
|
||
phone: `+39 333 ${String(i + 10).padStart(3, '0')}${String(i + 10).padStart(4, '0')}`,
|
||
email: `${roccaNames[i].firstName.toLowerCase()}.${roccaNames[i].lastName.toLowerCase()}@vigilanza.it`,
|
||
});
|
||
}
|
||
|
||
// Create 5 vehicles for Milano
|
||
const vehiclesMilano = [
|
||
{ licensePlate: "MI123AB", brand: "Fiat", model: "Ducato", vehicleType: "van" as const },
|
||
{ licensePlate: "MI456CD", brand: "Volkswagen", model: "Transporter", vehicleType: "van" as const },
|
||
{ licensePlate: "MI789EF", brand: "Ford", model: "Transit", vehicleType: "van" as const },
|
||
{ licensePlate: "MI012GH", brand: "Renault", model: "Kangoo", vehicleType: "car" as const },
|
||
{ licensePlate: "MI345IJ", brand: "Opel", model: "Vivaro", vehicleType: "van" as const },
|
||
];
|
||
|
||
for (const vehicle of vehiclesMilano) {
|
||
await db.insert(vehicles).values({
|
||
...vehicle,
|
||
location: "milano",
|
||
year: 2022,
|
||
status: "available",
|
||
});
|
||
}
|
||
|
||
// Create 5 vehicles for Roccapiemonte
|
||
const vehiclesRocca = [
|
||
{ licensePlate: "SA123AB", brand: "Fiat", model: "Ducato", vehicleType: "van" as const },
|
||
{ licensePlate: "SA456CD", brand: "Volkswagen", model: "Caddy", vehicleType: "car" as const },
|
||
{ licensePlate: "SA789EF", brand: "Ford", model: "Transit", vehicleType: "van" as const },
|
||
{ licensePlate: "SA012GH", brand: "Renault", model: "Master", vehicleType: "van" as const },
|
||
{ licensePlate: "SA345IJ", brand: "Peugeot", model: "Partner", vehicleType: "car" as const },
|
||
];
|
||
|
||
for (const vehicle of vehiclesRocca) {
|
||
await db.insert(vehicles).values({
|
||
...vehicle,
|
||
location: "roccapiemonte",
|
||
year: 2023,
|
||
status: "available",
|
||
});
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
message: "Dati di esempio creati con successo",
|
||
summary: {
|
||
sites: {
|
||
milano: 3,
|
||
roccapiemonte: 3,
|
||
},
|
||
guards: {
|
||
milano: 10,
|
||
roccapiemonte: 10,
|
||
},
|
||
vehicles: {
|
||
milano: 5,
|
||
roccapiemonte: 5,
|
||
},
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error("Error seeding data:", error);
|
||
res.status(500).json({ message: "Errore durante creazione dati di esempio" });
|
||
}
|
||
});
|
||
|
||
const httpServer = createServer(app);
|
||
return httpServer;
|
||
}
|