Compare commits
2 Commits
918c5f0226
...
278419c4ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
278419c4ff | ||
|
|
42a60fd32f |
@ -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.
|
||||||
BIN
database-backups/vigilanzaturni_v1.0.13_20251017_101732.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.13_20251017_101732.sql.gz
Normal file
Binary file not shown.
Binary file not shown.
346
server/ccnlRules.ts
Normal file
346
server/ccnlRules.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
127
server/routes.ts
127
server/routes.ts
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
10
version.json
10
version.json
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user