Compare commits
No commits in common. "1598eb208bdc9c70174c3d99328c2b71e057dfcc" and "d8a6ec9c493288672464446d87ba5f26239b7e69" have entirely different histories.
1598eb208b
...
d8a6ec9c49
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
@ -56,7 +56,7 @@ const menuItems = [
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Planning Fissi",
|
||||
title: "Planning Generale",
|
||||
url: "/general-planning",
|
||||
icon: BarChart3,
|
||||
roles: ["admin", "coordinator"],
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { format, startOfWeek, addWeeks } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
@ -19,16 +19,6 @@ 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";
|
||||
@ -54,9 +44,6 @@ interface SiteData {
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
serviceType: string;
|
||||
serviceStartTime: string;
|
||||
serviceEndTime: string;
|
||||
serviceHours: number;
|
||||
minGuards: number;
|
||||
guards: GuardWithHours[];
|
||||
vehicles: Vehicle[];
|
||||
@ -116,7 +103,6 @@ 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>({
|
||||
@ -175,28 +161,14 @@ 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: 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"] });
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
|
||||
|
||||
toast({
|
||||
title: "Guardia rimossa",
|
||||
@ -214,7 +186,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; 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);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@ -234,33 +206,19 @@ 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, variables) => {
|
||||
onError: (error: any) => {
|
||||
// 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[2]);
|
||||
const parsed = JSON.parse(match[1]);
|
||||
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[2];
|
||||
errorMessage = match[1];
|
||||
}
|
||||
} else {
|
||||
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>
|
||||
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
|
||||
Planning Fissi
|
||||
Planning Generale
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Vista settimanale turni con calcolo automatico guardie mancanti
|
||||
@ -817,14 +775,14 @@ export default function GeneralPlanning() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Guardie Già Assegnate ({currentCellData.guards.length})
|
||||
Guardie Già Assegnate ({selectedCell.data.guards.length})
|
||||
</h3>
|
||||
<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 className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -862,61 +820,43 @@ export default function GeneralPlanning() {
|
||||
|
||||
{/* Info turni esistenti */}
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
)}
|
||||
<h3 className="font-semibold text-sm">Situazione Attuale</h3>
|
||||
|
||||
<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">{currentCellData?.shiftsCount || 0}</p>
|
||||
<p className="text-2xl font-bold">{selectedCell.data.shiftsCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Ore Assegnate</p>
|
||||
<p className="text-2xl font-bold">{currentCellData?.totalShiftHours || 0}h</p>
|
||||
<p className="text-sm text-muted-foreground">Ore Totali</p>
|
||||
<p className="text-2xl font-bold">{selectedCell.data.totalShiftHours}h</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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="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">{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)
|
||||
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)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Veicoli */}
|
||||
{currentCellData && currentCellData.vehicles.length > 0 && (
|
||||
{selectedCell.data.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 ({currentCellData.vehicles.length})
|
||||
Veicoli Assegnati ({selectedCell.data.vehicles.length})
|
||||
</div>
|
||||
<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">
|
||||
<p className="font-medium">{vehicle.licensePlate}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@ -951,43 +891,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
database-backups/vigilanzaturni_v1.0.24_20251018_102519.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.24_20251018_102519.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
@ -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 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`)
|
||||
- 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)
|
||||
|
||||
142
server/routes.ts
142
server/routes.ts
@ -4,11 +4,9 @@ import { storage } from "./storage";
|
||||
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
||||
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
||||
import { db } from "./db";
|
||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema } 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 { 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;
|
||||
@ -1028,41 +1026,37 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const maxOreGuardia = 9; // Max ore per guardia
|
||||
const minGuardie = site.minGuards || 1;
|
||||
|
||||
// Calcola ore servizio del sito (per calcolo corretto anche senza turni)
|
||||
const serviceStart = site.serviceStartTime || "00:00";
|
||||
const serviceEnd = site.serviceEndTime || "23:59";
|
||||
const [startH, startM] = serviceStart.split(":").map(Number);
|
||||
const [endH, endM] = serviceEnd.split(":").map(Number);
|
||||
let serviceHours = (endH + endM/60) - (startH + startM/60);
|
||||
if (serviceHours <= 0) serviceHours += 24; // Servizio notturno (es. 22:00-06:00)
|
||||
|
||||
// Somma ore totali dei turni del giorno (se esistono)
|
||||
// Somma ore totali dei turni del giorno
|
||||
const totalShiftHours = dayShifts.reduce((sum: number, ds: any) => {
|
||||
const start = new Date(ds.shift.startTime);
|
||||
const end = new Date(ds.shift.endTime);
|
||||
return sum + differenceInHours(end, start);
|
||||
}, 0);
|
||||
|
||||
// Usa ore servizio o ore turni (se già creati)
|
||||
const effectiveHours = totalShiftHours > 0 ? totalShiftHours : serviceHours;
|
||||
|
||||
// Guardie uniche assegnate (conta ogni guardia una volta anche se ha più turni)
|
||||
const uniqueGuardsAssigned = new Set(guardsWithHours.map((g: any) => g.guardId)).size;
|
||||
|
||||
// Calcolo guardie necessarie basato su ore servizio
|
||||
// Slot necessari per coprire le ore (ogni guardia max 9h)
|
||||
const slotsNeeded = Math.ceil(effectiveHours / maxOreGuardia);
|
||||
// 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)
|
||||
const totalGuardsNeeded = slotsNeeded * minGuardie;
|
||||
const missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned);
|
||||
totalGuardsNeeded = slotsNeeded * minGuardie;
|
||||
missingGuards = Math.max(0, totalGuardsNeeded - uniqueGuardsAssigned);
|
||||
} else {
|
||||
// Se NON ci sono turni: serve almeno la copertura minima
|
||||
totalGuardsNeeded = minGuardie;
|
||||
missingGuards = minGuardie; // Tutte mancanti perché non ci sono turni
|
||||
}
|
||||
|
||||
return {
|
||||
siteId: site.id,
|
||||
siteName: site.name,
|
||||
serviceType: serviceType?.label || "N/A",
|
||||
serviceStartTime: serviceStart,
|
||||
serviceEndTime: serviceEnd,
|
||||
serviceHours: Math.round(serviceHours * 10) / 10, // Arrotonda a 1 decimale
|
||||
minGuards: site.minGuards,
|
||||
guards: guardsWithHours,
|
||||
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)
|
||||
app.post("/api/general-planning/assign-guard", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId, force = false } = req.body;
|
||||
const { siteId, date, guardId, startTime, durationHours, consecutiveDays = 1, vehicleId } = req.body;
|
||||
|
||||
if (!siteId || !date || !guardId || !startTime || !durationHours) {
|
||||
return res.status(400).json({
|
||||
@ -1293,8 +1287,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const actualMonth = baseDate.getMonth();
|
||||
const actualDay = baseDate.getDate();
|
||||
|
||||
// Build dates in LOCAL timezone to match user's selection
|
||||
const shiftDate = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0);
|
||||
// Build dates in UTC to avoid timezone shifts
|
||||
const shiftDate = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0));
|
||||
|
||||
// Check contract validity for this date
|
||||
if (site.contractStartDate && site.contractEndDate) {
|
||||
@ -1307,13 +1301,13 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Calculate planned start and end times in UTC
|
||||
const plannedStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours, minutes, 0, 0));
|
||||
const plannedEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, hours + durationHours, minutes, 0, 0));
|
||||
|
||||
// Find or create shift for this site/date (full day boundaries in LOCAL timezone)
|
||||
const dayStart = new Date(actualYear, actualMonth, actualDay, 0, 0, 0, 0);
|
||||
const dayEnd = new Date(actualYear, actualMonth, actualDay, 23, 59, 59, 999);
|
||||
// Find or create shift for this site/date (full day boundaries in UTC)
|
||||
const dayStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, 0, 0, 0, 0));
|
||||
const dayEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, 23, 59, 59, 999));
|
||||
|
||||
let existingShifts = await tx
|
||||
.select()
|
||||
@ -1337,12 +1331,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(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0);
|
||||
let shiftEnd = new Date(actualYear, actualMonth, actualDay, endHour, endMin, 0, 0);
|
||||
const shiftStart = new Date(Date.UTC(actualYear, actualMonth, actualDay, startHour, startMin, 0, 0));
|
||||
let shiftEnd = new Date(Date.UTC(actualYear, actualMonth, actualDay, endHour, endMin, 0, 0));
|
||||
|
||||
// If end time is before/equal to start time, shift extends to next day
|
||||
if (shiftEnd <= shiftStart) {
|
||||
shiftEnd = new Date(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({
|
||||
@ -1374,8 +1368,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
}
|
||||
|
||||
// CCNL: Check daily hour limit (max 9h/day) - skip if force=true
|
||||
if (!force) {
|
||||
// CCNL: Check daily hour limit (max 9h/day)
|
||||
const maxDailyHours = 9;
|
||||
let dailyHoursAlreadyAssigned = 0;
|
||||
|
||||
@ -1397,13 +1390,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
|
||||
// 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).`
|
||||
`Aggiungendo ${durationHours}h si supererebbero le ${maxDailyHours}h massime giornaliere (CCNL).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create assignment for this day
|
||||
const [assignment] = await tx.insert(shiftAssignments).values({
|
||||
@ -1431,14 +1422,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
if (errorMessage.includes('overlap') ||
|
||||
errorMessage.includes('conflict') ||
|
||||
errorMessage.includes('conflitto') ||
|
||||
errorMessage.includes('già assegnata') ||
|
||||
errorMessage.includes('limite giornaliero') ||
|
||||
errorMessage.includes('limite settimanale') ||
|
||||
errorMessage.includes('ccnl')) {
|
||||
return res.status(409).json({
|
||||
message: error.message,
|
||||
type: errorMessage.includes('limite') ? 'CCNL_VIOLATION' : 'CONFLICT'
|
||||
});
|
||||
errorMessage.includes('già assegnata')) {
|
||||
return res.status(409).json({ message: error.message });
|
||||
}
|
||||
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 =============
|
||||
app.get("/api/sites", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -4,7 +4,6 @@ import {
|
||||
guards,
|
||||
certifications,
|
||||
vehicles,
|
||||
customers,
|
||||
sites,
|
||||
shifts,
|
||||
shiftAssignments,
|
||||
@ -27,8 +26,6 @@ import {
|
||||
type InsertCertification,
|
||||
type Vehicle,
|
||||
type InsertVehicle,
|
||||
type Customer,
|
||||
type InsertCustomer,
|
||||
type Site,
|
||||
type InsertSite,
|
||||
type Shift,
|
||||
@ -88,13 +85,6 @@ 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>;
|
||||
@ -352,35 +342,6 @@ 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);
|
||||
|
||||
@ -200,35 +200,13 @@ 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(),
|
||||
customerId: varchar("customer_id").references(() => customers.id),
|
||||
clientId: varchar("client_id").references(() => users.id),
|
||||
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale
|
||||
|
||||
// Service requirements
|
||||
@ -500,6 +478,7 @@ export const usersRelations = relations(users, ({ one, many }) => ({
|
||||
fields: [users.id],
|
||||
references: [guards.userId],
|
||||
}),
|
||||
managedSites: many(sites),
|
||||
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 }) => ({
|
||||
customer: one(customers, {
|
||||
fields: [sites.customerId],
|
||||
references: [customers.id],
|
||||
client: one(users, {
|
||||
fields: [sites.clientId],
|
||||
references: [users.id],
|
||||
}),
|
||||
shifts: many(shifts),
|
||||
preferences: many(sitePreferences),
|
||||
@ -705,12 +680,6 @@ 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,
|
||||
@ -825,9 +794,6 @@ 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;
|
||||
|
||||
|
||||
10
version.json
10
version.json
@ -1,13 +1,7 @@
|
||||
{
|
||||
"version": "1.0.34",
|
||||
"lastUpdate": "2025-10-23T08:03:06.051Z",
|
||||
"version": "1.0.33",
|
||||
"lastUpdate": "2025-10-22T08:52:21.600Z",
|
||||
"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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user