Compare commits

...

2 Commits

Author SHA1 Message Date
Marco Lanzara
278419c4ff 🚀 Release v1.0.13
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.13_20251017_101732.sql.gz
- Data: 2025-10-17 10:17:48
2025-10-17 10:17:48 +00:00
marco370
42a60fd32f Add CCNL settings management and validation for shift planning
Implement API endpoints for managing CCNL settings and introduce a rules engine for validating shifts against these settings, enhancing compliance with labor regulations.

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/EEOXc3D
2025-10-17 10:07:50 +00:00
9 changed files with 842 additions and 3 deletions

View File

@ -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. Unarea 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 dassegnazione (mezzo/agente → servizio/turno alla data).
5. Per gli **agenti**: lordinamento “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 (lundom) **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 lassegnazione 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 dellagente 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:0016: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:mmhh: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 lassegnazione è per uno slot predefinito quel giorno (es. 0816), 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:0006: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.

346
server/ccnlRules.ts Normal file
View File

@ -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<Record<string, any>> {
const settings = await storage.getAllCcnlSettings();
const result: Record<string, any> = {};
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<T = any>(key: string): Promise<T | undefined> {
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<CcnlValidationResult> {
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<any> {
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<number> {
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<number> {
// 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<Record<string, CcnlValidationResult>> {
const results: Record<string, CcnlValidationResult> = {};
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<string, number>;
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,
};
}

View File

@ -407,6 +407,133 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// ============= 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 ============= // ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => { app.post("/api/certifications", isAuthenticated, async (req, res) => {
try { try {

View File

@ -1,5 +1,5 @@
import { db } from "./db"; 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 { eq } from "drizzle-orm";
import bcrypt from "bcrypt"; 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 // Create CCNL contract parameters
console.log("📋 Creazione parametri contrattuali CCNL..."); console.log("📋 Creazione parametri contrattuali CCNL...");
const existingParams = await db.select().from(contractParameters).limit(1); const existingParams = await db.select().from(contractParameters).limit(1);

View File

@ -17,6 +17,7 @@ import {
absenceAffectedShifts, absenceAffectedShifts,
contractParameters, contractParameters,
serviceTypes, serviceTypes,
ccnlSettings,
type User, type User,
type UpsertUser, type UpsertUser,
type Guard, type Guard,
@ -51,6 +52,8 @@ import {
type InsertContractParameters, type InsertContractParameters,
type ServiceType, type ServiceType,
type InsertServiceType, type InsertServiceType,
type CcnlSetting,
type InsertCcnlSetting,
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, and, gte, lte, desc } from "drizzle-orm"; import { eq, and, gte, lte, desc } from "drizzle-orm";
@ -144,6 +147,12 @@ export interface IStorage {
getContractParameters(): Promise<ContractParameters | undefined>; getContractParameters(): Promise<ContractParameters | undefined>;
createContractParameters(params: InsertContractParameters): Promise<ContractParameters>; createContractParameters(params: InsertContractParameters): Promise<ContractParameters>;
updateContractParameters(id: string, params: Partial<InsertContractParameters>): Promise<ContractParameters | undefined>; updateContractParameters(id: string, params: Partial<InsertContractParameters>): Promise<ContractParameters | undefined>;
// CCNL Settings operations
getAllCcnlSettings(): Promise<CcnlSetting[]>;
getCcnlSetting(key: string): Promise<CcnlSetting | undefined>;
upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting>;
deleteCcnlSetting(key: string): Promise<void>;
} }
export class DatabaseStorage implements IStorage { export class DatabaseStorage implements IStorage {
@ -614,6 +623,36 @@ export class DatabaseStorage implements IStorage {
.returning(); .returning();
return updated; return updated;
} }
// CCNL Settings operations
async getAllCcnlSettings(): Promise<CcnlSetting[]> {
return await db.select().from(ccnlSettings);
}
async getCcnlSetting(key: string): Promise<CcnlSetting | undefined> {
const [setting] = await db.select().from(ccnlSettings).where(eq(ccnlSettings.key, key));
return setting;
}
async upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting> {
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<void> {
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
}
} }
export const storage = new DatabaseStorage(); export const storage = new DatabaseStorage();

View File

@ -222,6 +222,9 @@ export const shifts = pgTable("shifts", {
endTime: timestamp("end_time").notNull(), endTime: timestamp("end_time").notNull(),
status: shiftStatusEnum("status").notNull().default("planned"), status: shiftStatusEnum("status").notNull().default("planned"),
// Veicolo assegnato al turno (opzionale)
vehicleId: varchar("vehicle_id").references(() => vehicles.id, { onDelete: "set null" }),
notes: text("notes"), notes: text("notes"),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
@ -240,6 +243,17 @@ export const shiftAssignments = pgTable("shift_assignments", {
checkOutTime: timestamp("check_out_time"), 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 ============= // ============= NOTIFICATIONS =============
export const notifications = pgTable("notifications", { export const notifications = pgTable("notifications", {
@ -484,6 +498,10 @@ export const shiftsRelations = relations(shifts, ({ one, many }) => ({
fields: [shifts.siteId], fields: [shifts.siteId],
references: [sites.id], references: [sites.id],
}), }),
vehicle: one(vehicles, {
fields: [shifts.vehicleId],
references: [vehicles.id],
}),
assignments: many(shiftAssignments), assignments: many(shiftAssignments),
})); }));
@ -658,6 +676,12 @@ export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments).
assignedAt: true, assignedAt: true,
}); });
export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertNotificationSchema = createInsertSchema(notifications).omit({ export const insertNotificationSchema = createInsertSchema(notifications).omit({
id: true, id: true,
createdAt: true, createdAt: true,
@ -731,6 +755,9 @@ export type Shift = typeof shifts.$inferSelect;
export type InsertShiftAssignment = z.infer<typeof insertShiftAssignmentSchema>; export type InsertShiftAssignment = z.infer<typeof insertShiftAssignmentSchema>;
export type ShiftAssignment = typeof shiftAssignments.$inferSelect; export type ShiftAssignment = typeof shiftAssignments.$inferSelect;
export type InsertCcnlSetting = z.infer<typeof insertCcnlSettingSchema>;
export type CcnlSetting = typeof ccnlSettings.$inferSelect;
export type InsertNotification = z.infer<typeof insertNotificationSchema>; export type InsertNotification = z.infer<typeof insertNotificationSchema>;
export type Notification = typeof notifications.$inferSelect; export type Notification = typeof notifications.$inferSelect;

View File

@ -1,7 +1,13 @@
{ {
"version": "1.0.12", "version": "1.0.13",
"lastUpdate": "2025-10-17T09:47:18.311Z", "lastUpdate": "2025-10-17T10:17:48.446Z",
"changelog": [ "changelog": [
{
"version": "1.0.13",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.13"
},
{ {
"version": "1.0.12", "version": "1.0.12",
"date": "2025-10-17", "date": "2025-10-17",