Modify the query in `server/routes.ts` to correctly filter shift assignments by comparing the date part of `shifts.startTime` with `routeData.shiftDate`. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/kDVJJUd
3865 lines
144 KiB
TypeScript
3865 lines
144 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, insertCustomerSchema, customers, patrolRoutes, patrolRouteStops, insertPatrolRouteSchema } 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";
|
||
import { z } from "zod";
|
||
import { fromZodError } from "zod-validation-error";
|
||
|
||
// 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
|
||
|
||
// TIMEZONE FIX: Valida formato senza parseISO per evitare shift timezone
|
||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||
if (!dateRegex.test(normalizedDateStr)) {
|
||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||
}
|
||
|
||
const dateStr = normalizedDateStr;
|
||
|
||
// 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
|
||
|
||
// TIMEZONE FIX: Valida formato senza parseISO per evitare shift timezone
|
||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||
if (!dateRegex.test(normalizedDateStr)) {
|
||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||
}
|
||
|
||
const dateStr = normalizedDateStr;
|
||
|
||
// 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];
|
||
|
||
// ✅ CORRETTO: Valida date con regex, NON parseISO
|
||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||
if (!dateRegex.test(normalizedWeekStart)) {
|
||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||
}
|
||
|
||
// ✅ CORRETTO: Costruisci Date da componenti per evitare timezone shift
|
||
const [year, month, day] = normalizedWeekStart.split("-").map(Number);
|
||
const parsedWeekStart = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||
|
||
const weekStartDate = normalizedWeekStart;
|
||
|
||
// Ottieni location dalla query (default: roccapiemonte)
|
||
const location = req.query.location as string || "roccapiemonte";
|
||
|
||
// Calcola fine settimana (weekStart + 6 giorni) usando componenti
|
||
const tempWeekEnd = new Date(year, month - 1, day + 6, 23, 59, 59, 999);
|
||
const weekEndYear = tempWeekEnd.getFullYear();
|
||
const weekEndMonth = tempWeekEnd.getMonth() + 1;
|
||
const weekEndDay = tempWeekEnd.getDate();
|
||
const weekEndDate = `${weekEndYear}-${String(weekEndMonth).padStart(2, '0')}-${String(weekEndDay).padStart(2, '0')}`;
|
||
|
||
// ✅ CORRETTO: Timestamp da componenti per query database
|
||
const weekStartTimestampForContract = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||
const weekEndTimestampForContract = new Date(weekEndYear, weekEndMonth - 1, weekEndDay, 23, 59, 59, 999);
|
||
|
||
// 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
|
||
// ✅ CORRETTO: Usa timestamp già creati correttamente sopra
|
||
const weekStartTimestamp = weekStartTimestampForContract;
|
||
const weekEndTimestamp = weekEndTimestampForContract;
|
||
|
||
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++) {
|
||
// ✅ CORRETTO: Calcola date usando componenti per evitare timezone shift
|
||
const currentDayTimestamp = new Date(year, month - 1, day + dayOffset, 0, 0, 0, 0);
|
||
const currentYear = currentDayTimestamp.getFullYear();
|
||
const currentMonth = currentDayTimestamp.getMonth() + 1;
|
||
const currentDay_num = currentDayTimestamp.getDate();
|
||
const dayStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(currentDay_num).padStart(2, '0')}`;
|
||
|
||
const dayStartTimestamp = new Date(currentYear, currentMonth - 1, currentDay_num, 0, 0, 0, 0);
|
||
const dayEndTimestamp = new Date(currentYear, currentMonth - 1, currentDay_num, 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;
|
||
|
||
// Calcola ore servizio del sito (per calcolo corretto anche senza turni)
|
||
const serviceStart = site.serviceStartTime || "00:00";
|
||
const serviceEnd = site.serviceEndTime || "23:59";
|
||
const [startH, startM] = serviceStart.split(":").map(Number);
|
||
const [endH, endM] = serviceEnd.split(":").map(Number);
|
||
let serviceHours = (endH + endM/60) - (startH + startM/60);
|
||
if (serviceHours <= 0) serviceHours += 24; // Servizio notturno (es. 22:00-06:00)
|
||
|
||
// Somma ore totali dei turni del giorno (se esistono)
|
||
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);
|
||
|
||
// Usa ore servizio o ore turni (se già creati)
|
||
const effectiveHours = totalShiftHours > 0 ? totalShiftHours : serviceHours;
|
||
|
||
// 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 basato su ore servizio
|
||
// Slot necessari per coprire le ore (ogni guardia max 9h)
|
||
const slotsNeeded = Math.ceil(effectiveHours / maxOreGuardia);
|
||
// Guardie totali necessarie (slot × min guardie contemporanee)
|
||
const totalGuardsNeeded = slotsNeeded * minGuardie;
|
||
const missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned);
|
||
|
||
return {
|
||
siteId: site.id,
|
||
siteName: site.name,
|
||
serviceType: serviceType?.label || "N/A",
|
||
serviceStartTime: serviceStart,
|
||
serviceEndTime: serviceEnd,
|
||
serviceHours: Math.round(serviceHours * 10) / 10, // Arrotonda a 1 decimale
|
||
minGuards: site.minGuards,
|
||
guards: guardsWithHours,
|
||
vehicles: dayVehicles,
|
||
totalShiftHours,
|
||
guardsAssigned: uniqueGuardsAssigned,
|
||
missingGuards,
|
||
shiftsCount: dayShifts.length,
|
||
};
|
||
});
|
||
|
||
weekData.push({
|
||
date: dayStr,
|
||
dayOfWeek: format(currentDayTimestamp, "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
|
||
// TIMEZONE FIX: Parse date as YYYY-MM-DD components to avoid timezone shifts
|
||
const [year, month, day] = startDate.split("-").map(Number);
|
||
|
||
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
|
||
// Create date using local timezone components (no UTC conversion)
|
||
const shiftDate = new Date(year, month - 1, day + 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++) {
|
||
// TIMEZONE FIX: Build date from components to maintain correct day
|
||
const shiftDate = new Date(year, month - 1, day + 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);
|
||
|
||
// Build timestamps using date components (no timezone conversion)
|
||
const shiftStart = new Date(year, month - 1, day + dayOffset, startHour, startMin, 0, 0);
|
||
const shiftEnd = new Date(year, month - 1, day + dayOffset, 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, force = false } = 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 LOCAL timezone to match user's selection
|
||
const shiftDate = new Date(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 LOCAL timezone
|
||
const plannedStart = new Date(actualYear, actualMonth, actualDay, hours, minutes, 0, 0);
|
||
const plannedEnd = new Date(actualYear, actualMonth, actualDay, hours + durationHours, minutes, 0, 0);
|
||
|
||
// Find or create shift for this site/date (full day boundaries in LOCAL timezone)
|
||
const dayStart = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0);
|
||
const dayEnd = new Date(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(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0);
|
||
let shiftEnd = new Date(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(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) - skip if force=true
|
||
if (!force) {
|
||
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) {
|
||
const excessHours = (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 di ${excessHours}h 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') ||
|
||
errorMessage.includes('limite giornaliero') ||
|
||
errorMessage.includes('limite settimanale') ||
|
||
errorMessage.includes('ccnl')) {
|
||
return res.status(409).json({
|
||
message: error.message,
|
||
type: errorMessage.includes('limite') ? 'CCNL_VIOLATION' : 'CONFLICT'
|
||
});
|
||
}
|
||
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) });
|
||
}
|
||
});
|
||
|
||
// Report fatturazione per cliente
|
||
app.get("/api/reports/customer-billing", 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 cliente e servizio
|
||
const monthShifts = await db
|
||
.select({
|
||
shift: shifts,
|
||
site: sites,
|
||
customer: customers,
|
||
serviceType: serviceTypes,
|
||
})
|
||
.from(shifts)
|
||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||
.leftJoin(customers, eq(sites.customerId, customers.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 cliente
|
||
const customerBillingMap: Record<string, any> = {};
|
||
|
||
monthShifts.forEach((shiftData: any) => {
|
||
const customerId = shiftData.customer?.id || "no-customer";
|
||
const customerName = shiftData.customer?.name || "Nessun Cliente";
|
||
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;
|
||
|
||
// Inizializza customer se non esiste
|
||
if (!customerBillingMap[customerId]) {
|
||
customerBillingMap[customerId] = {
|
||
customerId,
|
||
customerName,
|
||
sites: {},
|
||
totalHours: 0,
|
||
totalShifts: 0,
|
||
totalPatrolPassages: 0,
|
||
totalInspections: 0,
|
||
totalInterventions: 0,
|
||
};
|
||
}
|
||
|
||
// Inizializza sito se non esiste
|
||
if (!customerBillingMap[customerId].sites[siteId]) {
|
||
customerBillingMap[customerId].sites[siteId] = {
|
||
siteId,
|
||
siteName,
|
||
serviceTypes: {},
|
||
totalHours: 0,
|
||
totalShifts: 0,
|
||
};
|
||
}
|
||
|
||
// Inizializza service type se non esiste
|
||
if (!customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName]) {
|
||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName] = {
|
||
name: serviceTypeName,
|
||
hours: 0,
|
||
shifts: 0,
|
||
passages: 0,
|
||
inspections: 0,
|
||
interventions: 0,
|
||
};
|
||
}
|
||
|
||
// Aggiorna conteggi
|
||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].hours += hours;
|
||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].shifts += 1;
|
||
customerBillingMap[customerId].sites[siteId].totalHours += hours;
|
||
customerBillingMap[customerId].sites[siteId].totalShifts += 1;
|
||
customerBillingMap[customerId].totalHours += hours;
|
||
customerBillingMap[customerId].totalShifts += 1;
|
||
|
||
// Conteggio specifico per tipo servizio (basato su parametri)
|
||
const serviceType = shiftData.serviceType;
|
||
if (serviceType) {
|
||
// Pattuglia/Ronda: conta numero passaggi
|
||
if (serviceType.name.toLowerCase().includes("pattuglia") ||
|
||
serviceType.name.toLowerCase().includes("ronda")) {
|
||
const passages = serviceType.patrolPassages || 1;
|
||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].passages += passages;
|
||
customerBillingMap[customerId].totalPatrolPassages += passages;
|
||
}
|
||
// Ispezione: conta numero ispezioni
|
||
else if (serviceType.name.toLowerCase().includes("ispezione")) {
|
||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].inspections += 1;
|
||
customerBillingMap[customerId].totalInspections += 1;
|
||
}
|
||
// Pronto Intervento: conta numero interventi
|
||
else if (serviceType.name.toLowerCase().includes("pronto") ||
|
||
serviceType.name.toLowerCase().includes("intervento")) {
|
||
customerBillingMap[customerId].sites[siteId].serviceTypes[serviceTypeName].interventions += 1;
|
||
customerBillingMap[customerId].totalInterventions += 1;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Converti mappa in array e arrotonda ore
|
||
const customerReports = Object.values(customerBillingMap).map((customer: any) => {
|
||
const sitesArray = Object.values(customer.sites).map((site: any) => {
|
||
const serviceTypesArray = Object.values(site.serviceTypes).map((st: any) => ({
|
||
...st,
|
||
hours: Math.round(st.hours * 10) / 10,
|
||
}));
|
||
|
||
return {
|
||
...site,
|
||
serviceTypes: serviceTypesArray,
|
||
totalHours: Math.round(site.totalHours * 10) / 10,
|
||
};
|
||
});
|
||
|
||
return {
|
||
...customer,
|
||
sites: sitesArray,
|
||
totalHours: Math.round(customer.totalHours * 10) / 10,
|
||
};
|
||
});
|
||
|
||
res.json({
|
||
month: rawMonth,
|
||
location,
|
||
customers: customerReports,
|
||
summary: {
|
||
totalCustomers: customerReports.length,
|
||
totalHours: Math.round(customerReports.reduce((sum: number, c: any) => sum + c.totalHours, 0) * 10) / 10,
|
||
totalShifts: customerReports.reduce((sum: number, c: any) => sum + c.totalShifts, 0),
|
||
totalPatrolPassages: customerReports.reduce((sum: number, c: any) => sum + c.totalPatrolPassages, 0),
|
||
totalInspections: customerReports.reduce((sum: number, c: any) => sum + c.totalInspections, 0),
|
||
totalInterventions: customerReports.reduce((sum: number, c: any) => sum + c.totalInterventions, 0),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error("Error fetching customer billing report:", error);
|
||
res.status(500).json({ message: "Failed to fetch customer billing report", 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" });
|
||
}
|
||
});
|
||
|
||
// ============= CUSTOMER ROUTES =============
|
||
app.get("/api/customers", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const customers = await storage.getAllCustomers();
|
||
res.json(customers);
|
||
} catch (error) {
|
||
console.error("Error fetching customers:", error);
|
||
res.status(500).json({ message: "Failed to fetch customers" });
|
||
}
|
||
});
|
||
|
||
app.post("/api/customers", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const validatedData = insertCustomerSchema.parse(req.body);
|
||
const customer = await storage.createCustomer(validatedData);
|
||
res.json(customer);
|
||
} catch (error) {
|
||
if (error instanceof z.ZodError) {
|
||
return res.status(400).json({
|
||
message: "Validation failed",
|
||
errors: fromZodError(error).message
|
||
});
|
||
}
|
||
console.error("Error creating customer:", error);
|
||
res.status(500).json({ message: "Failed to create customer" });
|
||
}
|
||
});
|
||
|
||
app.patch("/api/customers/:id", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const validatedData = insertCustomerSchema.partial().parse(req.body);
|
||
const customer = await storage.updateCustomer(req.params.id, validatedData);
|
||
if (!customer) {
|
||
return res.status(404).json({ message: "Customer not found" });
|
||
}
|
||
res.json(customer);
|
||
} catch (error) {
|
||
if (error instanceof z.ZodError) {
|
||
return res.status(400).json({
|
||
message: "Validation failed",
|
||
errors: fromZodError(error).message
|
||
});
|
||
}
|
||
console.error("Error updating customer:", error);
|
||
res.status(500).json({ message: "Failed to update customer" });
|
||
}
|
||
});
|
||
|
||
app.delete("/api/customers/:id", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const customer = await storage.deleteCustomer(req.params.id);
|
||
if (!customer) {
|
||
return res.status(404).json({ message: "Customer not found" });
|
||
}
|
||
res.json(customer);
|
||
} catch (error) {
|
||
console.error("Error deleting customer:", error);
|
||
res.status(500).json({ message: "Failed to delete customer" });
|
||
}
|
||
});
|
||
|
||
// ============= 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 { shiftId, guardId } = req.body;
|
||
|
||
// Recupera il shift per ottenere la data
|
||
const [shift] = await db.select().from(shifts).where(eq(shifts.id, shiftId)).limit(1);
|
||
if (!shift) {
|
||
return res.status(404).json({ message: "Turno non trovato" });
|
||
}
|
||
|
||
// VINCOLO ESCLUSIVITÀ: Verifica che la guardia NON abbia patrol routes (turni mobile) nella stessa data
|
||
const existingMobileShifts = await db
|
||
.select()
|
||
.from(patrolRoutes)
|
||
.where(
|
||
and(
|
||
eq(patrolRoutes.guardId, guardId),
|
||
eq(patrolRoutes.shiftDate, shift.shiftDate)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
if (existingMobileShifts.length > 0) {
|
||
return res.status(400).json({
|
||
message: `Vincolo esclusività: la guardia è già assegnata a un turno pattuglia mobile in questa data. Una guardia non può essere assegnata contemporaneamente a turni fissi e mobile.`
|
||
});
|
||
}
|
||
|
||
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" });
|
||
}
|
||
|
||
// Recupera il shift per ottenere la data
|
||
const [shift] = await db.select().from(shifts).where(eq(shifts.id, shiftId)).limit(1);
|
||
if (!shift) {
|
||
return res.status(404).json({ message: "Turno non trovato" });
|
||
}
|
||
|
||
// VINCOLO ESCLUSIVITÀ: Verifica che la guardia NON abbia patrol routes (turni mobile) nella stessa data
|
||
const existingMobileShifts = await db
|
||
.select()
|
||
.from(patrolRoutes)
|
||
.where(
|
||
and(
|
||
eq(patrolRoutes.guardId, guardId),
|
||
eq(patrolRoutes.shiftDate, shift.shiftDate)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
if (existingMobileShifts.length > 0) {
|
||
return res.status(400).json({
|
||
message: `Vincolo esclusività: la guardia è già assegnata a un turno pattuglia mobile in questa data. Una guardia non può essere assegnata contemporaneamente a turni fissi e mobile.`
|
||
});
|
||
}
|
||
|
||
// 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" });
|
||
}
|
||
});
|
||
|
||
// ============= MY SHIFTS (GUARD VIEW) ROUTES =============
|
||
// GET - Turni fissi della guardia loggata
|
||
app.get("/api/my-shifts/fixed", isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
const userId = getUserId(req);
|
||
const currentUser = await storage.getUser(userId);
|
||
|
||
if (!currentUser) {
|
||
return res.status(401).json({ message: "User not authenticated" });
|
||
}
|
||
|
||
// Trova la guardia associata all'utente
|
||
const [guard] = await db
|
||
.select()
|
||
.from(guards)
|
||
.where(eq(guards.userId, userId))
|
||
.limit(1);
|
||
|
||
if (!guard) {
|
||
return res.status(404).json({ message: "Guardia non trovata per questo utente" });
|
||
}
|
||
|
||
// Estrai filtri data (opzionali)
|
||
const { startDate, endDate } = req.query;
|
||
|
||
// Query per recuperare i turni fissi assegnati alla guardia
|
||
let query = db
|
||
.select({
|
||
id: shiftAssignments.id,
|
||
shiftId: shiftAssignments.shiftId,
|
||
plannedStartTime: shiftAssignments.plannedStartTime,
|
||
plannedEndTime: shiftAssignments.plannedEndTime,
|
||
armed: shiftAssignments.armed,
|
||
vehicleId: shiftAssignments.vehicleId,
|
||
vehiclePlate: vehicles.licensePlate,
|
||
site: {
|
||
id: sites.id,
|
||
name: sites.name,
|
||
address: sites.address,
|
||
location: sites.location,
|
||
},
|
||
shift: {
|
||
shiftDate: shifts.shiftDate,
|
||
startTime: shifts.startTime,
|
||
endTime: shifts.endTime,
|
||
},
|
||
})
|
||
.from(shiftAssignments)
|
||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||
.leftJoin(vehicles, eq(shiftAssignments.vehicleId, vehicles.id))
|
||
.where(eq(shiftAssignments.guardId, guard.id));
|
||
|
||
// Applica filtri data se presenti
|
||
if (startDate && endDate) {
|
||
const start = new Date(startDate as string);
|
||
const end = new Date(endDate as string);
|
||
|
||
if (isValid(start) && isValid(end)) {
|
||
query = query.where(
|
||
and(
|
||
eq(shiftAssignments.guardId, guard.id),
|
||
gte(shifts.shiftDate, format(start, "yyyy-MM-dd")),
|
||
lte(shifts.shiftDate, format(end, "yyyy-MM-dd"))
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
const myShifts = await query.orderBy(asc(shifts.shiftDate), asc(shiftAssignments.plannedStartTime));
|
||
|
||
res.json(myShifts);
|
||
} catch (error) {
|
||
console.error("Error fetching guard's fixed shifts:", error);
|
||
res.status(500).json({ message: "Errore caricamento turni fissi" });
|
||
}
|
||
});
|
||
|
||
// GET - Turni pattuglia mobile della guardia loggata
|
||
app.get("/api/my-shifts/mobile", isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
const userId = getUserId(req);
|
||
const currentUser = await storage.getUser(userId);
|
||
|
||
if (!currentUser) {
|
||
return res.status(401).json({ message: "User not authenticated" });
|
||
}
|
||
|
||
// Trova la guardia associata all'utente
|
||
const [guard] = await db
|
||
.select()
|
||
.from(guards)
|
||
.where(eq(guards.userId, userId))
|
||
.limit(1);
|
||
|
||
if (!guard) {
|
||
return res.status(404).json({ message: "Guardia non trovata per questo utente" });
|
||
}
|
||
|
||
// Estrai filtri data (opzionali)
|
||
const { startDate, endDate } = req.query;
|
||
|
||
// Query per recuperare i patrol routes assegnati alla guardia
|
||
let query = db
|
||
.select({
|
||
id: patrolRoutes.id,
|
||
shiftDate: patrolRoutes.shiftDate,
|
||
startTime: patrolRoutes.startTime,
|
||
endTime: patrolRoutes.endTime,
|
||
location: patrolRoutes.location,
|
||
status: patrolRoutes.status,
|
||
vehicleId: patrolRoutes.vehicleId,
|
||
vehiclePlate: vehicles.licensePlate,
|
||
})
|
||
.from(patrolRoutes)
|
||
.leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id))
|
||
.where(eq(patrolRoutes.guardId, guard.id));
|
||
|
||
// Applica filtri data se presenti
|
||
if (startDate && endDate) {
|
||
const start = new Date(startDate as string);
|
||
const end = new Date(endDate as string);
|
||
|
||
if (isValid(start) && isValid(end)) {
|
||
query = query.where(
|
||
and(
|
||
eq(patrolRoutes.guardId, guard.id),
|
||
gte(patrolRoutes.shiftDate, format(start, "yyyy-MM-dd")),
|
||
lte(patrolRoutes.shiftDate, format(end, "yyyy-MM-dd"))
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
const routes = await query.orderBy(asc(patrolRoutes.shiftDate), asc(patrolRoutes.startTime));
|
||
|
||
// Per ogni route, recupera gli stops
|
||
const routesWithStops = await Promise.all(
|
||
routes.map(async (route) => {
|
||
const stops = await db
|
||
.select({
|
||
siteId: patrolRouteStops.siteId,
|
||
siteName: sites.name,
|
||
siteAddress: sites.address,
|
||
sequenceOrder: patrolRouteStops.sequenceOrder,
|
||
latitude: sites.latitude,
|
||
longitude: sites.longitude,
|
||
})
|
||
.from(patrolRouteStops)
|
||
.leftJoin(sites, eq(patrolRouteStops.siteId, sites.id))
|
||
.where(eq(patrolRouteStops.patrolRouteId, route.id))
|
||
.orderBy(asc(patrolRouteStops.sequenceOrder));
|
||
|
||
return {
|
||
...route,
|
||
stops,
|
||
};
|
||
})
|
||
);
|
||
|
||
res.json(routesWithStops);
|
||
} catch (error) {
|
||
console.error("Error fetching guard's patrol routes:", error);
|
||
res.status(500).json({ message: "Errore caricamento turni pattuglia" });
|
||
}
|
||
});
|
||
|
||
// GET - Planning per un sito specifico (tutti gli agenti assegnati)
|
||
app.get("/api/site-planning/:siteId", isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
const { siteId } = req.params;
|
||
const { startDate, endDate } = req.query;
|
||
|
||
if (!startDate || !endDate) {
|
||
return res.status(400).json({
|
||
message: "Missing required parameters: startDate, endDate"
|
||
});
|
||
}
|
||
|
||
const start = new Date(startDate as string);
|
||
const end = new Date(endDate as string);
|
||
|
||
if (!isValid(start) || !isValid(end)) {
|
||
return res.status(400).json({ message: "Invalid date format" });
|
||
}
|
||
|
||
// Query per recuperare tutti i turni del sito nel range di date
|
||
const assignments = await db
|
||
.select({
|
||
guardId: guards.id,
|
||
guardName: sql<string>`${guards.firstName} || ' ' || ${guards.lastName}`,
|
||
badgeNumber: guards.badgeNumber,
|
||
shiftDate: shifts.shiftDate,
|
||
plannedStartTime: shiftAssignments.plannedStartTime,
|
||
plannedEndTime: shiftAssignments.plannedEndTime,
|
||
armed: shiftAssignments.armed,
|
||
vehicleId: shiftAssignments.vehicleId,
|
||
vehiclePlate: vehicles.licensePlate,
|
||
})
|
||
.from(shiftAssignments)
|
||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||
.innerJoin(guards, eq(shiftAssignments.guardId, guards.id))
|
||
.leftJoin(vehicles, eq(shiftAssignments.vehicleId, vehicles.id))
|
||
.where(
|
||
and(
|
||
eq(shifts.siteId, siteId),
|
||
gte(shifts.shiftDate, format(start, "yyyy-MM-dd")),
|
||
lte(shifts.shiftDate, format(end, "yyyy-MM-dd"))
|
||
)
|
||
)
|
||
.orderBy(asc(shifts.shiftDate), asc(shiftAssignments.plannedStartTime));
|
||
|
||
// Raggruppa per data
|
||
const byDay = assignments.reduce((acc, assignment) => {
|
||
const date = assignment.shiftDate;
|
||
if (!acc[date]) {
|
||
acc[date] = [];
|
||
}
|
||
acc[date].push({
|
||
guardId: assignment.guardId,
|
||
guardName: assignment.guardName,
|
||
badgeNumber: assignment.badgeNumber,
|
||
plannedStartTime: assignment.plannedStartTime,
|
||
plannedEndTime: assignment.plannedEndTime,
|
||
armed: assignment.armed,
|
||
vehicleId: assignment.vehicleId,
|
||
vehiclePlate: assignment.vehiclePlate,
|
||
});
|
||
return acc;
|
||
}, {} as Record<string, any[]>);
|
||
|
||
// Converti in array
|
||
const result = Object.entries(byDay).map(([date, guards]) => ({
|
||
date,
|
||
guards,
|
||
}));
|
||
|
||
res.json(result);
|
||
} catch (error) {
|
||
console.error("Error fetching site planning:", error);
|
||
res.status(500).json({ message: "Errore caricamento planning sito" });
|
||
}
|
||
});
|
||
|
||
// ============= 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" });
|
||
}
|
||
});
|
||
|
||
// ============= PLANNING MOBILE ROUTES =============
|
||
// GET /api/planning-mobile/sites?location=X - Siti con servizi mobili (ronde/ispezioni/interventi)
|
||
app.get("/api/planning-mobile/sites", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const { location } = req.query;
|
||
|
||
if (!location || !["roccapiemonte", "milano", "roma"].includes(location as string)) {
|
||
return res.status(400).json({ message: "Location parameter required (roccapiemonte|milano|roma)" });
|
||
}
|
||
|
||
// Query siti con serviceType.classification = 'mobile' e location matching
|
||
const mobileSites = await db
|
||
.select({
|
||
id: sites.id,
|
||
name: sites.name,
|
||
address: sites.address,
|
||
serviceTypeId: sites.serviceTypeId,
|
||
serviceTypeName: serviceTypes.label,
|
||
location: sites.location,
|
||
latitude: sites.latitude,
|
||
longitude: sites.longitude,
|
||
})
|
||
.from(sites)
|
||
.leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id))
|
||
.where(
|
||
and(
|
||
eq(sites.location, location as "roccapiemonte" | "milano" | "roma"),
|
||
eq(serviceTypes.classification, "mobile"),
|
||
eq(sites.isActive, true)
|
||
)
|
||
)
|
||
.orderBy(sites.name);
|
||
|
||
res.json(mobileSites);
|
||
} catch (error) {
|
||
console.error("Error fetching mobile sites:", error);
|
||
res.status(500).json({ message: "Errore caricamento siti mobili" });
|
||
}
|
||
});
|
||
|
||
// GET /api/planning-mobile/guards?location=X&date=YYYY-MM-DD - Guardie disponibili per location e data
|
||
app.get("/api/planning-mobile/guards", isAuthenticated, async (req, res) => {
|
||
try {
|
||
const { location, date } = req.query;
|
||
|
||
if (!location || !["roccapiemonte", "milano", "roma"].includes(location as string)) {
|
||
return res.status(400).json({ message: "Location parameter required" });
|
||
}
|
||
|
||
if (!date || typeof date !== "string") {
|
||
return res.status(400).json({ message: "Date parameter required (YYYY-MM-DD)" });
|
||
}
|
||
|
||
// Valida formato data
|
||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||
if (!dateRegex.test(date)) {
|
||
return res.status(400).json({ message: "Invalid date format (use YYYY-MM-DD)" });
|
||
}
|
||
|
||
// Ottieni tutte le guardie per location CHE HANNO LA PATENTE
|
||
const allGuards = await db
|
||
.select()
|
||
.from(guards)
|
||
.where(
|
||
and(
|
||
eq(guards.location, location as "roccapiemonte" | "milano" | "roma"),
|
||
eq(guards.hasDriverLicense, true)
|
||
)
|
||
)
|
||
.orderBy(guards.lastName);
|
||
|
||
// Calcola settimana corrente per calcolare ore settimanali
|
||
const [year, month, day] = date.split("-").map(Number);
|
||
const targetDate = new Date(year, month - 1, day);
|
||
const weekStart = startOfWeek(targetDate, { weekStartsOn: 1 }); // lunedì
|
||
const weekEnd = endOfWeek(targetDate, { weekStartsOn: 1 });
|
||
|
||
// Per ogni guardia, calcola ore già assegnate nella settimana
|
||
const guardsWithAvailability = await Promise.all(
|
||
allGuards.map(async (guard) => {
|
||
// Query shifts assegnati alla guardia nella settimana
|
||
const weekShifts = await db
|
||
.select({
|
||
shiftId: shifts.id,
|
||
startTime: shifts.startTime,
|
||
endTime: shifts.endTime,
|
||
})
|
||
.from(shiftAssignments)
|
||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||
.where(
|
||
and(
|
||
eq(shiftAssignments.guardId, guard.id),
|
||
gte(shifts.startTime, weekStart),
|
||
lte(shifts.startTime, weekEnd)
|
||
)
|
||
);
|
||
|
||
// Calcola ore totali nella settimana
|
||
const weeklyHours = weekShifts.reduce((total, shift) => {
|
||
const hours = differenceInHours(new Date(shift.endTime), new Date(shift.startTime));
|
||
return total + hours;
|
||
}, 0);
|
||
|
||
const maxWeeklyHours = 45; // CCNL limit
|
||
const availableHours = Math.max(0, maxWeeklyHours - weeklyHours);
|
||
|
||
return {
|
||
id: guard.id,
|
||
firstName: guard.firstName,
|
||
lastName: guard.lastName,
|
||
badgeNumber: guard.badgeNumber,
|
||
location: guard.location,
|
||
weeklyHours,
|
||
availableHours,
|
||
};
|
||
})
|
||
);
|
||
|
||
// Filtra solo guardie con ore disponibili
|
||
const availableGuards = guardsWithAvailability.filter(g => g.availableHours > 0);
|
||
|
||
res.json(availableGuards);
|
||
} catch (error) {
|
||
console.error("Error fetching available guards:", error);
|
||
res.status(500).json({ message: "Errore caricamento guardie disponibili" });
|
||
}
|
||
});
|
||
|
||
// ============= PATROL ROUTES API =============
|
||
|
||
// GET patrol routes per guardia e data
|
||
app.get("/api/patrol-routes", isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
const { guardId, date, location } = req.query;
|
||
|
||
const conditions = [];
|
||
if (guardId && guardId !== "all") {
|
||
conditions.push(eq(patrolRoutes.guardId, guardId));
|
||
}
|
||
if (date) {
|
||
conditions.push(eq(patrolRoutes.shiftDate, date));
|
||
}
|
||
if (location) {
|
||
conditions.push(eq(patrolRoutes.location, location as any));
|
||
}
|
||
|
||
const routes = await db
|
||
.select({
|
||
id: patrolRoutes.id,
|
||
guardId: patrolRoutes.guardId,
|
||
shiftDate: patrolRoutes.shiftDate,
|
||
startTime: patrolRoutes.startTime,
|
||
endTime: patrolRoutes.endTime,
|
||
status: patrolRoutes.status,
|
||
location: patrolRoutes.location,
|
||
vehicleId: patrolRoutes.vehicleId,
|
||
isArmedRoute: patrolRoutes.isArmedRoute,
|
||
notes: patrolRoutes.notes,
|
||
guardFirstName: guards.firstName,
|
||
guardLastName: guards.lastName,
|
||
vehiclePlate: vehicles.licensePlate,
|
||
})
|
||
.from(patrolRoutes)
|
||
.leftJoin(guards, eq(patrolRoutes.guardId, guards.id))
|
||
.leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id))
|
||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||
.orderBy(desc(patrolRoutes.shiftDate));
|
||
|
||
// Carica stops per ogni route
|
||
const routesWithStops = await Promise.all(
|
||
routes.map(async (route) => {
|
||
const stops = await db
|
||
.select({
|
||
id: patrolRouteStops.id,
|
||
siteId: patrolRouteStops.siteId,
|
||
siteName: sites.name,
|
||
siteAddress: sites.address,
|
||
latitude: sites.latitude,
|
||
longitude: sites.longitude,
|
||
sequenceOrder: patrolRouteStops.sequenceOrder,
|
||
estimatedArrivalTime: patrolRouteStops.estimatedArrivalTime,
|
||
isCompleted: patrolRouteStops.isCompleted,
|
||
notes: patrolRouteStops.notes,
|
||
})
|
||
.from(patrolRouteStops)
|
||
.leftJoin(sites, eq(patrolRouteStops.siteId, sites.id))
|
||
.where(eq(patrolRouteStops.patrolRouteId, route.id))
|
||
.orderBy(asc(patrolRouteStops.sequenceOrder));
|
||
|
||
return {
|
||
...route,
|
||
stops,
|
||
};
|
||
})
|
||
);
|
||
|
||
res.json(routesWithStops);
|
||
} catch (error) {
|
||
console.error("Error fetching patrol routes:", error);
|
||
res.status(500).json({ message: "Errore caricamento turni pattuglia" });
|
||
}
|
||
});
|
||
|
||
// POST - Crea nuovo patrol route con stops
|
||
app.post("/api/patrol-routes", isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
const routeData = insertPatrolRouteSchema.parse(req.body);
|
||
const { stops } = req.body; // Array di siti in sequenza
|
||
|
||
// Verifica che non esista già un patrol route per questa guardia/data
|
||
const existing = await db
|
||
.select()
|
||
.from(patrolRoutes)
|
||
.where(
|
||
and(
|
||
eq(patrolRoutes.guardId, routeData.guardId),
|
||
eq(patrolRoutes.shiftDate, routeData.shiftDate)
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
if (existing.length > 0) {
|
||
return res.status(400).json({
|
||
message: "Esiste già un turno pattuglia per questa guardia in questa data"
|
||
});
|
||
}
|
||
|
||
// VINCOLO ESCLUSIVITÀ: Verifica che la guardia NON abbia shift assignments (turni fissi) nella stessa data
|
||
const existingFixedShifts = await db
|
||
.select({
|
||
shiftId: shifts.id,
|
||
siteName: sites.name,
|
||
})
|
||
.from(shiftAssignments)
|
||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||
.where(
|
||
and(
|
||
eq(shiftAssignments.guardId, routeData.guardId),
|
||
sql`DATE(${shifts.startTime}) = ${routeData.shiftDate}`
|
||
)
|
||
)
|
||
.limit(1);
|
||
|
||
if (existingFixedShifts.length > 0) {
|
||
return res.status(400).json({
|
||
message: `Vincolo esclusività: la guardia è già assegnata a un turno fisso (${existingFixedShifts[0].siteName}) in questa data. Una guardia non può essere assegnata contemporaneamente a turni fissi e mobile.`
|
||
});
|
||
}
|
||
|
||
// Crea patrol route
|
||
const [newRoute] = await db.insert(patrolRoutes).values(routeData).returning();
|
||
|
||
// Crea stops se presenti
|
||
if (stops && Array.isArray(stops) && stops.length > 0) {
|
||
const stopsData = stops.map((stop: any, index: number) => ({
|
||
patrolRouteId: newRoute.id,
|
||
siteId: stop.siteId,
|
||
sequenceOrder: index + 1,
|
||
estimatedArrivalTime: stop.estimatedArrivalTime || null,
|
||
}));
|
||
|
||
await db.insert(patrolRouteStops).values(stopsData);
|
||
}
|
||
|
||
res.json(newRoute);
|
||
} catch (error) {
|
||
console.error("Error creating patrol route:", error);
|
||
res.status(500).json({ message: "Errore creazione turno pattuglia" });
|
||
}
|
||
});
|
||
|
||
// PUT - Aggiorna patrol route esistente
|
||
app.put("/api/patrol-routes/:id", isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { stops, ...routeData } = req.body;
|
||
|
||
// Aggiorna patrol route
|
||
const [updated] = await db
|
||
.update(patrolRoutes)
|
||
.set(routeData)
|
||
.where(eq(patrolRoutes.id, id))
|
||
.returning();
|
||
|
||
if (!updated) {
|
||
return res.status(404).json({ message: "Turno pattuglia non trovato" });
|
||
}
|
||
|
||
// Se ci sono stops, elimina quelli vecchi e inserisci i nuovi
|
||
if (stops && Array.isArray(stops)) {
|
||
await db.delete(patrolRouteStops).where(eq(patrolRouteStops.patrolRouteId, id));
|
||
|
||
if (stops.length > 0) {
|
||
const stopsData = stops.map((stop: any, index: number) => ({
|
||
patrolRouteId: id,
|
||
siteId: stop.siteId,
|
||
sequenceOrder: index + 1,
|
||
estimatedArrivalTime: stop.estimatedArrivalTime || null,
|
||
}));
|
||
|
||
await db.insert(patrolRouteStops).values(stopsData);
|
||
}
|
||
}
|
||
|
||
res.json(updated);
|
||
} catch (error) {
|
||
console.error("Error updating patrol route:", error);
|
||
res.status(500).json({ message: "Errore aggiornamento turno pattuglia" });
|
||
}
|
||
});
|
||
|
||
// DELETE - Elimina patrol route
|
||
app.delete("/api/patrol-routes/:id", isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
await db.delete(patrolRoutes).where(eq(patrolRoutes.id, id));
|
||
|
||
res.json({ message: "Turno pattuglia eliminato" });
|
||
} catch (error) {
|
||
console.error("Error deleting patrol route:", error);
|
||
res.status(500).json({ message: "Errore eliminazione turno pattuglia" });
|
||
}
|
||
});
|
||
|
||
// ============= GEOCODING API (Nominatim/OSM) =============
|
||
|
||
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
||
let lastGeocodingRequest = 0;
|
||
|
||
app.post("/api/geocode", isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
const { address } = req.body;
|
||
|
||
if (!address || typeof address !== 'string') {
|
||
return res.status(400).json({ message: "Address parameter required" });
|
||
}
|
||
|
||
// Rispetta rate limit di 1 req/sec
|
||
const now = Date.now();
|
||
const timeSinceLastRequest = now - lastGeocodingRequest;
|
||
if (timeSinceLastRequest < 1000) {
|
||
const waitTime = 1000 - timeSinceLastRequest;
|
||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||
}
|
||
lastGeocodingRequest = Date.now();
|
||
|
||
// Chiama Nominatim API
|
||
const nominatimUrl = new URL("https://nominatim.openstreetmap.org/search");
|
||
nominatimUrl.searchParams.set("q", address);
|
||
nominatimUrl.searchParams.set("format", "json");
|
||
nominatimUrl.searchParams.set("limit", "1");
|
||
nominatimUrl.searchParams.set("addressdetails", "1");
|
||
|
||
// Nominatim Usage Policy richiede User-Agent con contatto email
|
||
// Ref: https://operations.osmfoundation.org/policies/nominatim/
|
||
const response = await fetch(nominatimUrl.toString(), {
|
||
headers: {
|
||
"User-Agent": "VigilanzaTurni/1.0 (Security Shift Management System; contact: support@vigilanzaturni.it)",
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Nominatim API error: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (!data || data.length === 0) {
|
||
return res.status(404).json({
|
||
message: "Indirizzo non trovato. Prova a essere più specifico (es. Via, Città, Italia)"
|
||
});
|
||
}
|
||
|
||
const result = data[0];
|
||
|
||
res.json({
|
||
latitude: result.lat,
|
||
longitude: result.lon,
|
||
displayName: result.display_name,
|
||
address: result.address,
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error("Error geocoding address:", error);
|
||
res.status(500).json({ message: "Errore durante la geocodifica dell'indirizzo" });
|
||
}
|
||
});
|
||
|
||
const httpServer = createServer(app);
|
||
return httpServer;
|
||
}
|