diff --git a/attached_assets/Pasted-Perfetto-Marco-Ti-preparo-un-task-brief-chiaro-e-operativo-da-dare-a-Replit-o-ad-un-dev-cos-i-1760694893657_1760694893657.txt b/attached_assets/Pasted-Perfetto-Marco-Ti-preparo-un-task-brief-chiaro-e-operativo-da-dare-a-Replit-o-ad-un-dev-cos-i-1760694893657_1760694893657.txt new file mode 100644 index 0000000..fa4f12e --- /dev/null +++ b/attached_assets/Pasted-Perfetto-Marco-Ti-preparo-un-task-brief-chiaro-e-operativo-da-dare-a-Replit-o-ad-un-dev-cos-i-1760694893657_1760694893657.txt @@ -0,0 +1,271 @@ +Perfetto, Marco. Ti preparo un “task brief” chiaro e operativo da dare a Replit (o ad un dev) così implementano senza ambiguità. Puoi incollarlo così com’è. + +--- + +# Task: Vista “Pianificazione Operativa” con disponibilità per **data** + +## Obiettivo + +Realizzare nella pagina **Pianificazione Operativa**: + +1. Un **selettore data** che filtra e calcola **disponibilità** di automezzi e agenti **alla data selezionata**. +2. Un’area centrale **divisa in due colonne**: + + * **Sinistra**: automezzi (ordinati per disponibilità). + * **Destra**: agenti (ordinati per disponibilità + priorità CCNL). +3. Ogni card (mezzo/agente) mostra **stato**: “Disponibile” (in alto) oppure “Non disponibile: assegnato a Turno #ID/Servizio”. +4. **Pulsante “Assegna servizio”** su ciascuna card, che apre il flusso d’assegnazione (mezzo/agente → servizio/turno alla data). +5. Per gli **agenti**: l’ordinamento “disponibili” deve privilegiare chi ha **meno ore già programmate** nel periodo di riferimento e **rispetta i vincoli CCNL**. + +--- + +## Definizioni rapide + +* **Data di riferimento D**: la data impostata dal selettore (default = oggi). +* **Periodo contabile**: ISO week della data D (lun–dom) **configurabile** (`settings.ccnl.reference_period = 'week'|'month'`); default = settimana corrente. +* **Turno**: intervallo con inizio/fine, collegato a un servizio e a risorse (agenti/mezzi). +* **Disponibilità**: + + * Mezzo disponibile se **non assegnato** in alcun turno che **intersechi D** e non in manutenzione/fermo. + * Agente disponibile se **non in turno** in D e se l’assegnazione proposta **rispetta i vincoli CCNL** (vedi sotto). + +--- + +## Vincoli CCNL (parametrizzabili) + +Creare un modulo `ccnlRules` con parametri in tabella/config: + +``` +max_hours_per_week: 48 +max_hours_per_month: 190 +min_rest_hours_between_shifts: 11 +max_night_shifts_consecutive: 6 +max_days_consecutive: 6 +min_weekly_rest_hours: 24 +overtime_threshold_week: 40 +penalty_if_overtime: true +``` + +> NB: i valori sono **segnaposto**; leggere da `settings_ccnl` per cliente. Se non presenti, usare default. + +**Funzione chiave**: `ccnlRules.isEligible(agentId, proposedShift, dateD) -> {eligible: boolean, reasons: string[]}` +Controlli minimi: + +* Riposo minimo tra fine ultimo turno e inizio `proposedShift.start` ≥ `min_rest_hours_between_shifts`. +* Ore accumulate nel periodo di riferimento + ore del turno proposto ≤ limiti (`max_hours_per_week`/`month`). +* Vincoli su notturno/consecutività/giorni consecutivi/rest settimanale (se dati). + +--- + +## Ordinamento + +### Automezzi (colonna sinistra) + +1. **Disponibili** in alto, poi **Non disponibili**. +2. Tra i disponibili: opzionale secondario per **priorità mezzo** (es. min km, categoria, stato batteria/serbatoio se disponibile). +3. Tra i non disponibili: ordina per **ora di fine del turno corrente** (prima quelli che si liberano prima). + +### Agenti (colonna destra) + +1. **Disponibili & CCNL-eligible** in alto. +2. Ordinare i disponibili per: + + * **OreTotaliPeriodo (crescente)** = somma ore dei turni dell’agente nel periodo di riferimento della data D. + * In caso di pari ore: meno straordinari > più straordinari; poi alfabetico. +3. Poi **Non disponibili** con motivazione (es. “Assegnato a Turno #123 08:00–16:00”). + +--- + +## UI/UX (wireframe funzionale) + +* Header: `DatePicker [D]` + (opz) `Periodo: settimana/mese` (solo admin). +* Body: **Grid 2 colonne responsive** (>= md: 2 colonne; < md: stack). + + * **Colonna Sinistra – Automezzi** + + * Card Mezzo: `targa | modello | stato` + + * Badge: `Disponibile` / `Non disponibile – Turno #ID hh:mm–hh:mm (Servizio)` + * (se non disponibile) small link “vedi turno/servizio” + * CTA: **Assegna servizio** + * **Colonna Destra – Agenti** + + * Card Agente: `cognome nome | qualifica` + + * Badge: `Disponibile` / `Non disponibile – Turno #ID` + * Righe info: `Ore settimana: Xh`, `Straordinari: Yh`, eventuali warning CCNL (tooltip) + * CTA: **Assegna servizio** +* Click “Assegna servizio” → apre **modal**: + + * Se chiamato da un **mezzo**: pre-seleziona mezzo, mostra servizi/turni disponibili in D, scegli agente. + * Se chiamato da un **agente**: pre-seleziona agente, mostra servizi/turni disponibili in D, scegli mezzo. + * **Validazione live CCNL**: mostra `✅ Conforme` o `❌ Motivi`. + +--- + +## Modello dati (minimo) + +```ts +// Tabella veicoli +Vehicle { id, plate, model, status: 'active'|'maintenance'|'out', priority?: number } + +// Tabella agenti +Agent { id, first_name, last_name, qualification, enabled: boolean } + +// Turni (calendar) +Shift { + id, service_id, start_at: datetime, end_at: datetime, + assigned_agent_id?: id, assigned_vehicle_id?: id +} + +// Servizi +Service { id, name, site, notes } + +// Parametri CCNL (per tenant/cliente) +SettingsCCNL { key, value } +``` + +--- + +## API (contratti) + +* `GET /planning?date=YYYY-MM-DD` + + * Ritorna: + + ```json + { + "date": "2025-10-17", + "vehicles": [ + { "id": 12, "plate": "AB123CD", "status": "available"|"busy", + "busy_shift": { "id": 77, "start_at": "...", "end_at": "...", "service_name": "..." } | null } + ], + "agents": [ + { "id": 9, "name": "Rossi Mario", + "status": "available"|"busy", + "ccnl_eligible": true|false, + "hours_period": 28.0, + "overtime_hours": 3.0, + "busy_shift": { ... } | null, + "ccnl_notes": ["min rest ok", "within weekly cap"] } + ] + } + ``` +* `POST /assign` + + * Body: + + ```json + { "date": "YYYY-MM-DD", "service_id": 10, "shift": {"start_at":"...","end_at":"..."}, "agent_id": 9, "vehicle_id": 12 } + ``` + * Validazioni: overlap, veicolo libero, `ccnlRules.isEligible(...)`. + * Risposte: `200 {ok:true, created_shift_id}` | `422 {ok:false, errors:[...]}` +* `GET /ccnl/preview?agent_id=&start_at=&end_at=` + + * Ritorna esito eleggibilità e motivi. + +--- + +## Logica (pseudocodice) + +```pseudo +function getPlanning(date D): + period = resolvePeriod(D, settings.ccnl.reference_period) + shiftsOnD = shifts.where(overlaps(shift, D)) + shiftsInPeriod = shifts.where(overlaps(shift, period)) + + vehicles = allVehicles.map(v): + s = find(shiftsOnD, assigned_vehicle_id == v.id) + if s exists or v.status == 'maintenance': + status = 'busy' + busy_shift = s + else status = 'available' + return { ... } + + agents = allAgents.enabledOnly().map(a): + s = find(shiftsOnD, assigned_agent_id == a.id) + hours = sumHours(shiftsInPeriod, assigned_agent_id == a.id) + if s exists: + status='busy'; eligible=false + else: + eligible = ccnlRules.isEligible(a.id, proposedShiftForD?, D).eligible + status='available' if eligible else 'available' (ma con ccnl_eligible=false) + overtime = max(0, hours - settings.ccnl.overtime_threshold_week) + return { id:a.id, name, status, ccnl_eligible:eligible, hours_period:hours, + overtime_hours:overtime, busy_shift:s, ccnl_notes:... } + + sort vehicles: available first, then by (busy_shift.end_at asc) + sort agents: + 1) available & ccnl_eligible first + 2) by hours_period asc + 3) by overtime_hours asc + 4) by last_name asc + + return { date:D, vehicles, agents } +``` + +> `proposedShiftForD?`: se l’assegnazione è per uno slot predefinito quel giorno (es. 08–16), passarlo per validazione live; se non c’è, `isEligible` valuta solo i vincoli “statici” (riposo/limiti alla data). + +--- + +## Casi limite da coprire (test) + +* Mezzo in **manutenzione** → sempre **Non disponibile** con label “Manutenzione”. +* Agente con turno che **sconfina** su D (es. notte 22:00–06:00) → è **Non disponibile** in D. +* Overlap di turni → `POST /assign` deve restituire `422` con errore “overlap”. +* CCNL: riposo < 11h tra fine turno precedente e inizio proposto → `eligible=false` + motivo. +* Periodo mensile vs settimanale: calcolo ore coerente al setting. +* Ordinamento agenti: a pari ore e overtime, ordinamento alfabetico stabile. +* Fuso orario: usare TZ server/tenant, no ambiguità su DST. + +--- + +## Accettazione (DoD) + +* Selettore data funziona; ricarica lista con calcoli corretti. +* Colonne mezzi/agentI con **badge stato** e **CTA “Assegna servizio”** operative. +* Ordinamenti rispettati come da regole. +* API `GET /planning` e `POST /assign` implementate con validazioni CCNL. +* Copertura test unit ≥ 80% per `ccnlRules` e funzioni di calcolo ore/overlap. +* Performance: risposta `GET /planning` ≤ 300 ms con dataset medio (500 agenti, 200 mezzi, 2k turni/sett). + +--- + +## Esempi (mock) + +### GET /planning?date=2025-10-17 + +```json +{ + "date": "2025-10-17", + "vehicles": [ + { "id": 12, "plate": "AB123CD", "status": "available", "busy_shift": null }, + { "id": 7, "plate": "ZZ987YY", "status": "busy", + "busy_shift": { "id": 331, "start_at":"2025-10-17T06:00:00Z","end_at":"2025-10-17T14:00:00Z","service_name":"Pattuglia 3"}} + ], + "agents": [ + { "id": 9, "name": "Rossi Mario", "status":"available", "ccnl_eligible": true, "hours_period": 24, "overtime_hours": 0, "busy_shift": null, "ccnl_notes": ["rest ok", "within weekly cap"] }, + { "id": 4, "name": "Bianchi Luca", "status":"available", "ccnl_eligible": false, "hours_period": 38, "overtime_hours": 0, "busy_shift": null, "ccnl_notes": ["rest < 11h"] }, + { "id": 2, "name": "Verdi Anna", "status":"busy", "ccnl_eligible": false, "hours_period": 32, "overtime_hours": 0, + "busy_shift": { "id": 329, "start_at":"2025-10-17T08:00:00Z","end_at":"2025-10-17T16:00:00Z","service_name":"Portierato Ospedale"}} + ] +} +``` + +--- + +## Note di implementazione + +* **Timezone**: salvare in UTC, presentare in TZ tenant. Usare libreria affidabile per overlap con turni notturni. +* **Configurazione CCNL** per cliente/ente: tabella `settings_ccnl(tenant_id, key, value)` + cache. +* **Sicurezza**: endpoint protetti (ruolo operatore/dispatcher). +* **Accessibilità**: badge con testo + icona, contrasto AA. +* **Telemetry**: loggare motivi di `ineligible` per audit. + +--- + +Se vuoi, alla prossima iterazione posso trasformare questo brief in: + +* scheletro **API (Node/Express + Prisma)**, +* componenti **React/Next** per la UI (grid 2 colonne, card, modal assegnazione), +* modulo `ccnlRules` completo con test unit. diff --git a/server/ccnlRules.ts b/server/ccnlRules.ts new file mode 100644 index 0000000..a67d74a --- /dev/null +++ b/server/ccnlRules.ts @@ -0,0 +1,346 @@ +import { storage } from "./storage"; +import { db } from "./db"; +import { shifts, shiftAssignments, guards } from "@shared/schema"; +import { eq, and, gte, lte, between, sql } from "drizzle-orm"; +import { differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, differenceInDays } from "date-fns"; + +export interface CcnlRule { + key: string; + value: string | number | boolean; + description?: string; +} + +export interface CcnlValidationResult { + isValid: boolean; + violations: CcnlViolation[]; + warnings: CcnlWarning[]; +} + +export interface CcnlViolation { + type: "max_hours_exceeded" | "insufficient_rest" | "consecutive_limit_exceeded" | "overtime_violation"; + severity: "error" | "warning"; + message: string; + details?: any; +} + +export interface CcnlWarning { + type: string; + message: string; + details?: any; +} + +/** + * Carica tutte le impostazioni CCNL dal database e le converte in un oggetto chiave-valore + */ +export async function loadCcnlSettings(): Promise> { + const settings = await storage.getAllCcnlSettings(); + const result: Record = {}; + + for (const setting of settings) { + // Converti i valori stringa nei tipi appropriati + if (setting.value === "true" || setting.value === "false") { + result[setting.key] = setting.value === "true"; + } else if (!isNaN(Number(setting.value))) { + result[setting.key] = Number(setting.value); + } else { + result[setting.key] = setting.value; + } + } + + return result; +} + +/** + * Ottiene un singolo valore CCNL con tipo convertito + */ +export async function getCcnlValue(key: string): Promise { + const setting = await storage.getCcnlSetting(key); + if (!setting) return undefined; + + if (setting.value === "true" || setting.value === "false") { + return (setting.value === "true") as T; + } else if (!isNaN(Number(setting.value))) { + return Number(setting.value) as T; + } else { + return setting.value as T; + } +} + +/** + * Valida se un turno rispetta le regole CCNL per un dato agente + */ +export async function validateShiftForGuard( + guardId: string, + shiftStartTime: Date, + shiftEndTime: Date, + excludeShiftId?: string +): Promise { + const violations: CcnlViolation[] = []; + const warnings: CcnlWarning[] = []; + + const ccnlSettings = await loadCcnlSettings(); + + // Calcola le ore del turno proposto (con precisione decimale) + const shiftHours = differenceInMinutes(shiftEndTime, shiftStartTime) / 60; + + // 1. Verifica ore minime di riposo tra turni + const minRestHours = ccnlSettings.min_rest_hours_between_shifts || 11; + const lastShift = await getLastShiftBeforeDate(guardId, shiftStartTime, excludeShiftId); + + if (lastShift) { + const restHours = differenceInMinutes(shiftStartTime, new Date(lastShift.endTime)) / 60; + if (restHours < minRestHours) { + violations.push({ + type: "insufficient_rest", + severity: "error", + message: `Riposo insufficiente: ${restHours.toFixed(1)} ore (minimo: ${minRestHours} ore)`, + details: { restHours, minRestHours, lastShiftEnd: lastShift.endTime } + }); + } + } + + // 2. Verifica ore settimanali massime + const maxHoursPerWeek = ccnlSettings.max_hours_per_week || 48; + const weekStart = startOfWeek(shiftStartTime, { weekStartsOn: 1 }); + const weekEnd = endOfWeek(shiftStartTime, { weekStartsOn: 1 }); + const weeklyHours = await getGuardHoursInPeriod(guardId, weekStart, weekEnd, excludeShiftId); + + if (weeklyHours + shiftHours > maxHoursPerWeek) { + violations.push({ + type: "max_hours_exceeded", + severity: "error", + message: `Superamento ore settimanali: ${weeklyHours + shiftHours} ore (massimo: ${maxHoursPerWeek} ore)`, + details: { currentWeekHours: weeklyHours, shiftHours, maxHoursPerWeek } + }); + } else if (weeklyHours + shiftHours > maxHoursPerWeek * 0.9) { + warnings.push({ + type: "approaching_weekly_limit", + message: `Vicino al limite settimanale: ${weeklyHours + shiftHours} ore su ${maxHoursPerWeek} ore`, + details: { currentWeekHours: weeklyHours, shiftHours, maxHoursPerWeek } + }); + } + + // 3. Verifica ore mensili massime + const maxHoursPerMonth = ccnlSettings.max_hours_per_month || 190; + const monthStart = startOfMonth(shiftStartTime); + const monthEnd = endOfMonth(shiftStartTime); + const monthlyHours = await getGuardHoursInPeriod(guardId, monthStart, monthEnd, excludeShiftId); + + if (monthlyHours + shiftHours > maxHoursPerMonth) { + violations.push({ + type: "max_hours_exceeded", + severity: "error", + message: `Superamento ore mensili: ${monthlyHours + shiftHours} ore (massimo: ${maxHoursPerMonth} ore)`, + details: { currentMonthHours: monthlyHours, shiftHours, maxHoursPerMonth } + }); + } else if (monthlyHours + shiftHours > maxHoursPerMonth * 0.9) { + warnings.push({ + type: "approaching_monthly_limit", + message: `Vicino al limite mensile: ${monthlyHours + shiftHours} ore su ${maxHoursPerMonth} ore`, + details: { currentMonthHours: monthlyHours, shiftHours, maxHoursPerMonth } + }); + } + + // 4. Verifica giorni consecutivi + const maxDaysConsecutive = ccnlSettings.max_days_consecutive || 6; + const consecutiveDays = await getConsecutiveDaysWorked(guardId, shiftStartTime, excludeShiftId); + + if (consecutiveDays >= maxDaysConsecutive) { + violations.push({ + type: "consecutive_limit_exceeded", + severity: "error", + message: `Superamento giorni consecutivi: ${consecutiveDays + 1} giorni (massimo: ${maxDaysConsecutive} giorni)`, + details: { consecutiveDays: consecutiveDays + 1, maxDaysConsecutive } + }); + } + + // 5. Verifica straordinario settimanale + const overtimeThreshold = ccnlSettings.overtime_threshold_week || 40; + if (weeklyHours + shiftHours > overtimeThreshold && ccnlSettings.penalty_if_overtime === true) { + const overtimeHours = (weeklyHours + shiftHours) - overtimeThreshold; + warnings.push({ + type: "overtime_warning", + message: `Straordinario settimanale: ${overtimeHours.toFixed(1)} ore oltre ${overtimeThreshold} ore`, + details: { overtimeHours, overtimeThreshold } + }); + } + + return { + isValid: violations.filter(v => v.severity === "error").length === 0, + violations, + warnings + }; +} + +/** + * Ottiene l'ultimo turno dell'agente prima di una certa data + */ +async function getLastShiftBeforeDate(guardId: string, beforeDate: Date, excludeShiftId?: string): Promise { + const query = db + .select() + .from(shifts) + .innerJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId)) + .where( + and( + eq(shiftAssignments.guardId, guardId), + lte(shifts.endTime, beforeDate), + excludeShiftId ? sql`${shifts.id} != ${excludeShiftId}` : sql`true` + ) + ) + .orderBy(sql`${shifts.endTime} DESC`) + .limit(1); + + const results = await query; + return results[0]?.shifts; +} + +/** + * Calcola le ore totali lavorate da un agente in un periodo + */ +async function getGuardHoursInPeriod( + guardId: string, + startDate: Date, + endDate: Date, + excludeShiftId?: string +): Promise { + const query = db + .select() + .from(shifts) + .innerJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId)) + .where( + and( + eq(shiftAssignments.guardId, guardId), + gte(shifts.startTime, startDate), + lte(shifts.endTime, endDate), + excludeShiftId ? sql`${shifts.id} != ${excludeShiftId}` : sql`true` + ) + ); + + const results = await query; + + let totalHours = 0; + for (const result of results) { + const shiftMinutes = differenceInMinutes( + new Date(result.shifts.endTime), + new Date(result.shifts.startTime) + ); + totalHours += shiftMinutes / 60; + } + + return totalHours; +} + +/** + * Conta i giorni consecutivi lavorati dall'agente fino a una certa data + */ +async function getConsecutiveDaysWorked( + guardId: string, + upToDate: Date, + excludeShiftId?: string +): Promise { + // Ottieni tutti i turni dell'agente negli ultimi 14 giorni + const twoWeeksAgo = new Date(upToDate); + twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); + + const query = db + .select() + .from(shifts) + .innerJoin(shiftAssignments, eq(shifts.id, shiftAssignments.shiftId)) + .where( + and( + eq(shiftAssignments.guardId, guardId), + gte(shifts.startTime, twoWeeksAgo), + lte(shifts.startTime, upToDate), + excludeShiftId ? sql`${shifts.id} != ${excludeShiftId}` : sql`true` + ) + ) + .orderBy(sql`${shifts.startTime} DESC`); + + const results = await query; + + if (results.length === 0) return 0; + + // Conta i giorni consecutivi partendo dalla data più recente + let consecutiveDays = 0; + let currentDate = new Date(upToDate); + currentDate.setHours(0, 0, 0, 0); + + const workedDates = new Set( + results.map((r: any) => { + const d = new Date(r.shifts.startTime); + d.setHours(0, 0, 0, 0); + return d.getTime(); + }) + ); + + while (workedDates.has(currentDate.getTime())) { + consecutiveDays++; + currentDate.setDate(currentDate.getDate() - 1); + } + + return consecutiveDays; +} + +/** + * Valida un batch di turni per più agenti + */ +export async function validateMultipleShifts( + assignments: Array<{ guardId: string; shiftStartTime: Date; shiftEndTime: Date }> +): Promise> { + const results: Record = {}; + + for (const assignment of assignments) { + results[assignment.guardId] = await validateShiftForGuard( + assignment.guardId, + assignment.shiftStartTime, + assignment.shiftEndTime + ); + } + + return results; +} + +/** + * Ottiene un report sulla disponibilità dell'agente in un periodo + */ +export async function getGuardAvailabilityReport( + guardId: string, + startDate: Date, + endDate: Date +): Promise<{ + totalHours: number; + weeklyHours: Record; + remainingWeeklyHours: number; + remainingMonthlyHours: number; + consecutiveDaysWorked: number; + lastShiftEnd?: Date; +}> { + const ccnlSettings = await loadCcnlSettings(); + const maxHoursPerWeek = ccnlSettings.max_hours_per_week || 48; + const maxHoursPerMonth = ccnlSettings.max_hours_per_month || 190; + + const totalHours = await getGuardHoursInPeriod(guardId, startDate, endDate); + + const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); + const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 }); + const currentWeekHours = await getGuardHoursInPeriod(guardId, weekStart, weekEnd); + + const monthStart = startOfMonth(new Date()); + const monthEnd = endOfMonth(new Date()); + const currentMonthHours = await getGuardHoursInPeriod(guardId, monthStart, monthEnd); + + const consecutiveDaysWorked = await getConsecutiveDaysWorked(guardId, new Date()); + + const lastShift = await getLastShiftBeforeDate(guardId, new Date()); + + return { + totalHours, + weeklyHours: { + current: currentWeekHours, + }, + remainingWeeklyHours: Math.max(0, maxHoursPerWeek - currentWeekHours), + remainingMonthlyHours: Math.max(0, maxHoursPerMonth - currentMonthHours), + consecutiveDaysWorked, + lastShiftEnd: lastShift ? new Date(lastShift.endTime) : undefined, + }; +} diff --git a/server/routes.ts b/server/routes.ts index 49ba748..6714962 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -407,6 +407,133 @@ export async function registerRoutes(app: Express): Promise { } }); + // ============= CCNL SETTINGS ROUTES ============= + app.get("/api/ccnl-settings", isAuthenticated, async (req: any, res) => { + try { + const currentUserId = getUserId(req); + const currentUser = await storage.getUser(currentUserId); + + // Only admins and coordinators can view CCNL settings + if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") { + return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" }); + } + + const settings = await storage.getAllCcnlSettings(); + res.json(settings); + } catch (error) { + console.error("Error fetching CCNL settings:", error); + res.status(500).json({ message: "Failed to fetch CCNL settings" }); + } + }); + + app.get("/api/ccnl-settings/:key", isAuthenticated, async (req: any, res) => { + try { + const currentUserId = getUserId(req); + const currentUser = await storage.getUser(currentUserId); + + // Only admins and coordinators can view CCNL settings + if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") { + return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" }); + } + + const setting = await storage.getCcnlSetting(req.params.key); + if (!setting) { + return res.status(404).json({ message: "CCNL setting not found" }); + } + res.json(setting); + } catch (error) { + console.error("Error fetching CCNL setting:", error); + res.status(500).json({ message: "Failed to fetch CCNL setting" }); + } + }); + + app.post("/api/ccnl-settings", isAuthenticated, async (req: any, res) => { + try { + const currentUserId = getUserId(req); + const currentUser = await storage.getUser(currentUserId); + + // Only admins can create/update CCNL settings + if (currentUser?.role !== "admin") { + return res.status(403).json({ message: "Forbidden: Admin access required" }); + } + + const { insertCcnlSettingSchema } = await import("@shared/schema"); + const validationResult = insertCcnlSettingSchema.safeParse(req.body); + + if (!validationResult.success) { + return res.status(400).json({ + message: "Invalid CCNL setting data", + errors: validationResult.error.errors + }); + } + + const setting = await storage.upsertCcnlSetting(validationResult.data); + res.json(setting); + } catch (error) { + console.error("Error creating/updating CCNL setting:", error); + res.status(500).json({ message: "Failed to create/update CCNL setting" }); + } + }); + + app.delete("/api/ccnl-settings/:key", isAuthenticated, async (req: any, res) => { + try { + const currentUserId = getUserId(req); + const currentUser = await storage.getUser(currentUserId); + + // Only admins can delete CCNL settings + if (currentUser?.role !== "admin") { + return res.status(403).json({ message: "Forbidden: Admin access required" }); + } + + await storage.deleteCcnlSetting(req.params.key); + res.json({ success: true }); + } catch (error) { + console.error("Error deleting CCNL setting:", error); + res.status(500).json({ message: "Failed to delete CCNL setting" }); + } + }); + + // ============= CCNL VALIDATION ROUTES ============= + app.post("/api/ccnl/validate-shift", isAuthenticated, async (req, res) => { + try { + const { validateShiftForGuard } = await import("./ccnlRules"); + const { guardId, shiftStartTime, shiftEndTime, excludeShiftId } = req.body; + + if (!guardId || !shiftStartTime || !shiftEndTime) { + return res.status(400).json({ + message: "Missing required fields: guardId, shiftStartTime, shiftEndTime" + }); + } + + const result = await validateShiftForGuard( + guardId, + new Date(shiftStartTime), + new Date(shiftEndTime), + excludeShiftId + ); + + res.json(result); + } catch (error) { + console.error("Error validating shift:", error); + res.status(500).json({ message: "Failed to validate shift" }); + } + }); + + app.get("/api/ccnl/guard-availability/:guardId", isAuthenticated, async (req, res) => { + try { + const { getGuardAvailabilityReport } = await import("./ccnlRules"); + const { guardId } = req.params; + const startDate = req.query.startDate ? new Date(req.query.startDate as string) : new Date(); + const endDate = req.query.endDate ? new Date(req.query.endDate as string) : new Date(); + + const report = await getGuardAvailabilityReport(guardId, startDate, endDate); + res.json(report); + } catch (error) { + console.error("Error fetching guard availability:", error); + res.status(500).json({ message: "Failed to fetch guard availability" }); + } + }); + // ============= CERTIFICATION ROUTES ============= app.post("/api/certifications", isAuthenticated, async (req, res) => { try { diff --git a/server/seed.ts b/server/seed.ts index a5c5e7d..e2faca1 100644 --- a/server/seed.ts +++ b/server/seed.ts @@ -1,5 +1,5 @@ import { db } from "./db"; -import { users, guards, sites, vehicles, contractParameters, serviceTypes } from "@shared/schema"; +import { users, guards, sites, vehicles, contractParameters, serviceTypes, ccnlSettings } from "@shared/schema"; import { eq } from "drizzle-orm"; import bcrypt from "bcrypt"; @@ -53,6 +53,29 @@ async function seed() { } } + // Create CCNL Settings + console.log("⚙️ Creazione impostazioni CCNL..."); + const defaultCcnlSettings = [ + { key: "max_hours_per_week", value: "48", description: "Massimo ore lavorative settimanali" }, + { key: "max_hours_per_month", value: "190", description: "Massimo ore lavorative mensili" }, + { key: "min_rest_hours_between_shifts", value: "11", description: "Ore minime di riposo tra turni" }, + { key: "max_night_shifts_consecutive", value: "6", description: "Massimo turni notturni consecutivi" }, + { key: "max_days_consecutive", value: "6", description: "Massimo giorni lavorativi consecutivi" }, + { key: "min_weekly_rest_hours", value: "24", description: "Ore minime di riposo settimanale" }, + { key: "overtime_threshold_week", value: "40", description: "Soglia ore per straordinario settimanale" }, + { key: "penalty_if_overtime", value: "true", description: "Penalità se supera straordinario" }, + ]; + + for (const setting of defaultCcnlSettings) { + const existing = await db.select().from(ccnlSettings).where(eq(ccnlSettings.key, setting.key)).limit(1); + if (existing.length === 0) { + await db.insert(ccnlSettings).values(setting); + console.log(` + Creata impostazione: ${setting.key}`); + } else { + console.log(` ✓ Impostazione esistente: ${setting.key}`); + } + } + // Create CCNL contract parameters console.log("📋 Creazione parametri contrattuali CCNL..."); const existingParams = await db.select().from(contractParameters).limit(1); diff --git a/server/storage.ts b/server/storage.ts index 2ba1d25..ce1f4fb 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -17,6 +17,7 @@ import { absenceAffectedShifts, contractParameters, serviceTypes, + ccnlSettings, type User, type UpsertUser, type Guard, @@ -51,6 +52,8 @@ import { type InsertContractParameters, type ServiceType, type InsertServiceType, + type CcnlSetting, + type InsertCcnlSetting, } from "@shared/schema"; import { db } from "./db"; import { eq, and, gte, lte, desc } from "drizzle-orm"; @@ -144,6 +147,12 @@ export interface IStorage { getContractParameters(): Promise; createContractParameters(params: InsertContractParameters): Promise; updateContractParameters(id: string, params: Partial): Promise; + + // CCNL Settings operations + getAllCcnlSettings(): Promise; + getCcnlSetting(key: string): Promise; + upsertCcnlSetting(setting: InsertCcnlSetting): Promise; + deleteCcnlSetting(key: string): Promise; } export class DatabaseStorage implements IStorage { @@ -614,6 +623,36 @@ export class DatabaseStorage implements IStorage { .returning(); return updated; } + + // CCNL Settings operations + async getAllCcnlSettings(): Promise { + return await db.select().from(ccnlSettings); + } + + async getCcnlSetting(key: string): Promise { + const [setting] = await db.select().from(ccnlSettings).where(eq(ccnlSettings.key, key)); + return setting; + } + + async upsertCcnlSetting(setting: InsertCcnlSetting): Promise { + const existing = await this.getCcnlSetting(setting.key); + + if (existing) { + const [updated] = await db + .update(ccnlSettings) + .set({ ...setting, updatedAt: new Date() }) + .where(eq(ccnlSettings.key, setting.key)) + .returning(); + return updated; + } else { + const [newSetting] = await db.insert(ccnlSettings).values(setting).returning(); + return newSetting; + } + } + + async deleteCcnlSetting(key: string): Promise { + await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key)); + } } export const storage = new DatabaseStorage(); diff --git a/shared/schema.ts b/shared/schema.ts index 15e3a47..e525250 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -222,6 +222,9 @@ export const shifts = pgTable("shifts", { endTime: timestamp("end_time").notNull(), status: shiftStatusEnum("status").notNull().default("planned"), + // Veicolo assegnato al turno (opzionale) + vehicleId: varchar("vehicle_id").references(() => vehicles.id, { onDelete: "set null" }), + notes: text("notes"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), @@ -240,6 +243,17 @@ export const shiftAssignments = pgTable("shift_assignments", { checkOutTime: timestamp("check_out_time"), }); +// ============= CCNL SETTINGS ============= + +export const ccnlSettings = pgTable("ccnl_settings", { + id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), + key: varchar("key").notNull().unique(), + value: varchar("value").notNull(), + description: text("description"), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + // ============= NOTIFICATIONS ============= export const notifications = pgTable("notifications", { @@ -484,6 +498,10 @@ export const shiftsRelations = relations(shifts, ({ one, many }) => ({ fields: [shifts.siteId], references: [sites.id], }), + vehicle: one(vehicles, { + fields: [shifts.vehicleId], + references: [vehicles.id], + }), assignments: many(shiftAssignments), })); @@ -658,6 +676,12 @@ export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments). assignedAt: true, }); +export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + export const insertNotificationSchema = createInsertSchema(notifications).omit({ id: true, createdAt: true, @@ -731,6 +755,9 @@ export type Shift = typeof shifts.$inferSelect; export type InsertShiftAssignment = z.infer; export type ShiftAssignment = typeof shiftAssignments.$inferSelect; +export type InsertCcnlSetting = z.infer; +export type CcnlSetting = typeof ccnlSettings.$inferSelect; + export type InsertNotification = z.infer; export type Notification = typeof notifications.$inferSelect;