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"],
},
{
title: "Planning Generale",
title: "Planning Fissi",
url: "/general-planning",
icon: BarChart3,
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 { format, startOfWeek, addWeeks } from "date-fns";
import { it } from "date-fns/locale";
@ -19,6 +19,16 @@ import {
DialogHeader,
DialogTitle,
} 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 { useToast } from "@/hooks/use-toast";
import type { GuardAvailability, Vehicle as VehicleDb } from "@shared/schema";
@ -44,6 +54,9 @@ interface SiteData {
siteId: string;
siteName: string;
serviceType: string;
serviceStartTime: string;
serviceEndTime: string;
serviceHours: number;
minGuards: number;
guards: GuardWithHours[];
vehicles: Vehicle[];
@ -103,6 +116,7 @@ export default function GeneralPlanning() {
const [durationHours, setDurationHours] = useState<number>(8);
const [consecutiveDays, setConsecutiveDays] = useState<number>(1);
const [showOvertimeGuards, setShowOvertimeGuards] = useState<boolean>(false);
const [ccnlConfirmation, setCcnlConfirmation] = useState<{ message: string; data: any } | null>(null);
// Query per dati planning settimanale
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
@ -161,14 +175,28 @@ export default function GeneralPlanning() {
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
const deleteAssignmentMutation = useMutation({
mutationFn: async (assignmentId: string) => {
return apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`, undefined);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
onSuccess: async () => {
// Invalida e refetch planning generale per aggiornare dialog
await queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
await queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
await queryClient.refetchQueries({ queryKey: ["/api/general-planning"] });
toast({
title: "Guardia rimossa",
@ -186,7 +214,7 @@ export default function GeneralPlanning() {
// Mutation per assegnare guardia con orari (anche multi-giorno)
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);
},
onSuccess: async () => {
@ -206,19 +234,33 @@ export default function GeneralPlanning() {
// Reset form (NON chiudere dialog per vedere lista aggiornata)
setSelectedGuardId("");
setSelectedVehicleId("");
setCcnlConfirmation(null); // Reset dialog conferma se aperto
},
onError: (error: any) => {
onError: (error: any, variables) => {
// Parse error message from API response
let errorMessage = "Impossibile assegnare la guardia";
let errorType = "";
if (error.message) {
// Error format from apiRequest: "STATUS_CODE: {json_body}"
const match = error.message.match(/^\d+:\s*(.+)$/);
const match = error.message.match(/^(\d+):\s*(.+)$/);
if (match) {
const statusCode = match[1];
try {
const parsed = JSON.parse(match[1]);
const parsed = JSON.parse(match[2]);
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 {
errorMessage = match[1];
errorMessage = match[2];
}
} else {
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>
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
Planning Generale
Planning Fissi
</h1>
<p className="text-muted-foreground">
Vista settimanale turni con calcolo automatico guardie mancanti
@ -775,14 +817,14 @@ export default function GeneralPlanning() {
</div>
{/* 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">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Users className="h-4 w-4" />
Guardie Già Assegnate ({selectedCell.data.guards.length})
Guardie Già Assegnate ({currentCellData.guards.length})
</h3>
<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 className="flex-1 space-y-1">
<div className="flex items-center gap-2">
@ -820,43 +862,61 @@ export default function GeneralPlanning() {
{/* Info turni esistenti */}
<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>
<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>
<p className="text-sm text-muted-foreground">Ore Totali</p>
<p className="text-2xl font-bold">{selectedCell.data.totalShiftHours}h</p>
<p className="text-sm text-muted-foreground">Ore Assegnate</p>
<p className="text-2xl font-bold">{currentCellData?.totalShiftHours || 0}h</p>
</div>
</div>
{/* 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="flex items-center gap-2 text-destructive font-semibold mb-2">
<AlertTriangle className="h-5 w-5" />
Attenzione: Guardie Mancanti
</div>
<p className="text-sm">
Servono ancora <span className="font-bold">{selectedCell.data.missingGuards}</span>{" "}
{selectedCell.data.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)
Servono ancora <span className="font-bold">{currentCellData.missingGuards}</span>{" "}
{currentCellData.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)
</p>
</div>
)}
{/* Veicoli */}
{selectedCell.data.vehicles.length > 0 && (
{currentCellData && currentCellData.vehicles.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<Car className="h-4 w-4" />
Veicoli Assegnati ({selectedCell.data.vehicles.length})
Veicoli Assegnati ({currentCellData.vehicles.length})
</div>
<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">
<p className="font-medium">{vehicle.licensePlate}</p>
<p className="text-sm text-muted-foreground">
@ -891,6 +951,43 @@ export default function GeneralPlanning() {
</DialogFooter>
</DialogContent>
</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>
);
}

View File

@ -46,7 +46,7 @@ The database includes core tables for `users`, `guards`, `certifications`, `site
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
- **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`)
- 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)

View File

@ -4,9 +4,11 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
import { db } from "./db";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema } from "@shared/schema";
import { 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 { 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;
@ -1026,37 +1028,41 @@ export async function registerRoutes(app: Express): Promise<Server> {
const maxOreGuardia = 9; // Max ore per guardia
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 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 e mancanti
let totalGuardsNeeded: number;
let missingGuards: number;
if (totalShiftHours > 0) {
// Se ci sono turni: calcola basandosi sulle ore
// Slot necessari per coprire le ore totali
const slotsNeeded = Math.ceil(totalShiftHours / maxOreGuardia);
// Guardie totali necessarie (slot × min guardie contemporanee)
totalGuardsNeeded = slotsNeeded * minGuardie;
missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned);
} else {
// Se NON ci sono turni: serve almeno la copertura minima
totalGuardsNeeded = minGuardie;
missingGuards = minGuardie; // Tutte mancanti perché non ci sono turni
}
// 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,
@ -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)
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
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) {
return res.status(400).json({
@ -1287,8 +1293,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
const actualMonth = baseDate.getMonth();
const actualDay = baseDate.getDate();
// Build dates in UTC to avoid timezone shifts
const shiftDate = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0));
// 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) {
@ -1301,13 +1307,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
}
// Calculate planned start and end times in UTC
const plannedStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours, minutes, 0, 0));
const plannedEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours + durationHours, minutes, 0, 0));
// 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 UTC)
const dayStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0));
const dayEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, 23, 59, 59, 999));
// 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()
@ -1331,12 +1337,12 @@ export async function registerRoutes(app: Express): Promise<Server> {
const [startHour, startMin] = serviceStart.split(":").map(Number);
const [endHour, endMin] = serviceEnd.split(":").map(Number);
const shiftStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0));
let shiftEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, endHour, endMin, 0, 0));
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(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({
@ -1368,32 +1374,35 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
}
// CCNL: Check daily hour limit (max 9h/day)
const maxDailyHours = 9;
let dailyHoursAlreadyAssigned = 0;
// 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;
for (const existing of existingAssignments) {
// Check if assignment is on the same day
const existingDate = new Date(existing.plannedStartTime);
if (
existingDate.getUTCFullYear() === actualYear &&
existingDate.getUTCMonth() === actualMonth &&
existingDate.getUTCDate() === actualDay
) {
const assignmentHours = differenceInHours(
existing.plannedEndTime,
existing.plannedStartTime
);
dailyHoursAlreadyAssigned += assignmentHours;
}
}
}
// Check if new assignment would exceed daily limit
if (dailyHoursAlreadyAssigned + durationHours > maxDailyHours) {
throw new Error(
`Limite giornaliero superato: la guardia ha già ${dailyHoursAlreadyAssigned}h assegnate il ${shiftDate.toLocaleDateString('it-IT')}. ` +
`Aggiungendo ${durationHours}h si supererebbero le ${maxDailyHours}h massime giornaliere (CCNL).`
);
// 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
@ -1422,8 +1431,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
if (errorMessage.includes('overlap') ||
errorMessage.includes('conflict') ||
errorMessage.includes('conflitto') ||
errorMessage.includes('già assegnata')) {
return res.status(409).json({ message: error.message });
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) });
}
@ -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 =============
app.get("/api/sites", isAuthenticated, async (req, res) => {
try {

View File

@ -4,6 +4,7 @@ import {
guards,
certifications,
vehicles,
customers,
sites,
shifts,
shiftAssignments,
@ -26,6 +27,8 @@ import {
type InsertCertification,
type Vehicle,
type InsertVehicle,
type Customer,
type InsertCustomer,
type Site,
type InsertSite,
type Shift,
@ -85,6 +88,13 @@ export interface IStorage {
updateServiceType(id: string, serviceType: Partial<InsertServiceType>): 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
getAllSites(): Promise<Site[]>;
getSite(id: string): Promise<Site | undefined>;
@ -342,6 +352,35 @@ export class DatabaseStorage implements IStorage {
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
async getAllSites(): Promise<Site[]> {
return await db.select().from(sites);

View File

@ -200,13 +200,35 @@ export const serviceTypes = pgTable("service_types", {
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 =============
export const sites = pgTable("sites", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
name: varchar("name").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
// Service requirements
@ -478,7 +500,6 @@ export const usersRelations = relations(users, ({ one, many }) => ({
fields: [users.id],
references: [guards.userId],
}),
managedSites: many(sites),
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 }) => ({
client: one(users, {
fields: [sites.clientId],
references: [users.id],
customer: one(customers, {
fields: [sites.customerId],
references: [customers.id],
}),
shifts: many(shifts),
preferences: many(sitePreferences),
@ -680,6 +705,12 @@ export const insertServiceTypeSchema = createInsertSchema(serviceTypes).omit({
updatedAt: true,
});
export const insertCustomerSchema = createInsertSchema(customers).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertSiteSchema = createInsertSchema(sites).omit({
id: true,
createdAt: true,
@ -794,6 +825,9 @@ export type Vehicle = typeof vehicles.$inferSelect;
export type InsertServiceType = z.infer<typeof insertServiceTypeSchema>;
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 Site = typeof sites.$inferSelect;

View File

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