Compare commits

..

7 Commits

Author SHA1 Message Date
Marco Lanzara
1598eb208b 🚀 Release v1.0.34
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.34_20251023_080247.sql.gz
- Data: 2025-10-23 08:03:06
2025-10-23 08:03:06 +00:00
marco370
8bb0386d1e Add customer management features and rename planning view
Implement CRUD operations for customers, including API endpoints and database schema. Rename the "Planning Generale" view to "Planning Fissi" and update related UI elements and documentation.

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/2w7P7NW
2025-10-23 07:58:57 +00:00
marco370
ba0bd4d36f Add confirmation dialog for guard assignments exceeding limits
Update general planning to include AlertDialog for CCNL_VIOLATION errors, allowing forced guard assignments and displaying service details.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/2w7P7NW
2025-10-23 07:46:11 +00:00
marco370
9c28befcb1 Allow overriding daily hour limits for guard assignments
Update the general planning API endpoint to include a 'force' option, enabling the assignment of guards even if they exceed the daily hour limit. This change adds a confirmation prompt for such cases and refactors error handling to provide more specific messages.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/2w7P7NW
2025-10-23 07:41:34 +00:00
marco370
fb99b5f738 Update planning dialog to show fresh data and improve guard assignment display
Refactors the general planning dialog to use a computed property `currentCellData` for fetching the latest site data, ensuring real-time updates. Updates the display of assigned guards and site statistics (shifts, hours, missing guards) to reflect this fresh data.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/2w7P7NW
2025-10-23 07:40:31 +00:00
marco370
153f272c15 Fix issues with shift assignment and guard allocation
Correct date assignment for shifts, prevent multiplication of assigned guards, and ensure real-time updates in the guard selection dialog.

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/TWQ52cO
2025-10-22 09:12:45 +00:00
marco370
3b7c55b55b Improve planning accuracy and real-time updates for guard shifts
Fixes an issue where dates were incorrectly shifted due to timezone handling and ensures the guard assignment dialog updates in real-time after modifications. Additionally, refactors the calculation of needed guards to accurately reflect site service hours and minimum guard requirements.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/TWQ52cO
2025-10-22 09:12:20 +00:00
10 changed files with 342 additions and 90 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

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

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } 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,6 +19,16 @@ 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";
@ -44,6 +54,9 @@ 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[];
@ -103,6 +116,7 @@ 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>({
@ -161,14 +175,28 @@ 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: () => { onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] }); // Invalida e refetch planning generale per aggiornare dialog
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] }); await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
await queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
await queryClient.refetchQueries({ queryKey: ["/api/general-planning"] });
toast({ toast({
title: "Guardia rimossa", title: "Guardia rimossa",
@ -186,7 +214,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 }) => { mutationFn: async (data: { siteId: string; date: string; guardId: string; startTime: string; durationHours: number; consecutiveDays: number; vehicleId?: string; force?: boolean }) => {
return apiRequest("POST", "/api/general-planning/assign-guard", data); return apiRequest("POST", "/api/general-planning/assign-guard", data);
}, },
onSuccess: async () => { onSuccess: async () => {
@ -206,19 +234,33 @@ 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) => { onError: (error: any, variables) => {
// 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[1]); const parsed = JSON.parse(match[2]);
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[1]; errorMessage = match[2];
} }
} else { } else {
errorMessage = error.message; errorMessage = error.message;
@ -288,7 +330,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 Generale Planning Fissi
</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
@ -775,14 +817,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 */}
{selectedCell.data.guards.length > 0 && ( {currentCellData && currentCellData.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 ({selectedCell.data.guards.length}) Guardie Già Assegnate ({currentCellData.guards.length})
</h3> </h3>
<div className="grid gap-2"> <div className="grid gap-2">
{selectedCell.data.guards.map((guard, idx) => ( {currentCellData.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">
@ -820,43 +862,61 @@ 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">Situazione Attuale</h3> <h3 className="font-semibold text-sm">Informazioni Servizio</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">{selectedCell.data.shiftsCount}</p> <p className="text-2xl font-bold">{currentCellData?.shiftsCount || 0}</p>
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Ore Totali</p> <p className="text-sm text-muted-foreground">Ore Assegnate</p>
<p className="text-2xl font-bold">{selectedCell.data.totalShiftHours}h</p> <p className="text-2xl font-bold">{currentCellData?.totalShiftHours || 0}h</p>
</div> </div>
</div> </div>
{/* Guardie mancanti */} {/* Guardie mancanti */}
{selectedCell.data.missingGuards > 0 && ( {currentCellData && currentCellData.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">{selectedCell.data.missingGuards}</span>{" "} Servono ancora <span className="font-bold">{currentCellData.missingGuards}</span>{" "}
{selectedCell.data.missingGuards === 1 ? "guardia" : "guardie"} per coprire completamente il servizio {currentCellData.missingGuards === 1 ? "guardia" : "guardie"} per coprire completamente il servizio
(calcolato su {selectedCell.data.totalShiftHours}h con max 9h per guardia e {selectedCell.data.minGuards} {selectedCell.data.minGuards === 1 ? "guardia minima" : "guardie minime"} contemporanee) (calcolato su {currentCellData.totalShiftHours}h con max 9h per guardia e {currentCellData.minGuards} {currentCellData.minGuards === 1 ? "guardia minima" : "guardie minime"} contemporanee)
</p> </p>
</div> </div>
)} )}
{/* Veicoli */} {/* Veicoli */}
{selectedCell.data.vehicles.length > 0 && ( {currentCellData && currentCellData.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 ({selectedCell.data.vehicles.length}) Veicoli Assegnati ({currentCellData.vehicles.length})
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
{selectedCell.data.vehicles.map((vehicle, idx) => ( {currentCellData.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">
@ -891,6 +951,43 @@ 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 Generale (October 18, 2025)**: New weekly planning overview feature showing all sites × 7 days in table format: - **Planning Fissi (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,9 +4,11 @@ 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 } from "@shared/schema"; import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema } 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;
@ -1026,37 +1028,41 @@ 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;
// Somma ore totali dei turni del giorno // 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 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 e mancanti // Calcolo guardie necessarie basato su ore servizio
let totalGuardsNeeded: number; // Slot necessari per coprire le ore (ogni guardia max 9h)
let missingGuards: number; const slotsNeeded = Math.ceil(effectiveHours / maxOreGuardia);
// Guardie totali necessarie (slot × min guardie contemporanee)
if (totalShiftHours > 0) { const totalGuardsNeeded = slotsNeeded * minGuardie;
// Se ci sono turni: calcola basandosi sulle ore const missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned);
// 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,
@ -1240,7 +1246,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 } = req.body; const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId, force = false } = req.body;
if (!siteId || !date || !guardId || !startTime || !durationHours) { if (!siteId || !date || !guardId || !startTime || !durationHours) {
return res.status(400).json({ return res.status(400).json({
@ -1287,8 +1293,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 UTC to avoid timezone shifts // Build dates in LOCAL timezone to match user's selection
const shiftDate = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0)); const shiftDate = new Date(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) {
@ -1301,13 +1307,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
} }
// Calculate planned start and end times in UTC // Calculate planned start and end times in LOCAL timezone
const plannedStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours, minutes, 0, 0)); const plannedStart = new Date(actualYear, actualMonth, actualDay, hours, minutes, 0, 0);
const plannedEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours + durationHours, 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 UTC) // Find or create shift for this site/date (full day boundaries in LOCAL timezone)
const dayStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0)); const dayStart = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0);
const dayEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, 23, 59, 59, 999)); const dayEnd = new Date(actualYear, actualMonth, actualDay, 23, 59, 59, 999);
let existingShifts = await tx let existingShifts = await tx
.select() .select()
@ -1331,12 +1337,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(Date.UTC(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0)); const shiftStart = new Date(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0);
let shiftEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, endHour, endMin, 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 end time is before/equal to start time, shift extends to next day
if (shiftEnd <= shiftStart) { if (shiftEnd <= shiftStart) {
shiftEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay + 1, endHour, endMin, 0, 0)); shiftEnd = new Date(actualYear, actualMonth, actualDay + 1, endHour, endMin, 0, 0);
} }
[shift] = await tx.insert(shifts).values({ [shift] = await tx.insert(shifts).values({
@ -1368,32 +1374,35 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
} }
// CCNL: Check daily hour limit (max 9h/day) // CCNL: Check daily hour limit (max 9h/day) - skip if force=true
const maxDailyHours = 9; if (!force) {
let dailyHoursAlreadyAssigned = 0; const maxDailyHours = 9;
let dailyHoursAlreadyAssigned = 0;
for (const existing of existingAssignments) {
// Check if assignment is on the same day for (const existing of existingAssignments) {
const existingDate = new Date(existing.plannedStartTime); // Check if assignment is on the same day
if ( const existingDate = new Date(existing.plannedStartTime);
existingDate.getUTCFullYear() === actualYear && if (
existingDate.getUTCMonth() === actualMonth && existingDate.getUTCFullYear() === actualYear &&
existingDate.getUTCDate() === actualDay existingDate.getUTCMonth() === actualMonth &&
) { existingDate.getUTCDate() === actualDay
const assignmentHours = differenceInHours( ) {
existing.plannedEndTime, const assignmentHours = differenceInHours(
existing.plannedStartTime existing.plannedEndTime,
); existing.plannedStartTime
dailyHoursAlreadyAssigned += assignmentHours; );
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).`
);
} }
}
// 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
@ -1422,8 +1431,14 @@ 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') ||
return res.status(409).json({ message: error.message }); 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) }); res.status(500).json({ message: "Failed to assign guard", error: String(error) });
} }
@ -1966,6 +1981,67 @@ 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,6 +4,7 @@ import {
guards, guards,
certifications, certifications,
vehicles, vehicles,
customers,
sites, sites,
shifts, shifts,
shiftAssignments, shiftAssignments,
@ -26,6 +27,8 @@ 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,
@ -85,6 +88,13 @@ 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>;
@ -342,6 +352,35 @@ 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,13 +200,35 @@ 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(),
clientId: varchar("client_id").references(() => users.id), customerId: varchar("customer_id").references(() => customers.id),
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale
// Service requirements // Service requirements
@ -478,7 +500,6 @@ 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),
})); }));
@ -510,10 +531,14 @@ 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 }) => ({
client: one(users, { customer: one(customers, {
fields: [sites.clientId], fields: [sites.customerId],
references: [users.id], references: [customers.id],
}), }),
shifts: many(shifts), shifts: many(shifts),
preferences: many(sitePreferences), preferences: many(sitePreferences),
@ -680,6 +705,12 @@ 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,
@ -794,6 +825,9 @@ 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,7 +1,13 @@
{ {
"version": "1.0.33", "version": "1.0.34",
"lastUpdate": "2025-10-22T08:52:21.600Z", "lastUpdate": "2025-10-23T08:03:06.051Z",
"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",