From b81f1253acb01d596149a23064879c859abfa94e Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Thu, 16 Oct 2025 17:56:46 +0000 Subject: [PATCH] Add system parameters configuration for contract rules Introduce a new section in the application for managing contract parameters. This includes backend API endpoints for fetching and updating contract parameters, schema definitions for these parameters, and a frontend page to display and edit them. Admins and coordinators can now configure various aspects of contract rules and shift planning. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/Z1LDqzu --- .replit | 4 + client/src/App.tsx | 2 + client/src/components/app-sidebar.tsx | 6 + client/src/pages/parameters.tsx | 370 ++++++++++++++++++++++++++ server/routes.ts | 60 +++++ server/storage.ts | 28 ++ shared/schema.ts | 6 + 7 files changed, 476 insertions(+) create mode 100644 client/src/pages/parameters.tsx diff --git a/.replit b/.replit index 048618b..b248541 100644 --- a/.replit +++ b/.replit @@ -15,6 +15,10 @@ run = ["npm", "run", "start"] localPort = 5000 externalPort = 80 +[[ports]] +localPort = 32847 +externalPort = 3003 + [[ports]] localPort = 33035 externalPort = 3001 diff --git a/client/src/App.tsx b/client/src/App.tsx index 36dad9f..bf62c07 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,6 +19,7 @@ import Notifications from "@/pages/notifications"; import Users from "@/pages/users"; import Planning from "@/pages/planning"; import Vehicles from "@/pages/vehicles"; +import Parameters from "@/pages/parameters"; function Router() { const { isAuthenticated, isLoading } = useAuth(); @@ -39,6 +40,7 @@ function Router() { + )} diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index c68ae43..a258eaa 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -84,6 +84,12 @@ const menuItems = [ icon: UserCog, roles: ["admin"], }, + { + title: "Parametri", + url: "/parameters", + icon: Settings, + roles: ["admin", "coordinator"], + }, ]; export function AppSidebar() { diff --git a/client/src/pages/parameters.tsx b/client/src/pages/parameters.tsx new file mode 100644 index 0000000..dc0ab3f --- /dev/null +++ b/client/src/pages/parameters.tsx @@ -0,0 +1,370 @@ +import { useState, useEffect } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { queryClient } from "@/lib/queryClient"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/hooks/use-toast"; +import { Loader2, Save, Settings } from "lucide-react"; +import type { ContractParameters } from "@shared/schema"; + +export default function Parameters() { + const { toast } = useToast(); + const [isEditing, setIsEditing] = useState(false); + + const { data: parameters, isLoading } = useQuery({ + queryKey: ["/api/contract-parameters"], + }); + + const [formData, setFormData] = useState>({}); + + // Sync formData with parameters when they load + useEffect(() => { + if (parameters && !isEditing) { + setFormData(parameters); + } + }, [parameters, isEditing]); + + const updateMutation = useMutation({ + mutationFn: async (data: Partial) => { + if (!parameters?.id) throw new Error("No parameters ID"); + const response = await fetch(`/api/contract-parameters/${parameters.id}`, { + method: "PUT", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + if (!response.ok) throw new Error("Failed to update parameters"); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/contract-parameters"] }); + toast({ + title: "Parametri aggiornati", + description: "I parametri CCNL sono stati aggiornati con successo.", + }); + setIsEditing(false); + }, + onError: (error: Error) => { + toast({ + title: "Errore", + description: error.message || "Impossibile aggiornare i parametri", + variant: "destructive", + }); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // Validate all numeric fields are present and valid + const requiredNumericFields = [ + 'maxHoursPerDay', 'maxOvertimePerDay', 'maxHoursPerWeek', 'maxOvertimePerWeek', + 'minDailyRestHours', 'minDailyRestHoursReduced', 'maxDailyRestReductionsPerMonth', + 'maxDailyRestReductionsPerYear', 'minWeeklyRestHours', 'pauseMinutesIfOver6Hours' + ]; + + for (const field of requiredNumericFields) { + const value = (formData as any)[field]; + if (value === undefined || value === null || isNaN(value)) { + toast({ + title: "Errore Validazione", + description: `Il campo ${field} deve essere un numero valido`, + variant: "destructive", + }); + return; + } + } + + updateMutation.mutate(formData); + }; + + const handleCancel = () => { + setFormData(parameters || {}); + setIsEditing(false); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!parameters) { + return ( +
+

Nessun parametro configurato

+
+ ); + } + + return ( +
+
+
+

+ + Parametri Sistema +

+

+ Configurazione limiti CCNL e regole turni +

+
+ + {!isEditing ? ( + + ) : ( +
+ + +
+ )} +
+ +
+ {/* Limiti Orari */} + + + Limiti Orari + Orari massimi giornalieri e settimanali secondo CCNL + + +
+ + setFormData({ ...formData, maxHoursPerDay: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-max-hours-per-day" + /> +
+ +
+ + setFormData({ ...formData, maxOvertimePerDay: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-max-overtime-per-day" + /> +
+ +
+ + setFormData({ ...formData, maxHoursPerWeek: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-max-hours-per-week" + /> +
+ +
+ + setFormData({ ...formData, maxOvertimePerWeek: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-max-overtime-per-week" + /> +
+ +
+ + setFormData({ ...formData, maxNightHoursPerWeek: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-max-night-hours-per-week" + /> +
+
+
+ + {/* Riposi Obbligatori */} + + + Riposi Obbligatori + Riposi minimi giornalieri e settimanali secondo CCNL + + +
+ + setFormData({ ...formData, minDailyRestHours: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-min-daily-rest-hours" + /> +
+ +
+ + setFormData({ ...formData, minDailyRestHoursReduced: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-min-daily-rest-hours-reduced" + /> +

Deroga CCNL - max 12 volte/anno

+
+ +
+ + setFormData({ ...formData, maxDailyRestReductionsPerMonth: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-max-daily-rest-reductions-per-month" + /> +
+ +
+ + setFormData({ ...formData, maxDailyRestReductionsPerYear: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-max-daily-rest-reductions-per-year" + /> +
+ +
+ + setFormData({ ...formData, minWeeklyRestHours: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-min-weekly-rest-hours" + /> +
+ +
+ + setFormData({ ...formData, pauseMinutesIfOver6Hours: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-pause-minutes-if-over-6-hours" + /> +
+
+
+ + {/* Maggiorazioni */} + + + Maggiorazioni Retributive + Percentuali maggiorazione per festivi, notturni e straordinari + + +
+ + setFormData({ ...formData, holidayPayIncrease: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-holiday-pay-increase" + /> +
+ +
+ + setFormData({ ...formData, nightPayIncrease: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-night-pay-increase" + /> +
+ +
+ + setFormData({ ...formData, overtimePayIncrease: parseInt(e.target.value) })} + disabled={!isEditing} + data-testid="input-overtime-pay-increase" + /> +
+
+
+ + {/* Tipo Contratto */} + + + Tipo Contratto + Identificatore CCNL di riferimento + + +
+ + setFormData({ ...formData, contractType: e.target.value })} + disabled={!isEditing} + data-testid="input-contract-type" + /> +
+
+
+
+
+ ); +} diff --git a/server/routes.ts b/server/routes.ts index 4585eb7..fa2ff9a 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -347,6 +347,66 @@ export async function registerRoutes(app: Express): Promise { } }); + // ============= CONTRACT PARAMETERS ROUTES ============= + app.get("/api/contract-parameters", isAuthenticated, async (req: any, res) => { + try { + const currentUserId = getUserId(req); + const currentUser = await storage.getUser(currentUserId); + + // Only admins and coordinators can view parameters + if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") { + return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" }); + } + + let params = await storage.getContractParameters(); + + // Se non esistono parametri, creali con valori di default CCNL + if (!params) { + params = await storage.createContractParameters({ + contractType: "CCNL_VIGILANZA_2024", + }); + } + + res.json(params); + } catch (error) { + console.error("Error fetching contract parameters:", error); + res.status(500).json({ message: "Failed to fetch contract parameters" }); + } + }); + + app.put("/api/contract-parameters/:id", isAuthenticated, async (req: any, res) => { + try { + const currentUserId = getUserId(req); + const currentUser = await storage.getUser(currentUserId); + + // Only admins can update parameters + if (currentUser?.role !== "admin") { + return res.status(403).json({ message: "Forbidden: Admin access required" }); + } + + // Validate request body with insert schema + const { insertContractParametersSchema } = await import("@shared/schema"); + const validationResult = insertContractParametersSchema.partial().safeParse(req.body); + + if (!validationResult.success) { + return res.status(400).json({ + message: "Invalid parameters data", + errors: validationResult.error.errors + }); + } + + const updated = await storage.updateContractParameters(req.params.id, validationResult.data); + if (!updated) { + return res.status(404).json({ message: "Contract parameters not found" }); + } + + res.json(updated); + } catch (error) { + console.error("Error updating contract parameters:", error); + res.status(500).json({ message: "Failed to update contract parameters" }); + } + }); + // ============= CERTIFICATION ROUTES ============= app.post("/api/certifications", isAuthenticated, async (req, res) => { try { diff --git a/server/storage.ts b/server/storage.ts index 6ac11f3..9a2d919 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -15,6 +15,7 @@ import { holidayAssignments, absences, absenceAffectedShifts, + contractParameters, type User, type UpsertUser, type Guard, @@ -45,6 +46,8 @@ import { type InsertAbsence, type AbsenceAffectedShift, type InsertAbsenceAffectedShift, + type ContractParameters, + type InsertContractParameters, } from "@shared/schema"; import { db } from "./db"; import { eq, and, gte, lte, desc } from "drizzle-orm"; @@ -126,6 +129,11 @@ export interface IStorage { getAffectedShiftsByAbsence(absenceId: string): Promise; createAbsenceAffectedShift(affected: InsertAbsenceAffectedShift): Promise; deleteAbsenceAffectedShift(id: string): Promise; + + // Contract Parameters operations + getContractParameters(): Promise; + createContractParameters(params: InsertContractParameters): Promise; + updateContractParameters(id: string, params: Partial): Promise; } export class DatabaseStorage implements IStorage { @@ -547,6 +555,26 @@ export class DatabaseStorage implements IStorage { async deleteAbsenceAffectedShift(id: string): Promise { await db.delete(absenceAffectedShifts).where(eq(absenceAffectedShifts.id, id)); } + + // Contract Parameters operations + async getContractParameters(): Promise { + const params = await db.select().from(contractParameters).limit(1); + return params[0]; + } + + async createContractParameters(params: InsertContractParameters): Promise { + const [newParams] = await db.insert(contractParameters).values(params).returning(); + return newParams; + } + + async updateContractParameters(id: string, params: Partial): Promise { + const [updated] = await db + .update(contractParameters) + .set(params) + .where(eq(contractParameters.id, id)) + .returning(); + return updated; + } } export const storage = new DatabaseStorage(); diff --git a/shared/schema.ts b/shared/schema.ts index 691092d..eba0016 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -292,8 +292,14 @@ export const contractParameters = pgTable("contract_parameters", { // Riposi obbligatori minDailyRestHours: integer("min_daily_rest_hours").notNull().default(11), + minDailyRestHoursReduced: integer("min_daily_rest_hours_reduced").notNull().default(9), // Deroga CCNL + maxDailyRestReductionsPerMonth: integer("max_daily_rest_reductions_per_month").notNull().default(3), + maxDailyRestReductionsPerYear: integer("max_daily_rest_reductions_per_year").notNull().default(12), minWeeklyRestHours: integer("min_weekly_rest_hours").notNull().default(24), + // Pause obbligatorie + pauseMinutesIfOver6Hours: integer("pause_minutes_if_over_6_hours").notNull().default(10), + // Limiti notturni (22:00-06:00) maxNightHoursPerWeek: integer("max_night_hours_per_week").default(48),