Compare commits

..

No commits in common. "1598eb208bdc9c70174c3d99328c2b71e057dfcc" and "d8a6ec9c493288672464446d87ba5f26239b7e69" have entirely different histories.

10 changed files with 89 additions and 341 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

View File

@ -56,7 +56,7 @@ const menuItems = [
roles: ["admin", "coordinator"], roles: ["admin", "coordinator"],
}, },
{ {
title: "Planning Fissi", title: "Planning Generale",
url: "/general-planning", url: "/general-planning",
icon: BarChart3, icon: BarChart3,
roles: ["admin", "coordinator"], roles: ["admin", "coordinator"],

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { format, startOfWeek, addWeeks } from "date-fns"; import { format, startOfWeek, addWeeks } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
@ -19,16 +19,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { queryClient, apiRequest } from "@/lib/queryClient"; import { queryClient, apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { GuardAvailability, Vehicle as VehicleDb } from "@shared/schema"; import type { GuardAvailability, Vehicle as VehicleDb } from "@shared/schema";
@ -54,9 +44,6 @@ interface SiteData {
siteId: string; siteId: string;
siteName: string; siteName: string;
serviceType: string; serviceType: string;
serviceStartTime: string;
serviceEndTime: string;
serviceHours: number;
minGuards: number; minGuards: number;
guards: GuardWithHours[]; guards: GuardWithHours[];
vehicles: Vehicle[]; vehicles: Vehicle[];
@ -116,7 +103,6 @@ export default function GeneralPlanning() {
const [durationHours, setDurationHours] = useState<number>(8); const [durationHours, setDurationHours] = useState<number>(8);
const [consecutiveDays, setConsecutiveDays] = useState<number>(1); const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false); const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
const [ccnlConfirmation, setCcnlConfirmation] = useState<{ message: string; data: any } | null>(null);
// Query per dati planning settimanale // Query per dati planning settimanale
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({ const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
@ -175,28 +161,14 @@ export default function GeneralPlanning() {
staleTime: 0, staleTime: 0,
}); });
// Calcola dati aggiornati della cella selezionata (per auto-refresh dialog)
const currentCellData = (() => {
if (!selectedCell || !planningData) return selectedCell?.data;
// Trova i dati freschi da planningData
const day = planningData.days.find(d => d.date === selectedCell.date);
if (!day) return selectedCell.data;
const updatedSite = day.sites.find(s => s.siteId === selectedCell.siteId);
return updatedSite || selectedCell.data;
})();
// Mutation per eliminare assegnazione guardia // Mutation per eliminare assegnazione guardia
const deleteAssignmentMutation = useMutation({ const deleteAssignmentMutation = useMutation({
mutationFn: async (assignmentId: string) => { mutationFn: async (assignmentId: string) => {
return apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`, undefined); return apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`, undefined);
}, },
onSuccess: async () => { onSuccess: () => {
// Invalida e refetch planning generale per aggiornare dialog queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] }); queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
await queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
await queryClient.refetchQueries({ queryKey: ["/api/general-planning"] });
toast({ toast({
title: "Guardia rimossa", title: "Guardia rimossa",
@ -214,7 +186,7 @@ export default function GeneralPlanning() {
// Mutation per assegnare guardia con orari (anche multi-giorno) // Mutation per assegnare guardia con orari (anche multi-giorno)
const assignGuardMutation = useMutation({ const assignGuardMutation = useMutation({
mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number; vehicleId?: string; force?: boolean }) => { mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number; vehicleId?: string }) => {
return apiRequest("POST", "/api/general-planning/assign-guard", data); return apiRequest("POST", "/api/general-planning/assign-guard", data);
}, },
onSuccess: async () => { onSuccess: async () => {
@ -234,33 +206,19 @@ export default function GeneralPlanning() {
// Reset form (NON chiudere dialog per vedere lista aggiornata) // Reset form (NON chiudere dialog per vedere lista aggiornata)
setSelectedGuardId(""); setSelectedGuardId("");
setSelectedVehicleId(""); setSelectedVehicleId("");
setCcnlConfirmation(null); // Reset dialog conferma se aperto
}, },
onError: (error: any, variables) => { onError: (error: any) => {
// Parse error message from API response // Parse error message from API response
let errorMessage = "Impossibile assegnare la guardia"; let errorMessage = "Impossibile assegnare la guardia";
let errorType = "";
if (error.message) { if (error.message) {
// Error format from apiRequest: "STATUS_CODE: {json_body}" // Error format from apiRequest: "STATUS_CODE: {json_body}"
const match = error.message.match(/^(\d+):\s*(.+)$/); const match = error.message.match(/^\d+:\s*(.+)$/);
if (match) { if (match) {
const statusCode = match[1];
try { try {
const parsed = JSON.parse(match[2]); const parsed = JSON.parse(match[1]);
errorMessage = parsed.message || errorMessage; errorMessage = parsed.message || errorMessage;
errorType = parsed.type || "";
// Se è un errore CCNL (409 con tipo CCNL_VIOLATION), mostra dialog conferma
if (statusCode === "409" && errorType === "CCNL_VIOLATION") {
setCcnlConfirmation({
message: errorMessage,
data: variables
});
return; // Non mostrare toast, mostra dialog
}
} catch { } catch {
errorMessage = match[2]; errorMessage = match[1];
} }
} else { } else {
errorMessage = error.message; errorMessage = error.message;
@ -330,7 +288,7 @@ export default function GeneralPlanning() {
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title"> <h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
Planning Fissi Planning Generale
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Vista settimanale turni con calcolo automatico guardie mancanti Vista settimanale turni con calcolo automatico guardie mancanti
@ -817,14 +775,14 @@ export default function GeneralPlanning() {
</div> </div>
{/* Guardie già assegnate - fuori dal form box per evitare di nascondere il form */} {/* Guardie già assegnate - fuori dal form box per evitare di nascondere il form */}
{currentCellData && currentCellData.guards.length > 0 && ( {selectedCell.data.guards.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold flex items-center gap-2"> <h3 className="text-sm font-semibold flex items-center gap-2">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
Guardie Già Assegnate ({currentCellData.guards.length}) Guardie Già Assegnate ({selectedCell.data.guards.length})
</h3> </h3>
<div className="grid gap-2"> <div className="grid gap-2">
{currentCellData.guards.map((guard, idx) => ( {selectedCell.data.guards.map((guard, idx) => (
<div key={idx} className="flex items-start justify-between gap-2 bg-muted/30 p-2.5 rounded border"> <div key={idx} className="flex items-start justify-between gap-2 bg-muted/30 p-2.5 rounded border">
<div className="flex-1 space-y-1"> <div className="flex-1 space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -862,61 +820,43 @@ export default function GeneralPlanning() {
{/* Info turni esistenti */} {/* Info turni esistenti */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold text-sm">Informazioni Servizio</h3> <h3 className="font-semibold text-sm">Situazione Attuale</h3>
{/* Tipo servizio e orario */}
{currentCellData && (
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tipo Servizio</span>
<Badge variant="outline">{currentCellData.serviceType}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Orario Servizio</span>
<span className="text-sm font-medium">{currentCellData.serviceStartTime} - {currentCellData.serviceEndTime}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Ore Richieste</span>
<span className="text-sm font-bold">{currentCellData.serviceHours}h</span>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<p className="text-sm text-muted-foreground">Turni Pianificati</p> <p className="text-sm text-muted-foreground">Turni Pianificati</p>
<p className="text-2xl font-bold">{currentCellData?.shiftsCount || 0}</p> <p className="text-2xl font-bold">{selectedCell.data.shiftsCount}</p>
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Ore Assegnate</p> <p className="text-sm text-muted-foreground">Ore Totali</p>
<p className="text-2xl font-bold">{currentCellData?.totalShiftHours || 0}h</p> <p className="text-2xl font-bold">{selectedCell.data.totalShiftHours}h</p>
</div> </div>
</div> </div>
{/* Guardie mancanti */} {/* Guardie mancanti */}
{currentCellData && currentCellData.missingGuards > 0 && ( {selectedCell.data.missingGuards > 0 && (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md"> <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex items-center gap-2 text-destructive font-semibold mb-2"> <div className="flex items-center gap-2 text-destructive font-semibold mb-2">
<AlertTriangle className="h-5 w-5" /> <AlertTriangle className="h-5 w-5" />
Attenzione: Guardie Mancanti Attenzione: Guardie Mancanti
</div> </div>
<p className="text-sm"> <p className="text-sm">
Servono ancora <span className="font-bold">{currentCellData.missingGuards}</span>{" "} Servono ancora <span className="font-bold">{selectedCell.data.missingGuards}</span>{" "}
{currentCellData.missingGuards === 1 ? "guardia" : "guardie"} per coprire completamente il servizio {selectedCell.data.missingGuards === 1 ? "guardia" : "guardie"} per coprire completamente il servizio
(calcolato su {currentCellData.totalShiftHours}h con max 9h per guardia e {currentCellData.minGuards} {currentCellData.minGuards === 1 ? "guardia minima" : "guardie minime"} contemporanee) (calcolato su {selectedCell.data.totalShiftHours}h con max 9h per guardia e {selectedCell.data.minGuards} {selectedCell.data.minGuards === 1 ? "guardia minima" : "guardie minime"} contemporanee)
</p> </p>
</div> </div>
)} )}
{/* Veicoli */} {/* Veicoli */}
{currentCellData && currentCellData.vehicles.length > 0 && ( {selectedCell.data.vehicles.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold"> <div className="flex items-center gap-2 text-sm font-semibold">
<Car className="h-4 w-4" /> <Car className="h-4 w-4" />
Veicoli Assegnati ({currentCellData.vehicles.length}) Veicoli Assegnati ({selectedCell.data.vehicles.length})
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
{currentCellData.vehicles.map((vehicle, idx) => ( {selectedCell.data.vehicles.map((vehicle, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 bg-accent/10 rounded-md"> <div key={idx} className="flex items-center gap-2 p-2 bg-accent/10 rounded-md">
<p className="font-medium">{vehicle.licensePlate}</p> <p className="font-medium">{vehicle.licensePlate}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@ -951,43 +891,6 @@ export default function GeneralPlanning() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Dialog conferma forzatura CCNL */}
<AlertDialog open={!!ccnlConfirmation} onOpenChange={() => setCcnlConfirmation(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-600" />
Superamento Limite CCNL
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p className="text-foreground font-medium">
{ccnlConfirmation?.message}
</p>
<p className="text-sm">
Vuoi forzare comunque l'assegnazione? L'operazione verrà registrata e potrai consultarla nei report.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-cancel-force">Annulla</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (ccnlConfirmation) {
assignGuardMutation.mutate({
...ccnlConfirmation.data,
force: true
});
}
}}
data-testid="button-confirm-force"
className="bg-yellow-600 hover:bg-yellow-700"
>
Forza Assegnazione
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View File

@ -46,7 +46,7 @@ The database includes core tables for `users`, `guards`, `certifications`, `site
5. Assign resources and create shift 5. Assign resources and create shift
- **Location-Based Filtering**: Backend endpoints use INNER JOIN with sites table to ensure complete resource isolation between locations - guards/vehicles in one sede remain available even when assigned to shifts in other sedi - **Location-Based Filtering**: Backend endpoints use INNER JOIN with sites table to ensure complete resource isolation between locations - guards/vehicles in one sede remain available even when assigned to shifts in other sedi
- **Site Management**: Added sede selection in site creation/editing forms with visual badges showing location in site listings - **Site Management**: Added sede selection in site creation/editing forms with visual badges showing location in site listings
- **Planning Fissi (October 18, 2025)**: New weekly planning overview feature showing all sites × 7 days in table format: - **Planning Generale (October 18, 2025)**: New weekly planning overview feature showing all sites × 7 days in table format:
- **Contract filtering**: Shows only sites with active contracts in the week dates (`contractStartDate <= weekEnd AND contractEndDate >= weekStart`) - **Contract filtering**: Shows only sites with active contracts in the week dates (`contractStartDate <= weekEnd AND contractEndDate >= weekStart`)
- Backend endpoint `/api/general-planning?weekStart=YYYY-MM-DD&location=sede` with complex joins and location filtering - Backend endpoint `/api/general-planning?weekStart=YYYY-MM-DD&location=sede` with complex joins and location filtering
- Automatic missing guards calculation: `ceil(totalShiftHours / maxHoursPerGuard) × minGuards - assignedGuards` (e.g., 24h shift, 2 guards min, 9h max = 6 total needed) - Automatic missing guards calculation: `ceil(totalShiftHours / maxHoursPerGuard) × minGuards - assignedGuards` (e.g., 24h shift, 2 guards min, 9h max = 6 total needed)

View File

@ -4,11 +4,9 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth"; import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth"; import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
import { db } from "./db"; import { db } from "./db";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema } from "@shared/schema"; import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema } from "@shared/schema";
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm"; import { 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 { 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 // Determina quale sistema auth usare basandosi sull'ambiente
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS; const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
@ -1028,41 +1026,37 @@ export async function registerRoutes(app: Express): Promise<Server> {
const maxOreGuardia = 9; // Max ore per guardia const maxOreGuardia = 9; // Max ore per guardia
const minGuardie = site.minGuards || 1; const minGuardie = site.minGuards || 1;
// Calcola ore servizio del sito (per calcolo corretto anche senza turni) // Somma ore totali dei turni del giorno
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 totalShiftHours = dayShifts.reduce((sum: number, ds: any) => {
const start = new Date(ds.shift.startTime); const start = new Date(ds.shift.startTime);
const end = new Date(ds.shift.endTime); const end = new Date(ds.shift.endTime);
return sum + differenceInHours(end, start); return sum + differenceInHours(end, start);
}, 0); }, 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) // Guardie uniche assegnate (conta ogni guardia una volta anche se ha più turni)
const uniqueGuardsAssigned = new Set(guardsWithHours.map((g: any) => g.guardId)).size; const uniqueGuardsAssigned = new Set(guardsWithHours.map((g: any) => g.guardId)).size;
// Calcolo guardie necessarie basato su ore servizio // Calcolo guardie necessarie e mancanti
// Slot necessari per coprire le ore (ogni guardia max 9h) let totalGuardsNeeded: number;
const slotsNeeded = Math.ceil(effectiveHours / maxOreGuardia); let missingGuards: number;
// Guardie totali necessarie (slot × min guardie contemporanee)
const totalGuardsNeeded = slotsNeeded * minGuardie; if (totalShiftHours > 0) {
const missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned); // Se ci sono turni: calcola basandosi sulle ore
// Slot necessari per coprire le ore totali
const slotsNeeded = Math.ceil(totalShiftHours / maxOreGuardia);
// Guardie totali necessarie (slot × min guardie contemporanee)
totalGuardsNeeded = slotsNeeded * minGuardie;
missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned);
} else {
// Se NON ci sono turni: serve almeno la copertura minima
totalGuardsNeeded = minGuardie;
missingGuards = minGuardie; // Tutte mancanti perché non ci sono turni
}
return { return {
siteId: site.id, siteId: site.id,
siteName: site.name, siteName: site.name,
serviceType: serviceType?.label || "N/A", serviceType: serviceType?.label || "N/A",
serviceStartTime: serviceStart,
serviceEndTime: serviceEnd,
serviceHours: Math.round(serviceHours * 10) / 10, // Arrotonda a 1 decimale
minGuards: site.minGuards, minGuards: site.minGuards,
guards: guardsWithHours, guards: guardsWithHours,
vehicles: dayVehicles, vehicles: dayVehicles,
@ -1246,7 +1240,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Assign guard to site/date with specific time slot (supports multi-day assignments) // Assign guard to site/date with specific time slot (supports multi-day assignments)
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => { app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
try { try {
const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId, force = false } = req.body; const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId } = req.body;
if (!siteId || !date || !guardId || !startTime || !durationHours) { if (!siteId || !date || !guardId || !startTime || !durationHours) {
return res.status(400).json({ return res.status(400).json({
@ -1293,8 +1287,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
const actualMonth = baseDate.getMonth(); const actualMonth = baseDate.getMonth();
const actualDay = baseDate.getDate(); const actualDay = baseDate.getDate();
// Build dates in LOCAL timezone to match user's selection // Build dates in UTC to avoid timezone shifts
const shiftDate = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0); const shiftDate = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0));
// Check contract validity for this date // Check contract validity for this date
if (site.contractStartDate && site.contractEndDate) { if (site.contractStartDate && site.contractEndDate) {
@ -1307,13 +1301,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
} }
// Calculate planned start and end times in LOCAL timezone // Calculate planned start and end times in UTC
const plannedStart = new Date(actualYear, actualMonth, actualDay, hours, minutes, 0, 0); const plannedStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours, minutes, 0, 0));
const plannedEnd = new Date(actualYear, actualMonth, actualDay, hours + durationHours, minutes, 0, 0); const plannedEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours + durationHours, minutes, 0, 0));
// Find or create shift for this site/date (full day boundaries in LOCAL timezone) // Find or create shift for this site/date (full day boundaries in UTC)
const dayStart = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0); const dayStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0));
const dayEnd = new Date(actualYear, actualMonth, actualDay, 23, 59, 59, 999); const dayEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, 23, 59, 59, 999));
let existingShifts = await tx let existingShifts = await tx
.select() .select()
@ -1337,12 +1331,12 @@ export async function registerRoutes(app: Express): Promise<Server> {
const [startHour, startMin] = serviceStart.split(":").map(Number); const [startHour, startMin] = serviceStart.split(":").map(Number);
const [endHour, endMin] = serviceEnd.split(":").map(Number); const [endHour, endMin] = serviceEnd.split(":").map(Number);
const shiftStart = new Date(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0); const shiftStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0));
let shiftEnd = new Date(actualYear, actualMonth, actualDay, endHour, endMin, 0, 0); let shiftEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, endHour, endMin, 0, 0));
// If end time is before/equal to start time, shift extends to next day // If end time is before/equal to start time, shift extends to next day
if (shiftEnd <= shiftStart) { if (shiftEnd <= shiftStart) {
shiftEnd = new Date(actualYear, actualMonth, actualDay + 1, endHour, endMin, 0, 0); shiftEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay + 1, endHour, endMin, 0, 0));
} }
[shift] = await tx.insert(shifts).values({ [shift] = await tx.insert(shifts).values({
@ -1374,37 +1368,34 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
} }
// CCNL: Check daily hour limit (max 9h/day) - skip if force=true // CCNL: Check daily hour limit (max 9h/day)
if (!force) { const maxDailyHours = 9;
const maxDailyHours = 9; let dailyHoursAlreadyAssigned = 0;
let dailyHoursAlreadyAssigned = 0;
for (const existing of existingAssignments) { for (const existing of existingAssignments) {
// Check if assignment is on the same day // Check if assignment is on the same day
const existingDate = new Date(existing.plannedStartTime); const existingDate = new Date(existing.plannedStartTime);
if ( if (
existingDate.getUTCFullYear() === actualYear && existingDate.getUTCFullYear() === actualYear &&
existingDate.getUTCMonth() === actualMonth && existingDate.getUTCMonth() === actualMonth &&
existingDate.getUTCDate() === actualDay existingDate.getUTCDate() === actualDay
) { ) {
const assignmentHours = differenceInHours( const assignmentHours = differenceInHours(
existing.plannedEndTime, existing.plannedEndTime,
existing.plannedStartTime 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).`
); );
dailyHoursAlreadyAssigned += assignmentHours;
} }
} }
// Check if new assignment would exceed daily limit
if (dailyHoursAlreadyAssigned + durationHours > maxDailyHours) {
throw new Error(
`Limite giornaliero superato: la guardia ha già ${dailyHoursAlreadyAssigned}h assegnate il ${shiftDate.toLocaleDateString('it-IT')}. ` +
`Aggiungendo ${durationHours}h si supererebbero le ${maxDailyHours}h massime giornaliere (CCNL).`
);
}
// Create assignment for this day // Create assignment for this day
const [assignment] = await tx.insert(shiftAssignments).values({ const [assignment] = await tx.insert(shiftAssignments).values({
shiftId: shift.id, shiftId: shift.id,
@ -1431,14 +1422,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
if (errorMessage.includes('overlap') || if (errorMessage.includes('overlap') ||
errorMessage.includes('conflict') || errorMessage.includes('conflict') ||
errorMessage.includes('conflitto') || errorMessage.includes('conflitto') ||
errorMessage.includes('già assegnata') || errorMessage.includes('già assegnata')) {
errorMessage.includes('limite giornaliero') || return res.status(409).json({ message: error.message });
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) }); res.status(500).json({ message: "Failed to assign guard", error: String(error) });
} }
@ -1981,67 +1966,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// ============= 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 ============= // ============= SITE ROUTES =============
app.get("/api/sites", isAuthenticated, async (req, res) => { app.get("/api/sites", isAuthenticated, async (req, res) => {
try { try {

View File

@ -4,7 +4,6 @@ import {
guards, guards,
certifications, certifications,
vehicles, vehicles,
customers,
sites, sites,
shifts, shifts,
shiftAssignments, shiftAssignments,
@ -27,8 +26,6 @@ import {
type InsertCertification, type InsertCertification,
type Vehicle, type Vehicle,
type InsertVehicle, type InsertVehicle,
type Customer,
type InsertCustomer,
type Site, type Site,
type InsertSite, type InsertSite,
type Shift, type Shift,
@ -88,13 +85,6 @@ export interface IStorage {
updateServiceType(id: string, serviceType: Partial<InsertServiceType>): Promise<ServiceType | undefined>; updateServiceType(id: string, serviceType: Partial<InsertServiceType>): Promise<ServiceType | undefined>;
deleteServiceType(id: string): Promise<ServiceType | undefined>; deleteServiceType(id: string): Promise<ServiceType | undefined>;
// Customer operations
getAllCustomers(): Promise<Customer[]>;
getCustomer(id: string): Promise<Customer | undefined>;
createCustomer(customer: InsertCustomer): Promise<Customer>;
updateCustomer(id: string, customer: Partial<InsertCustomer>): Promise<Customer | undefined>;
deleteCustomer(id: string): Promise<Customer | undefined>;
// Site operations // Site operations
getAllSites(): Promise<Site[]>; getAllSites(): Promise<Site[]>;
getSite(id: string): Promise<Site | undefined>; getSite(id: string): Promise<Site | undefined>;
@ -352,35 +342,6 @@ export class DatabaseStorage implements IStorage {
return deleted; return deleted;
} }
// Customer operations
async getAllCustomers(): Promise<Customer[]> {
return await db.select().from(customers).orderBy(desc(customers.createdAt));
}
async getCustomer(id: string): Promise<Customer | undefined> {
const [customer] = await db.select().from(customers).where(eq(customers.id, id));
return customer;
}
async createCustomer(customer: InsertCustomer): Promise<Customer> {
const [newCustomer] = await db.insert(customers).values(customer).returning();
return newCustomer;
}
async updateCustomer(id: string, customerData: Partial<InsertCustomer>): Promise<Customer | undefined> {
const [updated] = await db
.update(customers)
.set({ ...customerData, updatedAt: new Date() })
.where(eq(customers.id, id))
.returning();
return updated;
}
async deleteCustomer(id: string): Promise<Customer | undefined> {
const [deleted] = await db.delete(customers).where(eq(customers.id, id)).returning();
return deleted;
}
// Site operations // Site operations
async getAllSites(): Promise<Site[]> { async getAllSites(): Promise<Site[]> {
return await db.select().from(sites); return await db.select().from(sites);

View File

@ -200,35 +200,13 @@ export const serviceTypes = pgTable("service_types", {
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
}); });
// ============= CUSTOMERS =============
export const customers = pgTable("customers", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
name: varchar("name").notNull(),
businessName: varchar("business_name"), // Ragione sociale
vatNumber: varchar("vat_number"), // Partita IVA
fiscalCode: varchar("fiscal_code"), // Codice fiscale
address: varchar("address"),
city: varchar("city"),
province: varchar("province"),
zipCode: varchar("zip_code"),
phone: varchar("phone"),
email: varchar("email"),
pec: varchar("pec"), // PEC (Posta Elettronica Certificata)
contactPerson: varchar("contact_person"), // Referente
notes: text("notes"),
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// ============= SITES & CONTRACTS ============= // ============= SITES & CONTRACTS =============
export const sites = pgTable("sites", { export const sites = pgTable("sites", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
name: varchar("name").notNull(), name: varchar("name").notNull(),
address: varchar("address").notNull(), address: varchar("address").notNull(),
customerId: varchar("customer_id").references(() => customers.id), clientId: varchar("client_id").references(() => users.id),
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale
// Service requirements // Service requirements
@ -500,6 +478,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({
fields: [users.id], fields: [users.id],
references: [guards.userId], references: [guards.userId],
}), }),
managedSites: many(sites),
notifications: many(notifications), notifications: many(notifications),
})); }));
@ -531,14 +510,10 @@ export const certificationsRelations = relations(certifications, ({ one }) => ({
}), }),
})); }));
export const customersRelations = relations(customers, ({ many }) => ({
sites: many(sites),
}));
export const sitesRelations = relations(sites, ({ one, many }) => ({ export const sitesRelations = relations(sites, ({ one, many }) => ({
customer: one(customers, { client: one(users, {
fields: [sites.customerId], fields: [sites.clientId],
references: [customers.id], references: [users.id],
}), }),
shifts: many(shifts), shifts: many(shifts),
preferences: many(sitePreferences), preferences: many(sitePreferences),
@ -705,12 +680,6 @@ export const insertServiceTypeSchema = createInsertSchema(serviceTypes).omit({
updatedAt: true, updatedAt: true,
}); });
export const insertCustomerSchema = createInsertSchema(customers).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertSiteSchema = createInsertSchema(sites).omit({ export const insertSiteSchema = createInsertSchema(sites).omit({
id: true, id: true,
createdAt: true, createdAt: true,
@ -825,9 +794,6 @@ export type Vehicle = typeof vehicles.$inferSelect;
export type InsertServiceType = z.infer<typeof insertServiceTypeSchema>; export type InsertServiceType = z.infer<typeof insertServiceTypeSchema>;
export type ServiceType = typeof serviceTypes.$inferSelect; export type ServiceType = typeof serviceTypes.$inferSelect;
export type InsertCustomer = z.infer<typeof insertCustomerSchema>;
export type Customer = typeof customers.$inferSelect;
export type InsertSite = z.infer<typeof insertSiteSchema>; export type InsertSite = z.infer<typeof insertSiteSchema>;
export type Site = typeof sites.$inferSelect; export type Site = typeof sites.$inferSelect;

View File

@ -1,13 +1,7 @@
{ {
"version": "1.0.34", "version": "1.0.33",
"lastUpdate": "2025-10-23T08:03:06.051Z", "lastUpdate": "2025-10-22T08:52:21.600Z",
"changelog": [ "changelog": [
{
"version": "1.0.34",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.34"
},
{ {
"version": "1.0.33", "version": "1.0.33",
"date": "2025-10-22", "date": "2025-10-22",