initial commit
This commit is contained in:
33
.agent/rules/development-folders.md
Normal file
33
.agent/rules/development-folders.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
usa ./docs/development/devlog per tenere traccia di tutti i piani di lavoro e il loro attuale stato singolarmente, crea qui dentro i log delle lavorazioni ed il lavoro fatto, da fare e suggerito per ogni piano di sviluppo, usa il formato "yyyy-mm-dd-hh24miss_descrizione_brevissima".
|
||||
|
||||
usa ./docs/development per tenere un file DEVELOPMENT.md riassuntivo con link ai file specifici dentro ./docs/development/devlog e una breve sintesi specificando che tipo di sviluppo si è concluso o si sta lavorando.
|
||||
|
||||
usa ./src/backend per tutto quello che riguarda il backend in Node.js
|
||||
|
||||
usa ./src/frontend per tutto quello che riguarda il frontend in react
|
||||
|
||||
## Struttura Modulare del Progetto
|
||||
|
||||
Il progetto segue una rigorosa struttura modulare sia per il backend che per il frontend. Ogni nuova funzionalità o dominio di business deve essere incapsulato nel proprio modulo.
|
||||
|
||||
### Backend (Node.js)
|
||||
- **Moduli**: `src/backend/src/modules/[nome-modulo]/`
|
||||
- **Controllers**: `src/backend/src/modules/[nome-modulo]/controllers/`
|
||||
- I file devono essere nominati `[nome].controller.ts`.
|
||||
- Le rotte devono essere definite in `[nome-modulo].routes.ts` e seguire il pattern `api/[nome-modulo]/[risorsa]`.
|
||||
- **Entities/Models**: `src/backend/src/modules/[nome-modulo]/entities/`
|
||||
- Le entità devono essere definite usando l'ORM scelto (es. TypeORM/Prisma) e risiedere in questa cartella.
|
||||
- **Services**: `src/backend/src/modules/[nome-modulo]/services/`
|
||||
- La logica di business deve risiedere nei service, separata dai controller.
|
||||
|
||||
### Frontend (React)
|
||||
- **Moduli**: `src/frontend/src/modules/[nome-modulo]/`
|
||||
- **Pagine**: `src/frontend/src/modules/[nome-modulo]/pages/`
|
||||
- **Componenti**: `src/frontend/src/modules/[nome-modulo]/components/`
|
||||
- **Rotte**: `src/frontend/src/modules/[nome-modulo]/routes.tsx`
|
||||
- Il file `routes.tsx` deve esportare un componente che definisce le rotte figlie del modulo.
|
||||
- Le rotte devono essere importate e registrate nel router principale (es. `App.tsx`).
|
||||
37
.agent/rules/development-guide.md
Normal file
37
.agent/rules/development-guide.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Produci sempre prima il piano di implementazione nelle cartelle dedicate (`./docs/development/devlog`) e proponi di default all'utente di visionarlo. Se l'utente approva, prosegui con l'implementazione senza fermarti; aggiorna il piano man mano che viene completato.
|
||||
|
||||
### Filosofia UX/UI
|
||||
L'obiettivo primario è **sostituire Excel** riducendo il tempo di gestione manuale.
|
||||
- **Target Utente:** L'utente tipo (es. "Ilaria") deve gestire grandi moli di dati ripetitivi. Il software deve essere veloce, supportare l'inserimento rapido e fornire feedback immediati.
|
||||
- **Interfaccia:** Utilizza Material Design. Pulita, professionale e ad alto contrasto per la leggibilità delle tabelle (Scadenzario).
|
||||
- **Feedback:** Il salvataggio dei dati deve essere quanto più possibile automatico o "senza frizione". Se il backend restituisce un errore, questo deve essere notificato con un messaggio specifico e comprensibile, mai un generico "Errore".
|
||||
|
||||
### Stack Tecnologico
|
||||
- **Frontend:** React. Usa componenti standard e stabili.
|
||||
- **Backend:** Node.js.
|
||||
- **Database:** Gestione rigorosa in **Code First** tramite ORM (es. Prisma o TypeORM).
|
||||
- **Sviluppo:** SQLite.
|
||||
- **Produzione:** PostgreSQL.
|
||||
- **Regola:** Mai query SQL raw; usare sempre le migrazioni.
|
||||
|
||||
### Struttura e Navigazione
|
||||
Dimentica la logica "App Store". L'applicazione è un unico ambiente integrato con un Menu Laterale fisso per le aree funzionali:
|
||||
1. **Dashboard** (KPI e riepiloghi).
|
||||
2. **Scadenzario** (Il cuore operativo: filtri, viste scadenze, stati).
|
||||
3. **Anagrafiche** (Sottomenu: Aziende, Lavoratori).
|
||||
4. **Formazione** (Sottomenu: Catalogo Corsi, Eventi Formativi).
|
||||
5. **Comunicazioni** (Coda invio mail, Storico).
|
||||
6. **Configurazione/Admin**.
|
||||
|
||||
### Workflow di Sviluppo
|
||||
1. **Pianificazione:** Prima di ogni task, scrivi il piano in `./docs/development`.
|
||||
2. **Integrazione:** Lavora sempre sul codice esistente. Non riscrivere se puoi estendere.
|
||||
3. **Responsività:** L'interfaccia deve adattarsi, ma la priorità è la fruibilità Desktop per l'uso da ufficio intensivo.
|
||||
4. **I18n:** Predisponi il sistema per il multilingua (Italiano default), mantenendo i file di traduzione allineati.
|
||||
|
||||
### Verifica Preliminare
|
||||
Prima di pianificare nuove attività, verifica sempre che l'applicazione si avvii correttamente e che le funzioni base (es. Login, Lista Aziende) siano operative.
|
||||
17
.agent/rules/market-placing.md
Normal file
17
.agent/rules/market-placing.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
Il software si chiama Zentral (configurazione OBIS Safety) e si occupa esclusivamente di gestire la formazione obbligatoria in materia di sicurezza:
|
||||
|
||||
- **Anagrafiche**: Gestione strutturata di aziende clienti, sedi operative e lavoratori.
|
||||
- **Catalogo Corsi**: Definizione tipologie corsi, livelli e durate di validità (calcolo automatico scadenze).
|
||||
- **Eventi Formativi**: Registrazione della formazione avvenuta e calcolo automatico dello stato (valido, in scadenza, scaduto).
|
||||
- **Scadenzario**: Dashboard operativa per il monitoraggio delle scadenze con filtri e viste dedicate.
|
||||
- **Documentale**: Archiviazione e ricerca rapida degli attestati (PDF/Word) collegati ai lavoratori.
|
||||
- **Comunicazioni**: Automazione dei flussi di avviso via e-mail alle aziende (promemoria pre-scadenza, scadenza e post-scadenza) con sistema di approvazione.
|
||||
- **Import/Export**: Strumenti per l'importazione massiva dei dati storici e l'esportazione verso portali e-learning esterni.
|
||||
|
||||
Mostra statistiche grafiche sullo stato di copertura formativa nella dashboard principale.
|
||||
|
||||
Si orienta all'automatizzazione dei processi di back-office per eliminare la gestione manuale su file Excel e centralizzare i dati della formazione sicurezza.
|
||||
9
.agent/rules/running-guide.md
Normal file
9
.agent/rules/running-guide.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
All'inizio dei lavori avvia sempre l'applicazione. Assicurati di aver installato le dipendenze con `npm install` (o `yarn`) sia nel backend che nel frontend.
|
||||
|
||||
Avvia l'ambiente di sviluppo (che deve lanciare sia backend che frontend) con il comando dedicato (es. `npm run dev` o `make run dev` se presente Makefile).
|
||||
|
||||
Se le porte sono occupate, prima liberale, poi avvia l'applicazione.
|
||||
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
.git
|
||||
.DS_Store
|
||||
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
25
Makefile
Normal file
25
Makefile
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
# Shortcuts for management
|
||||
|
||||
build:
|
||||
docker-compose build
|
||||
|
||||
up:
|
||||
docker-compose up -d
|
||||
|
||||
down:
|
||||
docker-compose down
|
||||
|
||||
logs:
|
||||
docker-compose logs -f
|
||||
|
||||
dev:
|
||||
@echo "Starting dev environment..."
|
||||
# Logic for running dev local if needed, but 'npm run dev' is per-folder
|
||||
@echo "Use 'npm run dev' in backend/frontend folders"
|
||||
|
||||
migrate-deploy:
|
||||
docker-compose exec backend npx prisma migrate deploy
|
||||
|
||||
seed:
|
||||
docker-compose exec backend npm run seed
|
||||
44
docker-compose.yml
Normal file
44
docker-compose.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: obis
|
||||
POSTGRES_PASSWORD: securepassword123
|
||||
POSTGRES_DB: obis_db
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
backend:
|
||||
build: ./src/backend
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
PORT: 3000
|
||||
# Use internal docker dns name 'db'
|
||||
DATABASE_URL: postgresql://obis:securepassword123@db:5432/obis_db?schema=public
|
||||
JWT_SECRET: super_secure_production_secret
|
||||
# MAIL_ settings for nodemailer (set real ones here)
|
||||
MAIL_HOST: smtp.ethereal.email
|
||||
MAIL_PORT: 587
|
||||
MAIL_USER: ethrel_user
|
||||
MAIL_PASS: ethrel_pass
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
frontend:
|
||||
build: ./src/frontend
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
26
docs/development/DEVELOPMENT.md
Normal file
26
docs/development/DEVELOPMENT.md
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
# Piano di Sviluppo - Zentral (OBIS Safety) - Aggiornato
|
||||
Data Aggiornamento: 2025-12-15 12:30
|
||||
|
||||
## Riepilogo Stato
|
||||
**PROGETTO COMPLETATO (MVP)**
|
||||
|
||||
Il sistema è pronto per il rilascio in produzione con un set completo di funzionalità:
|
||||
- [x] Gestione Anagrafiche (Aziende, Sedi, Lavoratori)
|
||||
- [x] Formazione: Corsi, Storico Eventi
|
||||
- [x] Scadenzario Intelligente e Dashboard
|
||||
- [x] Notifiche Email (Pre-scadenza + Scheduler Automatico)
|
||||
- [x] Autenticazione e Sicurezza
|
||||
- [x] Importazione Excel Massiva
|
||||
- [x] Configurazione Deployment (Docker, PostgreSQL)
|
||||
|
||||
## Istruzioni
|
||||
- **Sviluppo Locale**: Usa `make dev` (o `npm run dev` nelle cartelle). Database: SQLite.
|
||||
- **Produzione**: Usa `make build && make up`. Database: PostgreSQL (Dockerizzato).
|
||||
|
||||
## Evolutive Future
|
||||
1. Integrazione SMTP reale (ora usa Ethereal Mock).
|
||||
2. Gestione allegati su S3/MinIO (ora usa URL statici o mock).
|
||||
3. Reportistica PDF avanzata.
|
||||
|
||||
Per dettagli tecnici vedi la cartella `./devlog`.
|
||||
69
docs/development/devlog/2025-12-15-111000_initial_plan.md
Normal file
69
docs/development/devlog/2025-12-15-111000_initial_plan.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Piano di Sviluppo - Zentral (OBIS Safety)
|
||||
Data: 2025-12-15
|
||||
Stato: Pianificazione Iniziale
|
||||
|
||||
## Obiettivo
|
||||
Creare una Web App SaaS ("Zentral") per la gestione della formazione sicurezza OBIS, sostituendo Excel con un sistema automatizzato.
|
||||
|
||||
## 1. Architettura del Sistema
|
||||
|
||||
### Backend (Node.js)
|
||||
- **Framework**: Express.js con TypeScript.
|
||||
- **Struttura**: Modulare (vedi `src/backend/src/modules`).
|
||||
- **Database**:
|
||||
- **ORM**: Prisma (Supporto nativo per SQLite in dev e PostgreSQL in prod).
|
||||
- **Dev**: SQLite (`dev.db`).
|
||||
- **Prod**: PostgreSQL.
|
||||
- **API**: RESTful standard `api/[modulo]/[risorsa]`.
|
||||
- **Sicurezza**: Helmet, CORS, input validation (Zod/Joi), password hashing (Argon2/Bcrypt).
|
||||
|
||||
### Frontend (React)
|
||||
- **Build Tool**: Vite.
|
||||
- **Linguaggio**: TypeScript.
|
||||
- **UI/UX**:
|
||||
- Design System: Custom basato su "Material Design" ma con estetica "Premium" (colori vibranti, glassmorphism).
|
||||
- Styling: Vanilla CSS (Variabili CSS per temi) + CSS Modules per scoping.
|
||||
- Componenti: React standard.
|
||||
- **Stato**: React Context + Hooks per gestione globale semplice.
|
||||
|
||||
## 2. Roadmap di Implementazione
|
||||
|
||||
### Fase 1: Setup e Fondamenta (Oggi)
|
||||
- [x] Inizializzazione Repository e Cartelle (`src/backend`, `src/frontend`).
|
||||
- [x] Setup Backend: Express, Prisma, Struttura Modulare base.
|
||||
- [x] Setup Frontend: Vite React, Configurazione CSS Base (Design Tokens), Layout App Shell (Sidebar, Header).
|
||||
- [x] Configurazione Database iniziale (Schema Prisma: User, Company preliminary).
|
||||
|
||||
### Fase 2: Gestione Anagrafiche
|
||||
- [x] Modulo Aziende (Companies): CRUD, Sedi.
|
||||
- [x] Modulo Lavoratori (Workers): CRUD, Associazione Azienda.
|
||||
- [x] UI per Anagrafiche: Tabelle moderne, Form di inserimento laterali o modali.
|
||||
|
||||
### Fase 3: Core Formazione
|
||||
- [x] Modulo Corsi (Courses): Catalogo, tipologie, validità.
|
||||
- [x] Modulo Eventi (TrainingEvents): Gestione eventi, upload attestati (Parziale).
|
||||
- [x] Logica Calcolo Scadenze (Service layer dedicate).
|
||||
|
||||
### Fase 4: Scadenzario e Dashboard
|
||||
- [x] Dashboard KPI: Grafici di copertura.
|
||||
- [x] Scadenzario: Datatable avanzata (Filtri, Sort, Export Excel).
|
||||
|
||||
### Fase 5: Notifiche e Comunicazioni
|
||||
- [x] Sistema Code Mail (Redis/Bull o tabella DB semplice per MVP).
|
||||
- [x] Template Engine (Mock/Simple).
|
||||
- [x] Workflow approvazione manuale invii.
|
||||
|
||||
## 3. Modello Dati Preliminare (Schema ER semplificato)
|
||||
|
||||
- **Company**: id, name, vatNumber, email, phone, status...
|
||||
- **Site**: id, companyId, address, city...
|
||||
- **Worker**: id, companyId, siteId, firstName, lastName, taxCode (CF), jobTitle, status...
|
||||
- **Course**: id, title, type, validityYears, hasPostExpiryReminder...
|
||||
- **TrainingEvent**: id, workerId, courseId, eventDate, expiryDate, provider, status (Valid/Expiring/Expired), certificateUrl...
|
||||
- **Notification**: id, companyId, type (Pre/At/Post), status (Pending/Sent/Error), scheduledDate...
|
||||
|
||||
## 4. Azioni Immediate
|
||||
1. Creare struttura cartelle.
|
||||
2. Inizializzare Backend (`npm init`, installazione dipendenze base).
|
||||
3. Inizializzare Frontend (`npm create vite`).
|
||||
4. Definire Schema Prisma iniziale.
|
||||
24
docs/development/devlog/2025-12-15-113000_setup_complete.md
Normal file
24
docs/development/devlog/2025-12-15-113000_setup_complete.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Setup Completato
|
||||
Data: 2025-12-15
|
||||
Stato: Completato
|
||||
|
||||
## Attività Svolte
|
||||
1. **Backend**:
|
||||
- Creato progetto Node.js + TypeScript (`src/backend`).
|
||||
- Configurato Prisma ORM con SQLite (`dev.db`).
|
||||
- Definito Schema iniziale (Company, Site, Worker).
|
||||
- Configurato Express server base.
|
||||
2. **Frontend**:
|
||||
- Creato progetto Vite React + TypeScript (`src/frontend`).
|
||||
- Implementato Design System in `index.css` (CSS Variables, Glassmorphism).
|
||||
- Creata struttura cartelle modulare.
|
||||
3. **Verifica**:
|
||||
- Database inizializzato con successo.
|
||||
- Script `dev` backend aggiunto.
|
||||
|
||||
## Prossimi Passi (Fase 2)
|
||||
Implementare la gestione Anagrafiche (Aziende e Lavoratori):
|
||||
- [ ] Backend: Controller e Service per `Companies`.
|
||||
- [ ] Backend: Controller e Service per `Workers`.
|
||||
- [ ] Frontend: Pagina lista Aziende.
|
||||
- [ ] Frontend: Form creazione Azienda.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Implementazione Anagrafiche (Aziende e Lavoratori)
|
||||
Data: 2025-12-15
|
||||
Stato: In Corso
|
||||
|
||||
## Obiettivo
|
||||
Implementare il modulo gestione anagrafiche completo (Backend API + Frontend UI) per Aziende e Lavoratori.
|
||||
|
||||
## Backend (Node.js/Express)
|
||||
- [ ] Struttura Modulare: setup cartelle `src/modules/{companies,workers}/{controllers,services,routes}`.
|
||||
- [ ] **Companies Module**:
|
||||
- [ ] Service: `create`, `findAll`, `findOne`, `update`, `delete`.
|
||||
- [ ] Controller: Endpoint REST.
|
||||
- [ ] Routes: `GET /api/companies`, `POST`, `PUT`, `DELETE`.
|
||||
- [ ] **Workers Module**:
|
||||
- [ ] Service: CRUD con relazione Azienda/Sede.
|
||||
- [ ] Controller: Endpoint REST.
|
||||
- [ ] Routes: `GET /api/workers` (con filtri), `POST`, etc.
|
||||
|
||||
## Frontend (React)
|
||||
- [ ] **Core**:
|
||||
- [ ] Setup `react-router-dom`.
|
||||
- [ ] Setup Client API (Axios instance).
|
||||
- [ ] Layout Component (Sidebar Menu).
|
||||
- [ ] **Companies UI**:
|
||||
- [ ] Page: Lista Aziende (Tabella).
|
||||
- [ ] Component: Form Azienda (Modale o Pagina dedicata).
|
||||
- [ ] **Workers UI**:
|
||||
- [ ] Page: Lista Lavoratori.
|
||||
- [ ] Component: Form Lavoratore.
|
||||
|
||||
## Note Tecniche
|
||||
- Usare `zod` per validazione input backend (se possibile, altrimenti validazione manuale per velocità MVP).
|
||||
- Frontend: usare componenti riutilizzabili per inputs e tabelle.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Fase 3 & 4: Core Formazione e Scadenzario
|
||||
Data: 2025-12-15
|
||||
Stato: Pianificazione
|
||||
|
||||
## Obiettivo
|
||||
Implementare il cuore del sistema: Catalogo Corsi, Eventi Formativi e Logica Scadenze, concludendo con la Dashboard Scadenzario.
|
||||
|
||||
## 3. Core Formazione (Backend + Frontend)
|
||||
|
||||
### Backend
|
||||
- **Schema Prisma Aggiornato**:
|
||||
- `Course` (nome, validità anni, isSafety).
|
||||
- `TrainingEvent` (workerId, courseId, date, expiryDate, fileUrl).
|
||||
- **Moduli**:
|
||||
- `src/modules/courses`: CRUD.
|
||||
- `src/modules/training`:
|
||||
- CRUD Eventi.
|
||||
- **Logica Business**: Calcolo automatico `expiryDate` (Data Corso + Validità Corso).
|
||||
- Upload file (Multer locale per ora).
|
||||
|
||||
### Frontend
|
||||
- **Courses UI**:
|
||||
- Lista Corsi (Tabella).
|
||||
- Gestione (Add/Edit) per definire le tipologie.
|
||||
- **Training UI**:
|
||||
- Pagina dettaglio Lavoratore: Tab "Formazione".
|
||||
- Form registrazione evento: Selezione Corso, Data. Calcolo scadenza automatico (mostrato preview).
|
||||
- Upload file.
|
||||
|
||||
### Stato Avanzamento
|
||||
- [x] Backend: Model `Course` e `TrainingEvent`
|
||||
- [x] Backend: Service e Controller CRUD Base
|
||||
- [x] Backend: Calcolo automatico scadenza
|
||||
- [x] Frontend: Pagina Lista Corsi
|
||||
- [x] Frontend: Form Creazione/Modifica Corsi
|
||||
- [x] Frontend: Integrazione Formazione in Dettaglio Lavoratore
|
||||
- [x] Frontend: Form Aggiunta Evento Formativo
|
||||
- [ ] Backend: Scheduler per controllo scadenze (Job notturno)
|
||||
- [ ] Backend: Endpoint Report Scadenze (Dashboard)
|
||||
|
||||
## 4. Scadenzario & Dashboard
|
||||
|
||||
### Backend
|
||||
- **Service Scadenzario**:
|
||||
- Query ottimizzate per trovare scadenze in range (prossimi 60gg).
|
||||
- Stati: `VALID`, `EXPIRING` (es. < 2 mesi), `EXPIRED`.
|
||||
|
||||
### Frontend
|
||||
- **Dashboard Home**:
|
||||
- Card KPI (Totale Lavoratori, Scadenze imminenti, Scaduti).
|
||||
- **Pagina Scadenzario**:
|
||||
- Grande tabella filtrabile.
|
||||
- Celle colorate per stato (Verde, Giallo, Rosso).
|
||||
- Export Excel (lato client con libreria `xlsx` o simile).
|
||||
|
||||
## Ordine di esecuzione immediato
|
||||
1. Aggiornamento schema Prisma (Corsi, Eventi).
|
||||
2. Backend API Corsi & Eventi.
|
||||
3. Frontend UI Corsi.
|
||||
4. Frontend UI Eventi (dentro lavoratore).
|
||||
5. Dashboard Scadenzario.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Implementazione Scadenzario (Deadlines)
|
||||
Data: 2025-12-15
|
||||
Stato: In Corso
|
||||
|
||||
## Obiettivo
|
||||
Creare la vista "Scadenzario" che permette di monitorare tutte le scadenze formative. È il cuore operativo del sistema.
|
||||
|
||||
## Funzionalità Richieste
|
||||
1. **Lista Scadenze**: Tabella con Lavoratore, Azienda, Corso, Data Scadenza, Stato.
|
||||
2. **Filtri**:
|
||||
- Per Stato (Scaduto, In Scadenza, Valido).
|
||||
- Per Periodo (Mese/Anno o Range di date).
|
||||
- Per Azienda.
|
||||
3. **Indicatori Visivi**: Semafori (Rosso=Scaduto, Arancio=In Scadenza < 30gg, Verde=Valido).
|
||||
|
||||
## Piano Tecnico
|
||||
|
||||
### Backend (`src/backend/src/modules/deadlines`)
|
||||
- [x] `deadline.service.ts`: Query su `TrainingEvent` con ordinamento per `expiryDate`.
|
||||
- [x] `deadline.controller.ts`: API endpoint con supporto query params per filtri.
|
||||
- [x] `deadlines.routes.ts`: Route `GET /`.
|
||||
|
||||
### Frontend (`src/frontend/src/modules/deadlines`)
|
||||
- [x] `DeadlinesPage.tsx`: Pagina principale.
|
||||
- [x] Integrazione in `Layout.tsx` (Menu laterale).
|
||||
- [x] Tabella avanzata con filtri lato server (o client per MVP se i dati sono pochi). *Decisione: Lato Server per scalabilità.*
|
||||
|
||||
## Modifiche
|
||||
...
|
||||
@@ -0,0 +1,26 @@
|
||||
|
||||
# Piano di Sviluppo: Completamento Anagrafiche (Sedi) e Dashboard
|
||||
|
||||
Obiettivo: Completare le funzionalità mancanti per rendere l'applicazione pienamente operativa per la gestione della formazione.
|
||||
|
||||
## 1. Gestione Sedi (Sites)
|
||||
Le sedi operative sono fondamentali per allocare i lavoratori.
|
||||
- [x] **Backend**: Modulo `sites` (CRUD).
|
||||
- Rotte: `GET /sites`, `GET /sites?companyId=...`, `POST /sites`, `PUT /sites/:id`, `DELETE /sites/:id`.
|
||||
- [x] **Frontend**: Pagina Dettaglio Azienda.
|
||||
- Visualizzazione dati azienda.
|
||||
- Tabella Sedi associate.
|
||||
- Form (Modal) per Aggiunta/Modifica Sede.
|
||||
- [x] **Frontend**: Integrazione nel Form Lavoratore.
|
||||
- Abilitare la select "Sede" filtrata in base all'azienda selezionata.
|
||||
|
||||
## 2. Dashboard e Scadenziario
|
||||
- [x] **Backend**: Endpoint `GET /dashboard/stats`.
|
||||
- Conteggio scadenze (Scaduti, In scadenza 30gg, Validi).
|
||||
- Prossimi corsi in programma.
|
||||
- [x] **Frontend**: Widget Dashboard.
|
||||
- Cards riepilogative (KPI).
|
||||
- Tabella "In Scadenza" veloce (Solo KPI per ora).
|
||||
|
||||
## 3. Notifiche (Base)
|
||||
- [x] Implementazione servizio invio mail (Stub/Log per ora).
|
||||
22
docs/development/devlog/2025-12-15-120500_phase6_auth.md
Normal file
22
docs/development/devlog/2025-12-15-120500_phase6_auth.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Fase 6: Autenticazione e Sicurezza
|
||||
Data: 2025-12-15
|
||||
Stato: In Corso
|
||||
|
||||
## Obiettivo
|
||||
Proteggere l'accesso all'applicazione tramite login e gestire gli utenti amministratori.
|
||||
|
||||
## Piano Tecnico
|
||||
|
||||
### Backend
|
||||
- [x] Aggiornare Schema: `User` (email, passwordHash, role).
|
||||
- [x] `auth.service.ts`:
|
||||
- Funzioni per hashing password (bcrypt/argon2).
|
||||
- Login (verifica password e emissione JWT).
|
||||
- [x] `auth.middleware.ts`: Verifica JWT su rotte protette.
|
||||
- [x] `auth.controller.ts`: Endpoint `/login`, `/me`.
|
||||
|
||||
### Frontend
|
||||
- [x] `AuthContext.tsx`: Gestione stato utente e token.
|
||||
- [x] `LoginPage.tsx`: Form di login.
|
||||
- [x] `PrivateRoute.tsx`: Protezione rotte.
|
||||
- [x] Aggiornare `Layout` con pulsante Logout.
|
||||
26
docs/development/devlog/2025-12-15-121000_phase7_import.md
Normal file
26
docs/development/devlog/2025-12-15-121000_phase7_import.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Fase 7: Importazione Dati Massiva
|
||||
Data: 2025-12-15
|
||||
Stato: In Corso
|
||||
|
||||
## Obiettivo
|
||||
Permettere l'importazione massiva di dati da file Excel per facilitare la migrazione iniziale dai vecchi sistemi.
|
||||
|
||||
## Funzionalità
|
||||
1. **Upload Excel**: L'utente carica un file `.xlsx`.
|
||||
2. **Parsing & Validazione**: Il sistema legge il file e valida i dati (campi obbligatori, formati).
|
||||
3. **Inserimento/Upsert**: Creazione o aggiornamento dei record nel database.
|
||||
4. **Supporto Entità**:
|
||||
- Aziende
|
||||
- Lavoratori (collegati ad aziende esistenti o create al volo)
|
||||
- Storico Formazione (opzionale/avanzato)
|
||||
|
||||
## Piano Tecnico
|
||||
|
||||
### Backend
|
||||
- [x] Installare `multer` (upload) e `xlsx` (parsing).
|
||||
- [x] `import.service.ts`: Logica di lettura Excel e mappatura verso Prisma.
|
||||
- [x] `import.controller.ts`: Endpoint `POST /api/import/workers`.
|
||||
|
||||
### Frontend
|
||||
- [x] `ImportPage.tsx`: Interfaccia di upload drag & drop.
|
||||
- [x] Feedback importazione (Righe importate, eventuali errori).
|
||||
@@ -0,0 +1,26 @@
|
||||
# Fase 5: Notifiche e Comunicazioni
|
||||
Data: 2025-12-15
|
||||
Stato: In Corso
|
||||
|
||||
## Obiettivo
|
||||
Implementare il sistema di generazione e invio delle notifiche di scadenza alle aziende.
|
||||
|
||||
## Requisiti
|
||||
1. **Generazione Automatica**: Il sistema deve identificare i corsi in scadenza (es. preavviso 30gg) e generare una "proposta di notifica".
|
||||
2. **Review Umana**: Le notifiche non partono subito; l'operatore deve poterle visionare nella sezione "Comunicazioni" e approvarle.
|
||||
3. **Queue System**: Gestione dello stato della notifica (Pending -> Sending -> Sent/Error).
|
||||
4. **Email Templates**: Uso di template HTML per le mail.
|
||||
|
||||
## Piano Tecnico
|
||||
|
||||
### Backend
|
||||
- [x] Installazione `nodemailer` e `ejs` per templating.
|
||||
- [x] Aggiornamento Schema Prisma: Aggiunta model `Notification` (type: EXPIRING_REMINDER, status: PENDING/SENT).
|
||||
- [x] `notification.service.ts`:
|
||||
- `generateReminders()`: Scansiona `TrainingEvent` e crea record `Notification`.
|
||||
- `sendPending(ids[])`: Invia le mail selezionate.
|
||||
- [x] `notification.controller.ts`: Endpoints per lista, generazione e invio.
|
||||
|
||||
### Frontend
|
||||
- [x] `CommunicationsPage.tsx`: Tabella delle notifiche generate.
|
||||
- [x] Azioni massive: "Genera Nuove" e "Invia Selezionate".
|
||||
@@ -0,0 +1,19 @@
|
||||
# Fase 8: Deployment e Configurazione Produzione
|
||||
Data: 2025-12-15
|
||||
Stato: In Corso
|
||||
|
||||
## Obiettivo
|
||||
Configurare l'ambiente di produzione utilizzando Docker per garantire replicabilità e stabilità. Passaggio da SQLite (Dev) a PostgreSQL (Prod).
|
||||
|
||||
## Requisiti
|
||||
1. **Containerizzazione**: Dockerfile per Backend e Frontend.
|
||||
2. **Orchestrazione**: `docker-compose.yml` per gestire i servizi (App, DB, Reverse Proxy).
|
||||
3. **Database**: PostgreSQL per produzione.
|
||||
4. **Automazione**: Makefile per comandi rapidi.
|
||||
|
||||
## Piano Tecnico
|
||||
- [x] Creare `src/backend/Dockerfile`.
|
||||
- [x] Creare `src/frontend/Dockerfile` (Nginx per serve statico).
|
||||
- [x] Creare `docker-compose.yml` (Backend, Frontend, Postgres).
|
||||
- [x] Aggiornare Prisma per supportare PostgreSQL via Environment Variable (gestito via `sed` in build).
|
||||
- [x] Creare `Makefile` per shortcut (build, up, down, logs).
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
# 2025-12-15 12:30:00 - Bug Fixes and Verification
|
||||
|
||||
## Stato Attuale
|
||||
L'applicazione è stata riparata e verificata.
|
||||
|
||||
## Problemi Risolti
|
||||
1. **Backend non rispondeva/non sincronizzato**: Il servizio backend in esecuzione (`npm run dev`) non era sincronizzato con il database SQLite, portando a errori di autenticazione ("Errore durante l'autenticazione").
|
||||
- *Soluzione*: Eseguito `npx prisma generate` e `npx prisma db push`. Riavviato il servizio backend sulla porta 3000.
|
||||
2. **Import Error nel Frontend**: L'applicazione non si avviava a causa di percorsi di import errati per `api.ts` in diversi file.
|
||||
- *Soluzione*: Corretti i percorsi relativi in `ImportPage.tsx`, `CommunicationsPage.tsx`, `DeadlinesPage.tsx`, `LoginPage.tsx`, `AuthContext.tsx`.
|
||||
3. **Lint Errors**:
|
||||
- Rimosso import inutilizzato di `useDebounce` in `DeadlinesPage.tsx`.
|
||||
- Corretto import `type ReactNode` e rimosso `api`/`useLocation` inutilizzati in `AuthContext.tsx`.
|
||||
|
||||
## Verifiche Effettuate
|
||||
- **Database**: Verificato che lo schema è sincronizzato.
|
||||
- **Registrazione Utente**: Creato utente admin (`admin@test.com` / `admin`) tramite script diretto.
|
||||
- **Login**: Accesso via browser riuscito con successo.
|
||||
- **Dashboard**: La Dashboard si carica correttamente e mostra i contatori a 0.
|
||||
|
||||
## Prossimi Passi
|
||||
- Proseguire con l'implementazione del modulo di Importazione (Phase 7) o popolamento dati.
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
# Completamento Sistema: Scheduler Notifiche
|
||||
|
||||
Obiettivo: Automatizzare la generazione e l'invio delle notifiche di scadenza.
|
||||
|
||||
## Backend
|
||||
- [x] Installazione `node-cron`.
|
||||
- [x] Creazione `src/backend/src/scheduler.ts`.
|
||||
- Job notturno (es. 02:00 AM) per generare i reminder (`generateReminders`).
|
||||
- Job frequente (es. ogni 10 min o subito dopo) per processare la coda (`sendPending`).
|
||||
- [x] Integrazione in `src/backend/src/index.ts` per avviare lo scheduler.
|
||||
|
||||
## Verifica
|
||||
- [ ] Avvio backend e verifica log "Scheduler started".
|
||||
- [ ] Test manuale (trigger via API o modificando crontab per esecuzione immediata).
|
||||
|
||||
## Chiusura Progetto
|
||||
- [ ] Aggiornamento `DEVELOPMENT.md` finale.
|
||||
- [ ] Verifica `devlog` precedenti e mark as complete.
|
||||
21
docs/development/devlog/2025-12-15-123500_ui_overhaul.md
Normal file
21
docs/development/devlog/2025-12-15-123500_ui_overhaul.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 2025-12-15-123500_ui_overhaul.md
|
||||
|
||||
## Objective
|
||||
Overhaul the UI/UX to create a premium, modern, and aesthetically pleasing experience ("Wow" factor), moving away from the "unwatchable" current state.
|
||||
|
||||
## Design Direction
|
||||
- **Theme:** Modern Dark/Light mode (default Dark deep premium).
|
||||
- **Palette:** Deep slate/navy backgrounds, vibrant accents (violet/indigo/teal gradients), glassmorphism effects.
|
||||
- **Typography:** Modern sans-serif (Inter/Roboto).
|
||||
- **Components:**
|
||||
- **Sidebar:** Glassmorphism, better spacing, active state indicators.
|
||||
- **Cards:** Subtle borders, soft shadows, hover lifts.
|
||||
- **Tables:** Clean headers, spacious rows, hover effects, specific status badges.
|
||||
- **Animations:** Page transitions, button hover states.
|
||||
|
||||
## Tasks
|
||||
- [x] Define new Color Palette and CSS Variables in `index.css`.
|
||||
- [x] Refactor `MainLayout` for better structure and glass sidebar.
|
||||
- [x] Update `Dashboard` widgets style.
|
||||
- [x] Update `Companies` table style.
|
||||
- [x] Global typography update.
|
||||
5
src/backend/.gitignore
vendored
Normal file
5
src/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/src/generated/prisma
|
||||
32
src/backend/Dockerfile
Normal file
32
src/backend/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
# Switch Prisma provider to postgresql for production build
|
||||
RUN sed -i 's/provider = "sqlite"/provider = "postgresql"/g' prisma/schema.prisma
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Run
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Add wait-for script or similar if strict DB readiness needed,
|
||||
# or rely on restart policies.
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/app.js"]
|
||||
BIN
src/backend/dev.db
Normal file
BIN
src/backend/dev.db
Normal file
Binary file not shown.
3253
src/backend/package-lock.json
generated
Normal file
3253
src/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
src/backend/package.json
Normal file
43
src/backend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "tsc && node dist/index.js",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^25.0.2",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/xlsx": "^0.0.35",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.10.0",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.11",
|
||||
"prisma": "5.10.0",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
14
src/backend/prisma.config.ts
Normal file
14
src/backend/prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
BIN
src/backend/prisma/dev.db
Normal file
BIN
src/backend/prisma/dev.db
Normal file
Binary file not shown.
@@ -0,0 +1,49 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Company" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"vatNumber" TEXT,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"address" TEXT,
|
||||
"city" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"notes" TEXT,
|
||||
"externalId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Site" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"address" TEXT,
|
||||
"city" TEXT,
|
||||
"companyId" INTEGER NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
CONSTRAINT "Site_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Worker" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"taxCode" TEXT,
|
||||
"jobTitle" TEXT,
|
||||
"companyId" INTEGER NOT NULL,
|
||||
"siteId" INTEGER,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"hiringDate" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Worker_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Worker_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Company_vatNumber_key" ON "Company"("vatNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Worker_taxCode_key" ON "Worker"("taxCode");
|
||||
@@ -0,0 +1,28 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Course" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"validityYears" INTEGER NOT NULL,
|
||||
"hasPostExpiryReminder" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isSafety" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TrainingEvent" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"workerId" INTEGER NOT NULL,
|
||||
"courseId" INTEGER NOT NULL,
|
||||
"eventDate" DATETIME NOT NULL,
|
||||
"expiryDate" DATETIME,
|
||||
"provider" TEXT,
|
||||
"certificateUrl" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'VALID',
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "TrainingEvent_workerId_fkey" FOREIGN KEY ("workerId") REFERENCES "Worker" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "TrainingEvent_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Course" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL DEFAULT 'TEMP',
|
||||
"description" TEXT,
|
||||
"validityYears" INTEGER NOT NULL,
|
||||
"hasPostExpiryReminder" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isSafety" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_Course" ("createdAt", "description", "hasPostExpiryReminder", "id", "isSafety", "title", "updatedAt", "validityYears") SELECT "createdAt", "description", "hasPostExpiryReminder", "id", "isSafety", "title", "updatedAt", "validityYears" FROM "Course";
|
||||
DROP TABLE "Course";
|
||||
ALTER TABLE "new_Course" RENAME TO "Course";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Notification" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"companyId" INTEGER NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"subject" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"sentAt" DATETIME,
|
||||
"scheduledFor" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Notification_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"role" TEXT NOT NULL DEFAULT 'ADMIN',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Worker" ADD COLUMN "email" TEXT;
|
||||
ALTER TABLE "Worker" ADD COLUMN "phone" TEXT;
|
||||
3
src/backend/prisma/migrations/migration_lock.toml
Normal file
3
src/backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
132
src/backend/prisma/schema.prisma
Normal file
132
src/backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,132 @@
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model Company {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
vatNumber String? @unique
|
||||
email String?
|
||||
phone String?
|
||||
address String?
|
||||
city String?
|
||||
isActive Boolean @default(true)
|
||||
notes String?
|
||||
externalId String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
workers Worker[]
|
||||
sites Site[]
|
||||
notifications Notification[]
|
||||
}
|
||||
|
||||
model Site {
|
||||
id Int @id @default(autoincrement())
|
||||
name String // e.g. "Sede Principale", "Cantiere A"
|
||||
address String?
|
||||
city String?
|
||||
companyId Int
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
|
||||
isActive Boolean @default(true)
|
||||
|
||||
workers Worker[]
|
||||
}
|
||||
|
||||
model Worker {
|
||||
id Int @id @default(autoincrement())
|
||||
firstName String
|
||||
lastName String
|
||||
taxCode String? @unique // Codice Fiscale
|
||||
jobTitle String? // Mansione
|
||||
email String?
|
||||
phone String?
|
||||
|
||||
companyId Int
|
||||
company Company @relation(fields: [companyId], references: [id])
|
||||
|
||||
siteId Int?
|
||||
site Site? @relation(fields: [siteId], references: [id])
|
||||
|
||||
isActive Boolean @default(true)
|
||||
hiringDate DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
events TrainingEvent[]
|
||||
}
|
||||
|
||||
model Course {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
code String @default("TEMP") // Temporaneo default per migrazione, poi rimuovo o rendo unique
|
||||
description String?
|
||||
validityYears Int // Durata validita in anni (es. 5)
|
||||
hasPostExpiryReminder Boolean @default(false)
|
||||
isSafety Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
events TrainingEvent[]
|
||||
}
|
||||
|
||||
model TrainingEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
workerId Int
|
||||
worker Worker @relation(fields: [workerId], references: [id], onDelete: Cascade)
|
||||
|
||||
courseId Int
|
||||
course Course @relation(fields: [courseId], references: [id])
|
||||
|
||||
eventDate DateTime // Data del corso
|
||||
expiryDate DateTime? // Data scadenza calcolata
|
||||
|
||||
provider String? // Ente formatore
|
||||
certificateUrl String? // Link al file PDF
|
||||
|
||||
status String @default("VALID") // VALID, EXPIRING, EXPIRED
|
||||
|
||||
notes String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id Int @id @default(autoincrement())
|
||||
companyId Int
|
||||
company Company @relation(fields: [companyId], references: [id])
|
||||
|
||||
type String // 'EXPIRING_REMINDER', 'EXPIRED_ALERT'
|
||||
status String @default("PENDING") // PENDING, SENT, FAILED
|
||||
|
||||
subject String
|
||||
content String // HTML content or JSON data to render
|
||||
|
||||
sentAt DateTime?
|
||||
scheduledFor DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
name String?
|
||||
role String @default("ADMIN") // ADMIN, USER
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
44
src/backend/src/app.ts
Normal file
44
src/backend/src/app.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
import companyRoutes from './modules/companies/companies.routes';
|
||||
import workerRoutes from './modules/workers/workers.routes';
|
||||
|
||||
app.use('/api/companies', companyRoutes);
|
||||
app.use('/api/workers', workerRoutes);
|
||||
|
||||
import courseRoutes from './modules/courses/courses.routes';
|
||||
import trainingRoutes from './modules/training/training.routes';
|
||||
|
||||
app.use('/api/courses', courseRoutes);
|
||||
app.use('/api/training', trainingRoutes);
|
||||
|
||||
import siteRoutes from './modules/sites/sites.routes';
|
||||
app.use('/api/sites', siteRoutes);
|
||||
|
||||
import dashboardRoutes from './modules/dashboard/dashboard.routes';
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
|
||||
import deadlineRoutes from './modules/deadlines/deadlines.routes';
|
||||
app.use('/api/deadlines', deadlineRoutes);
|
||||
|
||||
import notificationRoutes from './modules/notifications/notifications.routes';
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
|
||||
import authRoutes from './modules/auth/auth.routes';
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
import importRoutes from './modules/import/import.routes';
|
||||
app.use('/api/import', importRoutes);
|
||||
|
||||
export default app;
|
||||
30
src/backend/src/index.ts
Normal file
30
src/backend/src/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
import app from './app';
|
||||
import dotenv from 'dotenv';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { startScheduler } from './scheduler';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// Check DB connection
|
||||
// await prisma.$connect();
|
||||
// console.log('Database connected successfully');
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
|
||||
// Start Scheduler
|
||||
startScheduler();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unable to connect to the database:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
6
src/backend/src/lib/prisma.ts
Normal file
6
src/backend/src/lib/prisma.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default prisma;
|
||||
12
src/backend/src/modules/auth/auth.routes.ts
Normal file
12
src/backend/src/modules/auth/auth.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { login, register, getMe } from './controllers/auth.controller';
|
||||
import { authenticateToken } from './middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/login', login);
|
||||
router.post('/register', register);
|
||||
router.get('/me', authenticateToken, getMe);
|
||||
|
||||
export default router;
|
||||
36
src/backend/src/modules/auth/controllers/auth.controller.ts
Normal file
36
src/backend/src/modules/auth/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { authService } from '../services/auth.service';
|
||||
|
||||
export const login = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
|
||||
const result = await authService.login(email, password);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Credenziali non valide' });
|
||||
}
|
||||
};
|
||||
|
||||
export const register = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
|
||||
// Simple protection: only allow registration if secret key matches env or if DB is empty (logic usually in service)
|
||||
// For now open or protected by API logic.
|
||||
// Assuming this is used by admin to create other users, or seed script.
|
||||
|
||||
const user = await authService.register(email, password, name);
|
||||
res.status(201).json({ id: user.id, email: user.email });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(400).json({ error: 'Errore registrazione' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getMe = (req: any, res: Response) => {
|
||||
res.json(req.user);
|
||||
};
|
||||
22
src/backend/src/modules/auth/middleware/auth.middleware.ts
Normal file
22
src/backend/src/modules/auth/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_key_change_me';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: any;
|
||||
}
|
||||
|
||||
export const authenticateToken = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) return res.sendStatus(401);
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err: any, user: any) => {
|
||||
if (err) return res.sendStatus(403);
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
42
src/backend/src/modules/auth/services/auth.service.ts
Normal file
42
src/backend/src/modules/auth/services/auth.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_key_change_me';
|
||||
|
||||
export class AuthService {
|
||||
async register(email: string, password: string, name?: string) {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(password, user.password);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '8h' }
|
||||
);
|
||||
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
return { user: userWithoutPassword, token };
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
14
src/backend/src/modules/companies/companies.routes.ts
Normal file
14
src/backend/src/modules/companies/companies.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { CompanyController } from './controllers/company.controller';
|
||||
|
||||
const router = Router();
|
||||
const controller = new CompanyController();
|
||||
|
||||
router.get('/', controller.getAll);
|
||||
router.get('/:id', controller.getOne);
|
||||
router.post('/', controller.create);
|
||||
router.put('/:id', controller.update);
|
||||
router.delete('/:id', controller.delete);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,59 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { CompanyService } from '../services/company.service';
|
||||
|
||||
const companyService = new CompanyService();
|
||||
|
||||
export class CompanyController {
|
||||
async getAll(req: Request, res: Response) {
|
||||
try {
|
||||
const companies = await companyService.findAll();
|
||||
res.json(companies);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch companies' });
|
||||
}
|
||||
}
|
||||
|
||||
async getOne(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const company = await companyService.findOne(id);
|
||||
if (!company) {
|
||||
return res.status(404).json({ error: 'Company not found' });
|
||||
}
|
||||
res.json(company);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch company' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response) {
|
||||
try {
|
||||
const company = await companyService.create(req.body);
|
||||
res.status(201).json(company);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(400).json({ error: 'Failed to create company' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const company = await companyService.update(id, req.body);
|
||||
res.json(company);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: 'Failed to update company' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await companyService.delete(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete company' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export class CompanyService {
|
||||
async findAll() {
|
||||
return prisma.company.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { workers: true, sites: true }
|
||||
}
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
return prisma.company.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
sites: true,
|
||||
workers: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.CompanyCreateInput) {
|
||||
return prisma.company.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, data: Prisma.CompanyUpdateInput) {
|
||||
return prisma.company.update({
|
||||
where: { id },
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: number) {
|
||||
return prisma.company.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { CourseService } from '../services/course.service';
|
||||
|
||||
const courseService = new CourseService();
|
||||
|
||||
export class CourseController {
|
||||
async getAll(req: Request, res: Response) {
|
||||
try {
|
||||
const courses = await courseService.findAll();
|
||||
res.json(courses);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch courses' });
|
||||
}
|
||||
}
|
||||
|
||||
async getOne(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const course = await courseService.findOne(id);
|
||||
if (!course) {
|
||||
return res.status(404).json({ error: 'Course not found' });
|
||||
}
|
||||
res.json(course);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch course' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response) {
|
||||
try {
|
||||
const course = await courseService.create(req.body);
|
||||
res.status(201).json(course);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(400).json({ error: 'Failed to create course' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const course = await courseService.update(id, req.body);
|
||||
res.json(course);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: 'Failed to update course' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await courseService.delete(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete course' });
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/backend/src/modules/courses/courses.routes.ts
Normal file
14
src/backend/src/modules/courses/courses.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { CourseController } from './controllers/course.controller';
|
||||
|
||||
const router = Router();
|
||||
const controller = new CourseController();
|
||||
|
||||
router.get('/', controller.getAll);
|
||||
router.get('/:id', controller.getOne);
|
||||
router.post('/', controller.create);
|
||||
router.put('/:id', controller.update);
|
||||
router.delete('/:id', controller.delete);
|
||||
|
||||
export default router;
|
||||
36
src/backend/src/modules/courses/services/course.service.ts
Normal file
36
src/backend/src/modules/courses/services/course.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export class CourseService {
|
||||
async findAll() {
|
||||
return prisma.course.findMany({
|
||||
orderBy: { title: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
return prisma.course.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.CourseCreateInput) {
|
||||
return prisma.course.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, data: Prisma.CourseUpdateInput) {
|
||||
return prisma.course.update({
|
||||
where: { id },
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: number) {
|
||||
return prisma.course.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { dashboardService } from '../services/dashboard.service';
|
||||
|
||||
export const getDashboardStats = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = await dashboardService.getStats();
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Errore nel recupero statistiche dashboard' });
|
||||
}
|
||||
};
|
||||
9
src/backend/src/modules/dashboard/dashboard.routes.ts
Normal file
9
src/backend/src/modules/dashboard/dashboard.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { getDashboardStats } from './controllers/dashboard.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/stats', getDashboardStats);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
|
||||
export class DashboardService {
|
||||
async getStats() {
|
||||
const totalWorkers = await prisma.worker.count({ where: { isActive: true } });
|
||||
const totalCompanies = await prisma.company.count({ where: { isActive: true } });
|
||||
const totalSites = await prisma.site.count({ where: { isActive: true } });
|
||||
|
||||
const now = new Date();
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setDate(now.getDate() + 30);
|
||||
|
||||
const expiredTrainings = await prisma.trainingEvent.count({
|
||||
where: {
|
||||
expiryDate: { lt: now }
|
||||
}
|
||||
});
|
||||
|
||||
const expiringTrainings = await prisma.trainingEvent.count({
|
||||
where: {
|
||||
expiryDate: {
|
||||
gte: now,
|
||||
lte: nextMonth
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const validTrainings = await prisma.trainingEvent.count({
|
||||
where: {
|
||||
expiryDate: { gt: nextMonth }
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalWorkers,
|
||||
totalCompanies,
|
||||
totalSites,
|
||||
expiredTrainings,
|
||||
expiringTrainings,
|
||||
validTrainings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardService = new DashboardService();
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { deadlineService } from '../services/deadline.service';
|
||||
|
||||
export const getDeadlines = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { companyId, status, startDate, endDate, search } = req.query;
|
||||
|
||||
const filters = {
|
||||
companyId: companyId ? Number(companyId) : undefined,
|
||||
status: status as any,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
search: search as string
|
||||
};
|
||||
|
||||
const deadlines = await deadlineService.findAll(filters);
|
||||
res.json(deadlines);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Errore nel recupero dello scadenzario' });
|
||||
}
|
||||
};
|
||||
9
src/backend/src/modules/deadlines/deadlines.routes.ts
Normal file
9
src/backend/src/modules/deadlines/deadlines.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { getDeadlines } from './controllers/deadline.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', getDeadlines);
|
||||
|
||||
export default router;
|
||||
114
src/backend/src/modules/deadlines/services/deadline.service.ts
Normal file
114
src/backend/src/modules/deadlines/services/deadline.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export interface DeadlineFilters {
|
||||
companyId?: number;
|
||||
status?: 'EXPIRED' | 'EXPIRING' | 'VALID' | 'ALL'; // Default ALL is usually handled by omitting filter
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
search?: string; // Search worker name or course
|
||||
}
|
||||
|
||||
export class DeadlineService {
|
||||
async findAll(filters: DeadlineFilters) {
|
||||
const where: Prisma.TrainingEventWhereInput = {};
|
||||
|
||||
// Filter by Company
|
||||
if (filters.companyId) {
|
||||
where.worker = {
|
||||
companyId: filters.companyId
|
||||
};
|
||||
}
|
||||
|
||||
// Filter by Date Range (Expiry Date)
|
||||
if (filters.startDate || filters.endDate) {
|
||||
where.expiryDate = {
|
||||
gte: filters.startDate,
|
||||
lte: filters.endDate
|
||||
};
|
||||
}
|
||||
|
||||
// Filter by Status (Dynamic calculation usually, but we have a stored status field we try to keep updated,
|
||||
// OR we calculate properly based on dates here)
|
||||
// Using stored 'status' field is faster but requires it to be kept in sync via cron jobs.
|
||||
// For MVP, relying on the 'status' field might be risky if we don't have a background job updating it daily.
|
||||
// BETTER APPROACH FOR ROBUSTNESS: Query based on Dates when filtering by status.
|
||||
|
||||
// Status Logic:
|
||||
// EXPIRED: expiryDate < NOW
|
||||
// EXPIRING: NOW <= expiryDate <= NOW + 30 Days
|
||||
// VALID: expiryDate > NOW + 30 Days
|
||||
|
||||
const now = new Date();
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(now.getDate() + 30);
|
||||
|
||||
if (filters.status) {
|
||||
if (filters.status === 'EXPIRED') {
|
||||
where.expiryDate = { lt: now };
|
||||
} else if (filters.status === 'EXPIRING') {
|
||||
where.expiryDate = { gte: now, lte: thirtyDaysFromNow };
|
||||
} else if (filters.status === 'VALID') {
|
||||
where.expiryDate = { gt: thirtyDaysFromNow };
|
||||
}
|
||||
}
|
||||
|
||||
// Search text (Worker Name or Course Title)
|
||||
// Note: Search on relations in Prisma needs specific structure
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ worker: { firstName: { contains: filters.search } } },
|
||||
{ worker: { lastName: { contains: filters.search } } },
|
||||
{ course: { title: { contains: filters.search } } }
|
||||
];
|
||||
}
|
||||
|
||||
const events = await prisma.trainingEvent.findMany({
|
||||
where,
|
||||
include: {
|
||||
worker: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
company: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
course: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
code: true,
|
||||
validityYears: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
expiryDate: 'asc' // Most urgent first
|
||||
}
|
||||
});
|
||||
|
||||
// Post-process to ensure status is strictly correct
|
||||
return events.map(event => {
|
||||
// expiryDate in DB *could* be nullable if optional, but for training events it should be set.
|
||||
// However, Prisma types it as Date | null if optional in schema.
|
||||
// In our schema: expiryDate DateTime? (Nullable)
|
||||
if (!event.expiryDate) return { ...event, status: 'VALID' }; // Fallback for no expiry
|
||||
|
||||
const expiry = new Date(event.expiryDate);
|
||||
let dynamicStatus = 'VALID';
|
||||
if (expiry < now) dynamicStatus = 'EXPIRED';
|
||||
else if (expiry <= thirtyDaysFromNow) dynamicStatus = 'EXPIRING';
|
||||
|
||||
return {
|
||||
...event,
|
||||
status: dynamicStatus // Override stored status for display accuracy
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const deadlineService = new DeadlineService();
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { importService } from '../services/import.service';
|
||||
|
||||
export const importWorkers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Nessun file caricato' });
|
||||
}
|
||||
|
||||
const buffer = req.file.buffer;
|
||||
const result = await importService.importWorkers(buffer);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Errore durante l\'importazione' });
|
||||
}
|
||||
};
|
||||
11
src/backend/src/modules/import/import.routes.ts
Normal file
11
src/backend/src/modules/import/import.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { importWorkers } from './controllers/import.controller';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
router.post('/workers', upload.single('file'), importWorkers);
|
||||
|
||||
export default router;
|
||||
92
src/backend/src/modules/import/services/import.service.ts
Normal file
92
src/backend/src/modules/import/services/import.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export class ImportService {
|
||||
async importWorkers(buffer: Buffer) {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json<any>(sheet);
|
||||
|
||||
const results = {
|
||||
total: data.length,
|
||||
success: 0,
|
||||
errors: [] as string[]
|
||||
};
|
||||
|
||||
// Expected columns: Nome, Cognome, CF, Azienda, Mansione, DataAssunzione, Email
|
||||
for (const row of data) {
|
||||
try {
|
||||
const firstName = row['Nome'];
|
||||
const lastName = row['Cognome'];
|
||||
const taxCode = row['CF'];
|
||||
const companyName = row['Azienda'];
|
||||
const jobTitle = row['Mansione'];
|
||||
const email = row['Email'];
|
||||
// Handle dates in xlsx can be tricky, assuming string YYYY-MM-DD or excel date code
|
||||
// For MVP assuming simple parsing or string
|
||||
|
||||
if (!firstName || !lastName || !companyName) {
|
||||
results.errors.push(`Row missing required data: ${JSON.stringify(row)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Find or Create Company
|
||||
// Note: In a strict system we might want to fail if company doesn't exist.
|
||||
// For ease of use, we find by name.
|
||||
let company = await prisma.company.findFirst({
|
||||
where: { name: companyName }
|
||||
});
|
||||
|
||||
if (!company) {
|
||||
company = await prisma.company.create({
|
||||
data: { name: companyName }
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Upsert Worker
|
||||
// Determine unique constraint. TaxCode is best.
|
||||
if (taxCode) {
|
||||
await prisma.worker.upsert({
|
||||
where: { taxCode },
|
||||
update: {
|
||||
firstName,
|
||||
lastName,
|
||||
jobTitle,
|
||||
email,
|
||||
companyId: company.id
|
||||
},
|
||||
create: {
|
||||
firstName,
|
||||
lastName,
|
||||
taxCode,
|
||||
jobTitle,
|
||||
email,
|
||||
companyId: company.id
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If no taxCode, just create (risk of duplication)
|
||||
await prisma.worker.create({
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
jobTitle,
|
||||
email,
|
||||
companyId: company.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
results.success++;
|
||||
} catch (err: any) {
|
||||
results.errors.push(`Error processing row ${JSON.stringify(row)}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export const importService = new ImportService();
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { notificationService } from '../services/notification.service';
|
||||
|
||||
export const getNotifications = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const list = await notificationService.findAll();
|
||||
res.json(list);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Errore recupero notifiche' });
|
||||
}
|
||||
};
|
||||
|
||||
export const generateNotifications = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const generated = await notificationService.generateReminders();
|
||||
res.json({ message: `Generate ${generated.length} notifiche`, generated });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Errore generazione' });
|
||||
}
|
||||
};
|
||||
|
||||
export const sendNotifications = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { ids } = req.body; // Array of IDs
|
||||
if (!ids || !Array.isArray(ids)) return res.status(400).json({ error: 'Invalid IDs' });
|
||||
|
||||
const sent = await notificationService.sendPending(ids);
|
||||
res.json({ message: `Inviate ${sent.length} mail`, sent });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Errore invio' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { generateNotifications, getNotifications, sendNotifications } from './controllers/notification.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', getNotifications);
|
||||
router.post('/generate', generateNotifications);
|
||||
router.post('/send', sendNotifications);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,131 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
export class NotificationService {
|
||||
private transporter;
|
||||
|
||||
constructor() {
|
||||
// For Development: Log only or use Ethereal
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: 'smtp.ethereal.email',
|
||||
port: 587,
|
||||
auth: {
|
||||
user: 'ethereal.user@example.com', // Mock, will fail if used really
|
||||
pass: 'secret'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
return prisma.notification.findMany({
|
||||
include: { company: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async findPending() {
|
||||
return prisma.notification.findMany({
|
||||
where: { status: 'PENDING' }
|
||||
});
|
||||
}
|
||||
|
||||
async processQueue() {
|
||||
const pending = await this.findPending();
|
||||
if (pending.length === 0) return 0;
|
||||
|
||||
console.log(`[NotificationService] Found ${pending.length} pending notifications. Processing...`);
|
||||
const ids = pending.map(n => n.id);
|
||||
const results = await this.sendPending(ids);
|
||||
return results.length;
|
||||
}
|
||||
|
||||
async generateReminders() {
|
||||
// 1. Find Expiring Trainings IN NEXT 30 DAYS that usually happen to be "EXPIRING"
|
||||
const now = new Date();
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setDate(now.getDate() + 30);
|
||||
|
||||
// Logic: Find workers with expiring training
|
||||
const expiringEvents = await prisma.trainingEvent.findMany({
|
||||
where: {
|
||||
expiryDate: {
|
||||
gte: now,
|
||||
lte: nextMonth
|
||||
},
|
||||
// Avoid re-notifying if we already created a notification for this company this batch?
|
||||
// Ideally we group by Company.
|
||||
status: { in: ['VALID', 'EXPIRING'] }
|
||||
},
|
||||
include: {
|
||||
worker: { include: { company: true } },
|
||||
course: true
|
||||
}
|
||||
});
|
||||
|
||||
// Group by Company
|
||||
const companyUpdates = new Map<number, any[]>();
|
||||
for (const event of expiringEvents) {
|
||||
const cid = event.worker.companyId;
|
||||
if (!companyUpdates.has(cid)) {
|
||||
companyUpdates.set(cid, []);
|
||||
}
|
||||
companyUpdates.get(cid)?.push(event);
|
||||
}
|
||||
|
||||
const createdNotifications = [];
|
||||
|
||||
// Create Notification Record for each company
|
||||
for (const [companyId, events] of companyUpdates) {
|
||||
// Check if we already have a PENDING notification for this type regarding these?
|
||||
// Simplified MVP: Just generate a new one if no pending one for "EXPIRING_REMINDER" exists today.
|
||||
|
||||
const companyName = events[0].worker.company.name;
|
||||
const count = events.length;
|
||||
|
||||
const content = `Gentile ${companyName}, vi ricordiamo che n. ${count} corsi sono in scadenza nel prossimo mese. Accedi al portale per i dettagli.`;
|
||||
|
||||
const notif = await prisma.notification.create({
|
||||
data: {
|
||||
companyId,
|
||||
type: 'EXPIRING_REMINDER',
|
||||
subject: `Avviso Scadenze Formazione - ${companyName}`,
|
||||
content: content,
|
||||
status: 'PENDING'
|
||||
}
|
||||
});
|
||||
createdNotifications.push(notif);
|
||||
}
|
||||
|
||||
return createdNotifications;
|
||||
}
|
||||
|
||||
async sendPending(ids: number[]) {
|
||||
// Mock Sending
|
||||
const results = [];
|
||||
for (const id of ids) {
|
||||
const notif = await prisma.notification.findUnique({ where: { id }, include: { company: true } });
|
||||
if (!notif || notif.status !== 'PENDING') continue;
|
||||
|
||||
try {
|
||||
// In prod: await this.transporter.sendMail(...)
|
||||
console.log(`[EMAIL MOCK] Sending to ${notif.company.email || 'no-email@test.com'}: ${notif.subject}`);
|
||||
|
||||
const updated = await prisma.notification.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'SENT',
|
||||
sentAt: new Date()
|
||||
}
|
||||
});
|
||||
results.push(updated);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
await prisma.notification.update({ where: { id }, data: { status: 'FAILED' } });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
62
src/backend/src/modules/sites/controllers/site.controller.ts
Normal file
62
src/backend/src/modules/sites/controllers/site.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { siteService } from '../services/site.service';
|
||||
|
||||
export const getSites = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyId = req.query.companyId ? Number(req.query.companyId) : undefined;
|
||||
const sites = await siteService.findAll(companyId);
|
||||
res.json(sites);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Errore nel recupero delle sedi' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getSiteOfCompany = async (req: Request, res: Response) => {
|
||||
// This might be redundant if getSites handles query params, but could be specific route
|
||||
// skipping for now, using getSites with query param
|
||||
};
|
||||
|
||||
export const getSiteById = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const site = await siteService.findOne(Number(req.params.id));
|
||||
if (!site) return res.status(404).json({ error: 'Sede non trovata' });
|
||||
res.json(site);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Errore nel recupero della sede' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createSite = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, address, city, companyId } = req.body;
|
||||
const site = await siteService.create({
|
||||
name,
|
||||
address,
|
||||
city,
|
||||
company: { connect: { id: Number(companyId) } }
|
||||
});
|
||||
res.status(201).json(site);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Errore nella creazione della sede' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSite = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const site = await siteService.update(Number(req.params.id), req.body);
|
||||
res.json(site);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Errore nell\'aggiornamento della sede' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSite = async (req: Request, res: Response) => {
|
||||
try {
|
||||
await siteService.remove(Number(req.params.id));
|
||||
res.json({ message: 'Sede eliminata' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Errore nell\'eliminazione della sede' });
|
||||
}
|
||||
};
|
||||
41
src/backend/src/modules/sites/services/site.service.ts
Normal file
41
src/backend/src/modules/sites/services/site.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export class SiteService {
|
||||
async create(data: Prisma.SiteCreateInput) {
|
||||
return prisma.site.create({ data });
|
||||
}
|
||||
|
||||
async findAll(companyId?: number) {
|
||||
const where = companyId ? { companyId, isActive: true } : { isActive: true };
|
||||
return prisma.site.findMany({
|
||||
where,
|
||||
include: { company: true }
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
return prisma.site.findUnique({
|
||||
where: { id },
|
||||
include: { company: true }
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, data: Prisma.SiteUpdateInput) {
|
||||
return prisma.site.update({
|
||||
where: { id },
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
// Soft delete
|
||||
return prisma.site.update({
|
||||
where: { id },
|
||||
data: { isActive: false }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const siteService = new SiteService();
|
||||
13
src/backend/src/modules/sites/sites.routes.ts
Normal file
13
src/backend/src/modules/sites/sites.routes.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createSite, deleteSite, getSiteById, getSites, updateSite } from './controllers/site.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', getSites);
|
||||
router.get('/:id', getSiteById);
|
||||
router.post('/', createSite);
|
||||
router.put('/:id', updateSite);
|
||||
router.delete('/:id', deleteSite);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { TrainingEventService } from '../services/training.service';
|
||||
|
||||
const trainingService = new TrainingEventService();
|
||||
|
||||
export class TrainingController {
|
||||
async getAll(req: Request, res: Response) {
|
||||
try {
|
||||
const filters = {
|
||||
workerId: req.query.workerId ? parseInt(req.query.workerId as string) : undefined
|
||||
};
|
||||
const events = await trainingService.findAll(filters);
|
||||
res.json(events);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch training events' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response) {
|
||||
try {
|
||||
const { workerId, courseId, eventDate, completedDate, provider, notes } = req.body;
|
||||
const dateToUse = eventDate || completedDate;
|
||||
|
||||
if (!workerId || !courseId || !dateToUse) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
const event = await trainingService.createWithCalculation(
|
||||
parseInt(workerId),
|
||||
parseInt(courseId),
|
||||
new Date(dateToUse),
|
||||
provider,
|
||||
undefined, // fileUrl
|
||||
notes
|
||||
);
|
||||
res.status(201).json(event);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(400).json({ error: 'Failed to create training event' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export class TrainingEventService {
|
||||
async findAll(filters: { workerId?: number; expiringBefore?: Date }) {
|
||||
const where: Prisma.TrainingEventWhereInput = {};
|
||||
|
||||
if (filters.workerId) {
|
||||
where.workerId = filters.workerId;
|
||||
}
|
||||
|
||||
if (filters.expiringBefore) {
|
||||
where.expiryDate = {
|
||||
lte: filters.expiringBefore
|
||||
};
|
||||
where.status = { not: 'EXPIRED' }; // Only show active items that are expiring
|
||||
}
|
||||
|
||||
return prisma.trainingEvent.findMany({
|
||||
where,
|
||||
include: {
|
||||
worker: {
|
||||
select: { firstName: true, lastName: true, company: { select: { name: true } } }
|
||||
},
|
||||
course: {
|
||||
select: { title: true, validityYears: true }
|
||||
}
|
||||
},
|
||||
orderBy: { expiryDate: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.TrainingEventCreateInput) {
|
||||
// 1. Calculate Expiry Date automatically
|
||||
// Need to fetch course to get validityYears if not provided or to verify
|
||||
// For now assuming data passed might need enrichment.
|
||||
|
||||
// Better: Controller fetches course, then passes proper data.
|
||||
// Or we do it here.
|
||||
|
||||
// Simplification for MVP: We assume ExpiryDate is passed OR we calculate it if courseId is present.
|
||||
// However, Prisma CreateInput is strict.
|
||||
// Let's rely on the controller or frontend to send proper dates for now to keep service simple,
|
||||
// or fetch course here.
|
||||
|
||||
return prisma.trainingEvent.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
async createWithCalculation(workerId: number, courseId: number, eventDate: Date, provider?: string, fileUrl?: string, notes?: string) {
|
||||
const course = await prisma.course.findUnique({ where: { id: courseId } });
|
||||
if (!course) throw new Error("Course not found");
|
||||
|
||||
// Calculate Expiry
|
||||
const expiryDate = new Date(eventDate);
|
||||
expiryDate.setFullYear(expiryDate.getFullYear() + course.validityYears);
|
||||
|
||||
return prisma.trainingEvent.create({
|
||||
data: {
|
||||
worker: { connect: { id: workerId } },
|
||||
course: { connect: { id: courseId } },
|
||||
eventDate: eventDate,
|
||||
expiryDate: expiryDate,
|
||||
provider,
|
||||
certificateUrl: fileUrl,
|
||||
status: 'VALID',
|
||||
notes
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
11
src/backend/src/modules/training/training.routes.ts
Normal file
11
src/backend/src/modules/training/training.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { TrainingController } from './controllers/training.controller';
|
||||
|
||||
const router = Router();
|
||||
const controller = new TrainingController();
|
||||
|
||||
router.get('/', controller.getAll);
|
||||
router.post('/', controller.create);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { WorkerService } from '../services/worker.service';
|
||||
|
||||
const workerService = new WorkerService();
|
||||
|
||||
export class WorkerController {
|
||||
async getAll(req: Request, res: Response) {
|
||||
try {
|
||||
const filters = {
|
||||
companyId: req.query.companyId ? parseInt(req.query.companyId as string) : undefined,
|
||||
search: req.query.search ? (req.query.search as string) : undefined
|
||||
};
|
||||
|
||||
const workers = await workerService.findAll(filters);
|
||||
res.json(workers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch workers' });
|
||||
}
|
||||
}
|
||||
|
||||
async getOne(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const worker = await workerService.findOne(id);
|
||||
if (!worker) {
|
||||
return res.status(404).json({ error: 'Worker not found' });
|
||||
}
|
||||
res.json(worker);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch worker' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response) {
|
||||
try {
|
||||
const worker = await workerService.create(req.body);
|
||||
res.status(201).json(worker);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(400).json({ error: 'Failed to create worker' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const worker = await workerService.update(id, req.body);
|
||||
res.json(worker);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: 'Failed to update worker' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
await workerService.delete(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete worker' });
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/backend/src/modules/workers/services/worker.service.ts
Normal file
67
src/backend/src/modules/workers/services/worker.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import prisma from '../../../lib/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export class WorkerService {
|
||||
async findAll(filters: { companyId?: number, search?: string }) {
|
||||
const where: Prisma.WorkerWhereInput = {
|
||||
isActive: true
|
||||
};
|
||||
|
||||
if (filters.companyId) {
|
||||
where.companyId = filters.companyId;
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: filters.search } },
|
||||
{ lastName: { contains: filters.search } },
|
||||
{ taxCode: { contains: filters.search } }
|
||||
];
|
||||
}
|
||||
|
||||
return prisma.worker.findMany({
|
||||
where,
|
||||
include: {
|
||||
company: {
|
||||
select: { name: true }
|
||||
},
|
||||
site: {
|
||||
select: { name: true }
|
||||
}
|
||||
},
|
||||
orderBy: { lastName: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
return prisma.worker.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
company: true,
|
||||
site: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.WorkerCreateInput) {
|
||||
return prisma.worker.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, data: Prisma.WorkerUpdateInput) {
|
||||
return prisma.worker.update({
|
||||
where: { id },
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: number) {
|
||||
// Soft delete usually better, but for now hard delete or status update
|
||||
return prisma.worker.update({
|
||||
where: { id },
|
||||
data: { isActive: false }
|
||||
});
|
||||
}
|
||||
}
|
||||
14
src/backend/src/modules/workers/workers.routes.ts
Normal file
14
src/backend/src/modules/workers/workers.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { WorkerController } from './controllers/worker.controller';
|
||||
|
||||
const router = Router();
|
||||
const controller = new WorkerController();
|
||||
|
||||
router.get('/', controller.getAll);
|
||||
router.get('/:id', controller.getOne);
|
||||
router.post('/', controller.create);
|
||||
router.put('/:id', controller.update);
|
||||
router.delete('/:id', controller.delete);
|
||||
|
||||
export default router;
|
||||
33
src/backend/src/scheduler.ts
Normal file
33
src/backend/src/scheduler.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
import cron from 'node-cron';
|
||||
import { notificationService } from './modules/notifications/services/notification.service';
|
||||
|
||||
export function startScheduler() {
|
||||
console.log('[Scheduler] Initializing notification scheduler...');
|
||||
|
||||
// 1. Generate Reminders every night at 02:00
|
||||
cron.schedule('0 2 * * *', async () => {
|
||||
console.log('[Scheduler] Running nightly reminder generation...');
|
||||
try {
|
||||
const generated = await notificationService.generateReminders();
|
||||
console.log(`[Scheduler] Generated ${generated.length} new notifications.`);
|
||||
} catch (err) {
|
||||
console.error('[Scheduler] Error generating reminders:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Process Email Queue every hour
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
console.log('[Scheduler] Checking for pending notifications...');
|
||||
try {
|
||||
const sentCount = await notificationService.processQueue();
|
||||
if (sentCount > 0) {
|
||||
console.log(`[Scheduler] Successfully sent/processed ${sentCount} notifications.`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Scheduler] Error sending pending emails:', err);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Scheduler] Tasks scheduled: Nightly Generation (02:00) & Hourly Queue Processing.');
|
||||
}
|
||||
25
src/backend/tsconfig.json
Normal file
25
src/backend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"lib": [
|
||||
"es2020"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
24
src/frontend/.gitignore
vendored
Normal file
24
src/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
21
src/frontend/Dockerfile
Normal file
21
src/frontend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
# Default nginx config is usually fine for SPA if we add fallback logic
|
||||
# Creating a custom simple config inline or separate file is better.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
73
src/frontend/README.md
Normal file
73
src/frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
src/frontend/eslint.config.js
Normal file
23
src/frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
src/frontend/index.html
Normal file
13
src/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
src/frontend/nginx.conf
Normal file
20
src/frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
4232
src/frontend/package-lock.json
generated
Normal file
4232
src/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
src/frontend/package.json
Normal file
37
src/frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.6",
|
||||
"@mui/material": "^7.3.6",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-router-dom": "^7.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
1
src/frontend/public/vite.svg
Normal file
1
src/frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
src/frontend/src/App.css
Normal file
42
src/frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
46
src/frontend/src/App.tsx
Normal file
46
src/frontend/src/App.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Layout } from './modules/shared/components/Layout';
|
||||
import { CompaniesPage } from './modules/companies/CompaniesPage';
|
||||
import { CompanyDetailPage } from './modules/companies/CompanyDetailPage';
|
||||
import { WorkersPage } from './modules/workers/WorkersPage';
|
||||
import { WorkerDetailPage } from './modules/workers/WorkerDetailPage';
|
||||
import { CoursesPage } from './modules/courses/CoursesPage';
|
||||
import { DashboardPage } from './modules/dashboard/DashboardPage';
|
||||
import { DeadlinesPage } from './modules/deadlines/DeadlinesPage';
|
||||
import { CommunicationsPage } from './modules/notifications/CommunicationsPage';
|
||||
|
||||
import { AuthProvider } from './modules/auth/AuthContext';
|
||||
import { LoginPage } from './modules/auth/LoginPage';
|
||||
import { PrivateRoute } from './modules/auth/PrivateRoute';
|
||||
|
||||
import { ImportPage } from './modules/import/ImportPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
<Route element={<PrivateRoute />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="deadlines" element={<DeadlinesPage />} />
|
||||
<Route path="notifications" element={<CommunicationsPage />} />
|
||||
<Route path="companies" element={<CompaniesPage />} />
|
||||
<Route path="companies/:id" element={<CompanyDetailPage />} />
|
||||
<Route path="workers" element={<WorkersPage />} />
|
||||
<Route path="workers/:id" element={<WorkerDetailPage />} />
|
||||
<Route path="courses" element={<CoursesPage />} />
|
||||
<Route path="import" element={<ImportPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
src/frontend/src/assets/react.svg
Normal file
1
src/frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
267
src/frontend/src/index.css
Normal file
267
src/frontend/src/index.css
Normal file
@@ -0,0 +1,267 @@
|
||||
/* Google Fonts Import */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
/* Brand Colors - Premium Dark Mode Palette */
|
||||
--color-primary-50: #eef2ff;
|
||||
--color-primary-100: #e0e7ff;
|
||||
--color-primary-200: #c7d2fe;
|
||||
--color-primary-300: #a5b4fc;
|
||||
--color-primary-400: #818cf8;
|
||||
--color-primary-500: #6366f1; /* Indigo 500 */
|
||||
--color-primary-600: #4f46e5;
|
||||
--color-primary-700: #4338ca;
|
||||
|
||||
--color-accent-500: #ec4899; /* Pink 500 - for vibrancy */
|
||||
--color-accent-600: #db2777;
|
||||
|
||||
/* Backgrounds */
|
||||
--color-bg-app: #0f172a; /* Slate 900 */
|
||||
--color-bg-card: #1e293b; /* Slate 800 */
|
||||
--color-bg-card-hover: #334155; /* Slate 700 */
|
||||
|
||||
/* Text */
|
||||
--color-text-main: #f8fafc; /* Slate 50 */
|
||||
--color-text-secondary: #cbd5e1; /* Slate 300 */
|
||||
--color-text-muted: #94a3b8; /* Slate 400 */
|
||||
|
||||
/* Borders */
|
||||
--color-border: #334155; /* Slate 700 */
|
||||
--color-border-subtle: #1e293b;
|
||||
|
||||
/* Status */
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #3b82f6;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3); /* Primary Glow */
|
||||
|
||||
/* Glassmorphism */
|
||||
--glass-bg: rgba(30, 41, 59, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--glass-blur: 16px;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-display: 'Outfit', sans-serif;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-app);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-bg-app);
|
||||
color: var(--color-text-main);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-main);
|
||||
letter-spacing: -0.025em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 2.25rem; }
|
||||
h2 { font-size: 1.875rem; }
|
||||
h3 { font-size: 1.5rem; }
|
||||
|
||||
/* Utility Classes */
|
||||
.layout-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(to bottom right, var(--color-bg-app), #020617);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background-color: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border-right: 1px solid var(--glass-border);
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2.5rem;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Glass Panels & Cards */
|
||||
.card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 1.5rem;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
/* transform: translateY(-2px); */
|
||||
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
||||
border-color: var(--color-primary-500);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 0.95rem;
|
||||
transition: all var(--transition-fast);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600));
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px rgba(79, 70, 229, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--color-primary-400), var(--color-primary-500));
|
||||
box-shadow: 0 8px 12px rgba(79, 70, 229, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-card-hover);
|
||||
color: var(--color-text-main);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
/* Navigation Links */
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: linear-gradient(90deg, rgba(99, 102, 241, 0.15), transparent);
|
||||
color: var(--color-primary-400);
|
||||
border-left: 3px solid var(--color-primary-500);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
padding: 1rem 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
10
src/frontend/src/main.tsx
Normal file
10
src/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
72
src/frontend/src/modules/auth/AuthContext.tsx
Normal file
72
src/frontend/src/modules/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (token: string, userData: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial token
|
||||
const token = localStorage.getItem('token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (token && storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
// Optional: Validate token with backend /api/auth/me
|
||||
} catch (e) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = (token: string, userData: User) => {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
setUser(userData);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setUser(null);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
118
src/frontend/src/modules/auth/LoginPage.tsx
Normal file
118
src/frontend/src/modules/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import api from '../shared/utils/api';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Per MVP o test, register function might be exposed or hidden
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isRegistering) {
|
||||
// Register flow
|
||||
await api.post('/auth/register', { email, password, name });
|
||||
alert('Registrazione completata. Effettua il login.');
|
||||
setIsRegistering(false);
|
||||
} else {
|
||||
// Login flow
|
||||
const res = await api.post('/auth/login', { email, password });
|
||||
const { token, user } = res.data;
|
||||
login(token, user);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.response?.data?.error || 'Errore durante l\'autenticazione');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#09090b]">
|
||||
<div className="w-full max-w-md p-8 rounded-2xl bg-white/5 border border-white/10 backdrop-blur-xl shadow-2xl">
|
||||
<h1 className="text-3xl font-bold text-center mb-2 bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
||||
Zentral Safety
|
||||
</h1>
|
||||
<p className="text-center text-gray-400 mb-8">
|
||||
{isRegistering ? 'Crea un nuovo account' : 'Accedi al gestionale'}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/50 text-red-200 p-3 rounded-lg text-sm mb-6 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isRegistering && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Nome</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-black/20 border border-white/10 rounded-lg p-3 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="Il tuo nome"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-black/20 border border-white/10 rounded-lg p-3 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="name@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-black/20 border border-white/10 rounded-lg p-3 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 rounded-lg font-medium bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50 mt-4"
|
||||
>
|
||||
{loading ? 'Caricamento...' : (isRegistering ? 'Registrati' : 'Accedi')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
{isRegistering ? 'Hai già un account? ' : 'Non hai un account? '}
|
||||
<button
|
||||
onClick={() => { setIsRegistering(!isRegistering); setError(''); }}
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
{isRegistering ? 'Accedi' : 'Contatta l\'amministratore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/frontend/src/modules/auth/PrivateRoute.tsx
Normal file
11
src/frontend/src/modules/auth/PrivateRoute.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export function PrivateRoute() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) return <div className="min-h-screen bg-[#09090b] flex items-center justify-center text-gray-500">Verifica credenziali...</div>;
|
||||
|
||||
return user ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
153
src/frontend/src/modules/companies/CompaniesPage.tsx
Normal file
153
src/frontend/src/modules/companies/CompaniesPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../shared/utils/api';
|
||||
import { CompanyForm, type Company as CompanyFormData } from './components/CompanyForm';
|
||||
import { Modal } from '../shared/components/Modal';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Interfaccia estesa per la visualizzazione (con i count)
|
||||
interface Company extends CompanyFormData {
|
||||
id: number;
|
||||
_count?: {
|
||||
workers: number;
|
||||
sites: number;
|
||||
}
|
||||
}
|
||||
|
||||
export function CompaniesPage() {
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCompany, setEditingCompany] = useState<Company | undefined>(undefined);
|
||||
|
||||
const fetchCompanies = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.get('/companies');
|
||||
setCompanies(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCompanies();
|
||||
}, []);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditingCompany(undefined);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (company: Company) => {
|
||||
setEditingCompany(company);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
setIsModalOpen(false);
|
||||
fetchCompanies();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<h1>Aziende Clienti</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)' }}>Gestisci le anagrafiche, sedi e lavoratori</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleCreateNew}>
|
||||
<span>+</span> Nuova Azienda
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center' }} className="loading">
|
||||
Caricamento anagrafiche...
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ragione Sociale</th>
|
||||
<th>P.IVA / CF</th>
|
||||
<th>Email</th>
|
||||
<th>Dipendenti</th>
|
||||
<th>Sedi</th>
|
||||
<th style={{ textAlign: 'right' }}>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{companies.map(company => (
|
||||
<tr key={company.id}>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<Link to={`/companies/${company.id}`} style={{ color: 'var(--color-primary-400)', textDecoration: 'none' }}>
|
||||
{company.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: '0.9em' }}>{company.vatNumber}</td>
|
||||
<td>{company.email}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
padding: '0.2rem 0.6rem',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: '0.85rem'
|
||||
}}>
|
||||
{company._count?.workers || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
padding: '0.2rem 0.6rem',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: '0.85rem'
|
||||
}}>
|
||||
{company._count?.sites || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '0.4rem 0.8rem', fontSize: '0.85rem' }}
|
||||
onClick={() => handleEdit(company)}
|
||||
>
|
||||
Modifica
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{companies.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '4rem', textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: '1rem', opacity: 0.5 }}>🏢</div>
|
||||
<div>Nessuna azienda presente.</div>
|
||||
<div style={{ fontSize: '0.9rem', marginTop: '0.5rem' }}>Clicca su "Nuova Azienda" per inserirne una.</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={editingCompany ? `Modifica ${editingCompany.name}` : 'Nuova Azienda'}
|
||||
>
|
||||
<CompanyForm
|
||||
company={editingCompany}
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
src/frontend/src/modules/companies/CompanyDetailPage.tsx
Normal file
185
src/frontend/src/modules/companies/CompanyDetailPage.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import api from '../shared/utils/api';
|
||||
import { Modal } from '../shared/components/Modal';
|
||||
import { SiteForm, type Site } from './components/SiteForm';
|
||||
import { CompanyForm } from './components/CompanyForm';
|
||||
|
||||
interface CompanyDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
vatNumber?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
sites: Site[];
|
||||
}
|
||||
|
||||
export function CompanyDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [company, setCompany] = useState<CompanyDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Modal states
|
||||
const [isSiteModalOpen, setIsSiteModalOpen] = useState(false);
|
||||
const [editingSite, setEditingSite] = useState<Site | undefined>(undefined);
|
||||
const [isEditCompanyModalOpen, setIsEditCompanyModalOpen] = useState(false);
|
||||
|
||||
const fetchCompany = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.get(`/companies/${id}`);
|
||||
setCompany(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (id) fetchCompany();
|
||||
}, [id]);
|
||||
|
||||
const handleCreateSite = () => {
|
||||
setEditingSite(undefined);
|
||||
setIsSiteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSite = (site: Site) => {
|
||||
setEditingSite(site);
|
||||
setIsSiteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSiteSuccess = () => {
|
||||
setIsSiteModalOpen(false);
|
||||
fetchCompany();
|
||||
};
|
||||
|
||||
const handleDeleteSite = async (siteId: number) => {
|
||||
if (!confirm('Sei sicuro di voler eliminare questa sede?')) return;
|
||||
try {
|
||||
await api.delete(`/sites/${siteId}`);
|
||||
fetchCompany();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Errore eliminazione sede');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompanyUpdateSuccess = () => {
|
||||
setIsEditCompanyModalOpen(false);
|
||||
fetchCompany();
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-gray-400">Caricamento azienda...</div>;
|
||||
if (!company) return <div className="p-8 text-center text-red-400">Azienda non trovata</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Azienda */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<button onClick={() => navigate('/companies')} className="text-sm text-gray-400 hover:text-white mb-2 flex items-center gap-1">
|
||||
← Torna alla lista
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-1">
|
||||
{company.name}
|
||||
</h1>
|
||||
<p className="text-gray-400">{company.city} {company.address && `• ${company.address}`}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsEditCompanyModalOpen(true)}
|
||||
className="btn btn-ghost border border-white/10"
|
||||
>
|
||||
Modifica Dati
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Card Info */}
|
||||
<div className="card md:col-span-1 h-fit">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 border-b border-white/10 pb-2">Dettagli</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 block">P.IVA / CF</span>
|
||||
<span className="text-white font-mono">{company.vatNumber || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 block">Email</span>
|
||||
<span className="text-white">{company.email || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 block">Telefono</span>
|
||||
<span className="text-white">{company.phone || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Sedi */}
|
||||
<div className="card md:col-span-2">
|
||||
<div className="flex justify-between items-center mb-4 border-b border-white/10 pb-2">
|
||||
<h2 className="text-lg font-semibold text-white">Sedi Operative</h2>
|
||||
<button
|
||||
onClick={handleCreateSite}
|
||||
className="text-xs px-2 py-1 bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors"
|
||||
>
|
||||
+ Aggiungi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{company.sites && company.sites.filter(s => s.isActive).length > 0 ? (
|
||||
company.sites.filter(s => s.isActive).map(site => (
|
||||
<div key={site.id} className="flex items-center justify-between p-3 bg-white/5 rounded-lg group hover:bg-white/10 transition-colors border border-white/5 hover:border-white/10">
|
||||
<div>
|
||||
<div className="font-medium text-white">{site.name}</div>
|
||||
<div className="text-xs text-gray-400">{site.city} {site.address && `• ${site.address}`}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => handleEditSite(site)} className="text-xs text-blue-400 hover:text-blue-300">Modifica</button>
|
||||
<button onClick={() => site.id && handleDeleteSite(site.id)} className="text-xs text-red-400 hover:text-red-300">Elimina</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center py-4 text-gray-500 text-sm">Nessuna sede operativa registrata.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isSiteModalOpen}
|
||||
onClose={() => setIsSiteModalOpen(false)}
|
||||
title={editingSite ? 'Modifica Sede' : 'Nuova Sede'}
|
||||
>
|
||||
<SiteForm
|
||||
companyId={company.id}
|
||||
site={editingSite}
|
||||
onSuccess={handleSiteSuccess}
|
||||
onCancel={() => setIsSiteModalOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={isEditCompanyModalOpen}
|
||||
onClose={() => setIsEditCompanyModalOpen(false)}
|
||||
title="Modifica Azienda"
|
||||
>
|
||||
{/* Note: I need to adapt CompanyForm to accept initial data correctly or use the backend data directly.
|
||||
CompanyForm expects 'company' prop which matches the interface.
|
||||
The 'company' state here has 'sites' which Company in CompanyForm might not expect, but extra props are usually locally ignored or we cast.
|
||||
*/}
|
||||
<CompanyForm
|
||||
company={company as any}
|
||||
onSuccess={handleCompanyUpdateSuccess}
|
||||
onCancel={() => setIsEditCompanyModalOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/frontend/src/modules/companies/components/CompanyForm.tsx
Normal file
150
src/frontend/src/modules/companies/components/CompanyForm.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../shared/utils/api';
|
||||
|
||||
export interface Company {
|
||||
id?: number;
|
||||
name: string;
|
||||
vatNumber: string;
|
||||
address?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface CompanyFormProps {
|
||||
company?: Company;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CompanyForm: React.FC<CompanyFormProps> = ({ company, onSuccess, onCancel }) => {
|
||||
const [formData, setFormData] = useState<Company>({
|
||||
name: '',
|
||||
vatNumber: '',
|
||||
address: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (company) {
|
||||
setFormData(company);
|
||||
}
|
||||
}, [company]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (company?.id) {
|
||||
await api.put(`/companies/${company.id}`, formData);
|
||||
} else {
|
||||
await api.post('/companies', formData);
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to save company', err);
|
||||
setError('Errore durante il salvataggio. Verifica i dati.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/50 text-red-200 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Ragione Sociale *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
placeholder="Es. Acme S.r.l."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Partita IVA / C.F. *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="vatNumber"
|
||||
required
|
||||
value={formData.vatNumber}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
placeholder="IT00000000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Indirizzo Sede Legale</label>
|
||||
<input
|
||||
type="text"
|
||||
name="address"
|
||||
value={formData.address || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
placeholder="Via Roma 1, Milano"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
placeholder="info@acme.it"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Telefono</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
placeholder="+39 02 123456"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-gray-300 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Salvataggio...' : (company?.id ? 'Aggiorna' : 'Crea Azienda')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
118
src/frontend/src/modules/companies/components/SiteForm.tsx
Normal file
118
src/frontend/src/modules/companies/components/SiteForm.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../shared/utils/api';
|
||||
|
||||
export interface Site {
|
||||
id?: number;
|
||||
name: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
companyId: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
|
||||
interface SiteFormProps {
|
||||
companyId: number;
|
||||
site?: Site;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const SiteForm: React.FC<SiteFormProps> = ({ companyId, site, onSuccess, onCancel }) => {
|
||||
const [formData, setFormData] = useState<Site>({
|
||||
name: '',
|
||||
address: '',
|
||||
city: '',
|
||||
companyId: companyId
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (site) {
|
||||
setFormData(site);
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, companyId }));
|
||||
}
|
||||
}, [site, companyId]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (site?.id) {
|
||||
await api.put(`/sites/${site.id}`, formData);
|
||||
} else {
|
||||
await api.post('/sites', formData);
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Errore salvataggio sede');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Nome Sede *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
placeholder="Es. Sede Principale, Magazzino A"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Indirizzo</label>
|
||||
<input
|
||||
type="text"
|
||||
name="address"
|
||||
value={formData.address || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Città</label>
|
||||
<input
|
||||
type="text"
|
||||
name="city"
|
||||
value={formData.city || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-gray-300 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Salvataggio...' : (site?.id ? 'Aggiorna' : 'Aggiungi Sede')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
181
src/frontend/src/modules/courses/CoursesPage.tsx
Normal file
181
src/frontend/src/modules/courses/CoursesPage.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../shared/utils/api';
|
||||
import { CourseForm, type Course as CourseFormData } from './components/CourseForm';
|
||||
import { Modal } from '../shared/components/Modal';
|
||||
|
||||
interface Course extends CourseFormData {
|
||||
id: number;
|
||||
isSafety?: boolean;
|
||||
}
|
||||
|
||||
export function CoursesPage() {
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.get('/courses');
|
||||
setCourses(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses();
|
||||
}, []);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditingCourse(undefined);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (course: Course) => {
|
||||
setEditingCourse(course);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
setIsModalOpen(false);
|
||||
fetchCourses();
|
||||
};
|
||||
|
||||
const filteredCourses = courses.filter(c =>
|
||||
c.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.code.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
background: 'var(--color-bg-app)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '0.6rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-main)',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h1>Catalogo Corsi</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '1.1rem' }}>Gestione tipologie formative e validità</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleCreateNew}>
|
||||
<span>+</span> Nuovo Corso
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="card" style={{ padding: '1.5rem' }}>
|
||||
<h3 style={{ fontSize: '1rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
🔍 Filtri
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' }}>Cerca Corso</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Titolo o Codice..."
|
||||
style={inputStyle}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{loading ? (
|
||||
<div className="loading" style={{ padding: '3rem', textAlign: 'center' }}>Caricamento corsi...</div>
|
||||
) : (
|
||||
<div className="table-container" style={{ border: 'none', borderRadius: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Codice</th>
|
||||
<th>Titolo Corso</th>
|
||||
<th>Durata (Anni)</th>
|
||||
<th>Tipo</th>
|
||||
<th style={{ textAlign: 'right' }}>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCourses.map(course => (
|
||||
<tr key={course.id}>
|
||||
<td style={{ fontFamily: 'monospace', color: 'var(--color-primary-400)', fontWeight: 600 }}>{course.code}</td>
|
||||
<td style={{ fontWeight: 500, color: 'var(--color-text-main)' }}>{course.title}</td>
|
||||
<td style={{ color: 'var(--color-text-secondary)' }}>
|
||||
<span style={{
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
padding: '0.2rem 0.6rem',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: '0.85rem'
|
||||
}}>
|
||||
{course.validityYears} Anni
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
backgroundColor: course.isSafety !== false ? 'rgba(99, 102, 241, 0.1)' : 'rgba(255, 255, 255, 0.05)',
|
||||
color: course.isSafety !== false ? 'var(--color-primary-400)' : 'var(--color-text-muted)',
|
||||
border: course.isSafety !== false ? '1px solid rgba(99, 102, 241, 0.2)' : '1px solid var(--color-border)'
|
||||
}}
|
||||
>
|
||||
{course.isSafety !== false ? 'SICUREZZA' : 'GENERICO'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }}
|
||||
onClick={() => handleEdit(course)}
|
||||
>
|
||||
Modifica
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredCourses.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '4rem', textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: '1rem', opacity: 0.5 }}>🎓</div>
|
||||
<div>Nessun corso trovato nel catalogo.</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={editingCourse ? `Modifica ${editingCourse.code}` : 'Nuovo Corso'}
|
||||
>
|
||||
<CourseForm
|
||||
course={editingCourse}
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/frontend/src/modules/courses/components/CourseForm.tsx
Normal file
139
src/frontend/src/modules/courses/components/CourseForm.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../shared/utils/api';
|
||||
|
||||
export interface Course {
|
||||
id?: number;
|
||||
title: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
validityYears: number;
|
||||
}
|
||||
|
||||
interface CourseFormProps {
|
||||
course?: Course;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CourseForm: React.FC<CourseFormProps> = ({ course, onSuccess, onCancel }) => {
|
||||
const [formData, setFormData] = useState<Course>({
|
||||
title: '',
|
||||
code: '',
|
||||
description: '',
|
||||
validityYears: 5 // Default
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (course) {
|
||||
setFormData(course);
|
||||
}
|
||||
}, [course]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: name === 'validityYears' ? Number(value) : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (course?.id) {
|
||||
await api.put(`/courses/${course.id}`, formData);
|
||||
} else {
|
||||
await api.post('/courses', formData);
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Errore salvataggio corso.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/50 text-red-200 p-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Titolo Corso *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
required
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
placeholder="Es. Addetto Antincendio"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Codice *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
required
|
||||
value={formData.code}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
placeholder="Es. SIC-001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Validità (Anni) *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
name="validityYears"
|
||||
required
|
||||
value={formData.validityYears}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">Descrizione</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
value={formData.description || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-gray-300 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Salvataggio...' : (course?.id ? 'Aggiorna' : 'Crea Corso')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
195
src/frontend/src/modules/dashboard/DashboardPage.tsx
Normal file
195
src/frontend/src/modules/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../shared/utils/api';
|
||||
|
||||
interface DashboardStats {
|
||||
totalWorkers: number;
|
||||
totalCompanies: number;
|
||||
totalSites: number;
|
||||
expiredTrainings: number;
|
||||
expiringTrainings: number;
|
||||
validTrainings: number;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await api.get('/dashboard/stats');
|
||||
setStats(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="loading" style={{ padding: '3rem', textAlign: 'center' }}>Caricamento dashboard...</div>;
|
||||
if (!stats) return <div style={{ padding: '3rem', textAlign: 'center', color: 'var(--color-error)' }}>Errore caricamento dati dashboard</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2.5rem' }}>
|
||||
<div>
|
||||
<h1>Dashboard Operativa</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '1.1rem' }}>Panoramica dello stato di conformità e anagrafiche</p>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1.5rem' }}>
|
||||
<StatCard
|
||||
title="Lavoratori Totali"
|
||||
value={stats.totalWorkers}
|
||||
icon="👥"
|
||||
variant="primary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Aziende Clienti"
|
||||
value={stats.totalCompanies}
|
||||
icon="🏢"
|
||||
variant="info"
|
||||
/>
|
||||
<StatCard
|
||||
title="Sedi Operative"
|
||||
value={stats.totalSites}
|
||||
icon="📍"
|
||||
variant="default"
|
||||
subtext="Sedi attive monitorate"
|
||||
/>
|
||||
<StatCard
|
||||
title="Stato Sistema"
|
||||
value="OK"
|
||||
icon="✅"
|
||||
variant="success"
|
||||
isText
|
||||
subtext="Tutti i servizi operativi"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span>🎓</span> Stato Formazione
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '1.5rem' }}>
|
||||
<StatCard
|
||||
title="Formazione Scaduta"
|
||||
value={stats.expiredTrainings}
|
||||
icon="⚠️"
|
||||
variant="danger"
|
||||
subtext="Attestati da rinnovare immediatamente"
|
||||
/>
|
||||
<StatCard
|
||||
title="In Scadenza (30gg)"
|
||||
value={stats.expiringTrainings}
|
||||
icon="⏳"
|
||||
variant="warning"
|
||||
subtext="Da pianificare a breve"
|
||||
/>
|
||||
<StatCard
|
||||
title="Corsi Validi"
|
||||
value={stats.validTrainings}
|
||||
icon="✨"
|
||||
variant="success"
|
||||
subtext="Copertura attiva"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: string;
|
||||
variant: 'primary' | 'info' | 'default' | 'success' | 'danger' | 'warning';
|
||||
subtext?: string;
|
||||
isText?: boolean;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, variant, subtext }: StatCardProps) {
|
||||
const getStyles = () => {
|
||||
switch (variant) {
|
||||
case 'primary': return {
|
||||
bg: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(99, 102, 241, 0.05))',
|
||||
border: 'var(--color-primary-500)',
|
||||
text: 'var(--color-primary-400)'
|
||||
};
|
||||
case 'info': return {
|
||||
bg: 'linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(14, 165, 233, 0.05))',
|
||||
border: '#0EA5E9',
|
||||
text: '#38BDF8'
|
||||
};
|
||||
case 'success': return {
|
||||
bg: 'linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(16, 185, 129, 0.05))',
|
||||
border: 'var(--color-success)',
|
||||
text: '#34D399'
|
||||
};
|
||||
case 'warning': return {
|
||||
bg: 'linear-gradient(135deg, rgba(245, 158, 11, 0.1), rgba(245, 158, 11, 0.05))',
|
||||
border: 'var(--color-warning)',
|
||||
text: '#FBBF24'
|
||||
};
|
||||
case 'danger': return {
|
||||
bg: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1), rgba(239, 68, 68, 0.05))',
|
||||
border: 'var(--color-error)',
|
||||
text: '#F87171'
|
||||
};
|
||||
default: return {
|
||||
bg: 'var(--color-bg-card)',
|
||||
border: 'var(--color-border)',
|
||||
text: 'var(--color-text-muted)'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
background: styles.bg,
|
||||
borderColor: styles.border,
|
||||
borderWidth: variant === 'default' ? '1px' : '0 0 0 4px', // Left border accent
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
|
||||
<div style={{
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', opacity: 0.8 }}>{icon}</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-main)',
|
||||
marginBottom: '0.5rem',
|
||||
lineHeight: 1
|
||||
}}>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{subtext && (
|
||||
<div style={{ fontSize: '0.85rem', color: styles.text, fontWeight: 500 }}>
|
||||
{subtext}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
278
src/frontend/src/modules/deadlines/DeadlinesPage.tsx
Normal file
278
src/frontend/src/modules/deadlines/DeadlinesPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../shared/utils/api';
|
||||
|
||||
// NOTE: I will create useDebounce if not exists, or just use simple timeout for now.
|
||||
|
||||
interface DeadlineEvent {
|
||||
id: number;
|
||||
worker: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company: { name: string };
|
||||
};
|
||||
course: {
|
||||
title: string;
|
||||
code: string;
|
||||
};
|
||||
eventDate: string;
|
||||
expiryDate: string;
|
||||
status: 'VALID' | 'EXPIRING' | 'EXPIRED';
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
companyId: number;
|
||||
status: string;
|
||||
search: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export function DeadlinesPage() {
|
||||
const [deadlines, setDeadlines] = useState<DeadlineEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [companies, setCompanies] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
companyId: 0,
|
||||
status: '',
|
||||
search: '',
|
||||
startDate: '', // YYYY-MM-DD
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
// Simple debounce logic for search
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(filters.search);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearch(filters.search);
|
||||
}, 500);
|
||||
return () => clearTimeout(handler);
|
||||
}, [filters.search]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load companies for filter
|
||||
api.get('/companies').then(res => setCompanies(res.data)).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeadlines();
|
||||
}, [filters.companyId, filters.status, filters.startDate, filters.endDate, debouncedSearch]);
|
||||
|
||||
const fetchDeadlines = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.companyId) params.append('companyId', filters.companyId.toString());
|
||||
if (filters.status) params.append('status', filters.status);
|
||||
if (debouncedSearch) params.append('search', debouncedSearch);
|
||||
if (filters.startDate) params.append('startDate', filters.startDate);
|
||||
if (filters.endDate) params.append('endDate', filters.endDate);
|
||||
|
||||
const res = await api.get(`/deadlines?${params.toString()}`);
|
||||
setDeadlines(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const baseStyle = {
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
border: '1px solid transparent',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em'
|
||||
};
|
||||
|
||||
switch (status) {
|
||||
case 'VALID': return (
|
||||
<span style={{
|
||||
...baseStyle,
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
color: '#34D399',
|
||||
borderColor: 'rgba(16, 185, 129, 0.2)'
|
||||
}}>
|
||||
Valido
|
||||
</span>
|
||||
);
|
||||
case 'EXPIRING': return (
|
||||
<span style={{
|
||||
...baseStyle,
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#FBBF24',
|
||||
borderColor: 'rgba(245, 158, 11, 0.2)'
|
||||
}}>
|
||||
In Scadenza
|
||||
</span>
|
||||
);
|
||||
case 'EXPIRED': return (
|
||||
<span style={{
|
||||
...baseStyle,
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
color: '#F87171',
|
||||
borderColor: 'rgba(239, 68, 68, 0.2)'
|
||||
}}>
|
||||
Scaduto
|
||||
</span>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getRowStyle = (status: string) => {
|
||||
switch (status) {
|
||||
case 'EXPIRED': return { borderLeft: '3px solid var(--color-error)' };
|
||||
case 'EXPIRING': return { borderLeft: '3px solid var(--color-warning)' };
|
||||
default: return { borderLeft: '3px solid transparent' };
|
||||
}
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
background: 'var(--color-bg-app)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '0.6rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-main)',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div>
|
||||
<h1>Scadenzario</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '1.1rem' }}>Monitoraggio scadenze formative e rinnovi</p>
|
||||
</div>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<div className="card" style={{ padding: '1.5rem' }}>
|
||||
<h3 style={{ fontSize: '1rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
🔍 Filtri di Ricerca
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', alignItems: 'end' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' }}>Cerca</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Lavoratore o Corso..."
|
||||
style={inputStyle}
|
||||
value={filters.search}
|
||||
onChange={e => setFilters({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' }}>Stato</label>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={filters.status}
|
||||
onChange={e => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">Tutti</option>
|
||||
<option value="EXPIRED">Scaduti</option>
|
||||
<option value="EXPIRING">In Scadenza (30gg)</option>
|
||||
<option value="VALID">Validi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' }}>Azienda</label>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={filters.companyId}
|
||||
onChange={e => setFilters({ ...filters, companyId: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>Tutte le aziende</option>
|
||||
{companies.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' }}>Da Scadenza</label>
|
||||
<input
|
||||
type="date"
|
||||
style={inputStyle}
|
||||
value={filters.startDate}
|
||||
onChange={e => setFilters({ ...filters, startDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: '0.5rem', textTransform: 'uppercase' }}>A Scadenza</label>
|
||||
<input
|
||||
type="date"
|
||||
style={inputStyle}
|
||||
value={filters.endDate}
|
||||
onChange={e => setFilters({ ...filters, endDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{loading ? (
|
||||
<div className="loading" style={{ padding: '3rem', textAlign: 'center' }}>Caricamento scadenze...</div>
|
||||
) : (
|
||||
<div className="table-container" style={{ border: 'none', borderRadius: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Scadenza</th>
|
||||
<th>Stato</th>
|
||||
<th>Lavoratore</th>
|
||||
<th>Azienda</th>
|
||||
<th>Corso</th>
|
||||
<th style={{ textAlign: 'right' }}>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deadlines.map(item => (
|
||||
<tr key={item.id} style={getRowStyle(item.status)}>
|
||||
<td style={{ fontFamily: 'monospace', fontWeight: 500, fontSize: '0.95rem' }}>
|
||||
{new Date(item.expiryDate).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
{getStatusBadge(item.status)}
|
||||
</td>
|
||||
<td style={{ fontWeight: 600, color: 'var(--color-text-main)' }}>
|
||||
{item.worker.lastName} {item.worker.firstName}
|
||||
</td>
|
||||
<td style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{item.worker.company.name}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ color: 'var(--color-text-main)', fontWeight: 500 }}>{item.course.title}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{item.course.code}</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '0.3rem 0.7rem', fontSize: '0.8rem' }}
|
||||
>
|
||||
Dettagli
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{deadlines.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '4rem', textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: '1rem', opacity: 0.5 }}>📅</div>
|
||||
<div>Nessuna scadenza trovata con i filtri attuali.</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
src/frontend/src/modules/import/ImportPage.tsx
Normal file
151
src/frontend/src/modules/import/ImportPage.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import api from '../shared/utils/api';
|
||||
|
||||
export function ImportPage() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
setUploading(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const res = await api.post('/import/workers', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert('Errore caricamento file');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fileInputStyle = {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fileSelectorButton: {
|
||||
marginRight: '1rem',
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
border: 'none',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
color: 'var(--color-primary-400)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div>
|
||||
<h1>Importazione Dati</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '1.1rem' }}>Caricamento massivo anagrafiche da Excel</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: '48rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '1.5rem', color: 'var(--color-text-main)' }}>Carica Excel Lavoratori</h2>
|
||||
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px dashed var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx, .xls"
|
||||
onChange={handleFileChange}
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
className="file-input"
|
||||
/>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)', marginTop: '1rem' }}>
|
||||
Formato richiesto: .xlsx. Colonne: Nome, Cognome, CF, Azienda, Mansione, Email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.file-input::file-selector-button {
|
||||
margin-right: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-primary-500);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background-color: rgba(99, 102, 241, 0.1);
|
||||
color: var(--color-primary-400);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.file-input::file-selector-button:hover {
|
||||
background-color: var(--color-primary-500);
|
||||
color: white;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{uploading ? 'Importazione in corso...' : 'Avvia Importazione'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1.1rem', marginBottom: '1.5rem', color: 'var(--color-text-main)' }}>Riepilogo Importazione</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ padding: '1.5rem', background: 'rgba(255,255,255,0.03)', borderRadius: 'var(--radius-md)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 'bold', color: 'var(--color-text-main)' }}>{result.total}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Righe Totali</div>
|
||||
</div>
|
||||
<div style={{ padding: '1.5rem', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid rgba(16, 185, 129, 0.2)', borderRadius: 'var(--radius-md)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 'bold', color: '#34D399' }}>{result.success}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#34D399', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Importati</div>
|
||||
</div>
|
||||
<div style={{ padding: '1.5rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)', borderRadius: 'var(--radius-md)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 'bold', color: '#F87171' }}>{result.errors.length}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#F87171', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Errori</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div style={{ background: 'rgba(0,0,0,0.3)', borderRadius: 'var(--radius-md)', padding: '1rem', maxHeight: '15rem', overflowY: 'auto' }}>
|
||||
<h4 style={{ fontSize: '0.9rem', fontWeight: 'bold', color: 'var(--color-error)', marginBottom: '0.5rem' }}>Dettaglio Errori:</h4>
|
||||
<ul style={{ fontSize: '0.8rem', color: '#FCA5A5', fontFamily: 'monospace', listStyle: 'none', padding: 0 }}>
|
||||
{result.errors.map((err: string, idx: number) => (
|
||||
<li key={idx} style={{ padding: '0.2rem 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>• {err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
src/frontend/src/modules/notifications/CommunicationsPage.tsx
Normal file
193
src/frontend/src/modules/notifications/CommunicationsPage.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../shared/utils/api';
|
||||
|
||||
interface Notification {
|
||||
id: number;
|
||||
type: string;
|
||||
status: 'PENDING' | 'SENT' | 'FAILED';
|
||||
subject: string;
|
||||
content: string;
|
||||
company: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
createdAt: string;
|
||||
sentAt?: string;
|
||||
}
|
||||
|
||||
export function CommunicationsPage() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get('/notifications');
|
||||
setNotifications(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateNew = async () => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
await api.post('/notifications/generate', {});
|
||||
alert('Generazione completata');
|
||||
fetchNotifications();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Errore generazione');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendSelected = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (!confirm(`Vuoi inviare ${selectedIds.length} email selezionate?`)) return;
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
await api.post('/notifications/send', { ids: selectedIds });
|
||||
alert('Invio completato');
|
||||
setSelectedIds([]);
|
||||
fetchNotifications();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Errore invio');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
setSelectedIds(selectedIds.filter(i => i !== id));
|
||||
} else {
|
||||
setSelectedIds([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const pendingIds = notifications.filter(n => n.status === 'PENDING').map(n => n.id);
|
||||
if (selectedIds.length === pendingIds.length && pendingIds.length > 0) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(pendingIds);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'PENDING': return <span style={{ background: 'rgba(251, 191, 36, 0.1)', color: '#FBBF24', border: '1px solid rgba(251, 191, 36, 0.2)', padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.75rem', fontWeight: 600 }}>IN CODA</span>;
|
||||
case 'SENT': return <span style={{ background: 'rgba(16, 185, 129, 0.1)', color: '#34D399', border: '1px solid rgba(16, 185, 129, 0.2)', padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.75rem', fontWeight: 600 }}>INVIATA</span>;
|
||||
case 'FAILED': return <span style={{ background: 'rgba(239, 68, 68, 0.1)', color: '#F87171', border: '1px solid rgba(239, 68, 68, 0.2)', padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.75rem', fontWeight: 600 }}>ERRORE</span>;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading" style={{ padding: '3rem', textAlign: 'center' }}>Caricamento comunicazioni...</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h1>Comunicazioni</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: '1.1rem' }}>Gestione invio avvisi e notifiche</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button
|
||||
onClick={generateNew}
|
||||
disabled={processing}
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.9rem' }}
|
||||
>
|
||||
{processing ? 'Elaborazione...' : '🔄 Genera Avvisi'}
|
||||
</button>
|
||||
<button
|
||||
onClick={sendSelected}
|
||||
disabled={processing || selectedIds.length === 0}
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '0.9rem' }}
|
||||
>
|
||||
{processing ? 'Invio in corso...' : `Invia Selezionate (${selectedIds.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="table-container" style={{ border: 'none', borderRadius: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '3rem', textAlign: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={toggleSelectAll}
|
||||
style={{ cursor: 'pointer', opacity: 0.7 }}
|
||||
checked={notifications.filter(n => n.status === 'PENDING').length > 0 && selectedIds.length === notifications.filter(n => n.status === 'PENDING').length}
|
||||
/>
|
||||
</th>
|
||||
<th>Stato</th>
|
||||
<th>Data Generazione</th>
|
||||
<th>Azienda</th>
|
||||
<th>Oggetto</th>
|
||||
<th>Data Invio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notifications.map(n => (
|
||||
<tr key={n.id} style={{ background: selectedIds.includes(n.id) ? 'rgba(99, 102, 241, 0.05)' : 'transparent' }}>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{n.status === 'PENDING' && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(n.id)}
|
||||
onChange={() => toggleSelect(n.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{getStatusBadge(n.status)}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.85rem', color: 'var(--color-text-muted)', fontFamily: 'monospace' }}>
|
||||
{new Date(n.createdAt).toLocaleDateString()} <span style={{ opacity: 0.5 }}>{new Date(n.createdAt).toLocaleTimeString()}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600, color: 'var(--color-text-main)' }}>{n.company.name}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{n.company.email}</div>
|
||||
</td>
|
||||
<td style={{ color: 'var(--color-text-secondary)', fontStyle: 'italic' }}>
|
||||
{n.subject}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.85rem', color: 'var(--color-text-muted)' }}>
|
||||
{n.sentAt ? new Date(n.sentAt).toLocaleString() : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{notifications.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '4rem', textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: '1rem', opacity: 0.5 }}>📨</div>
|
||||
<div>Nessuna notifica in coda o inviata.</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user