[POLISH] Docker setup fix + UI polish + project completion

- Fix Docker: add php.ini, correct env var names (DB_NAME/DB_USER/DB_PASS),
  add 002_email_log.sql to initdb, add Authorization header passthrough,
  add uploads volume, install opcache, create .dockerignore
- UI polish: page fade-in transitions, skeleton loader CSS, staggered card
  animations, mobile sidebar backdrop overlay, keyboard focus-visible styles,
  button loading state, tooltip system, alert banners, tab component,
  custom scrollbar, print styles, clickable table rows
- Add setButtonLoading() and _toggleSidebar() helpers to common.js
- Update CLAUDE.md to reflect 100% project completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cristiano Benassati 2026-02-17 19:48:11 +01:00
parent bcc5a2b003
commit 68f8cab0bf
8 changed files with 476 additions and 29 deletions

26
.dockerignore Normal file
View File

@ -0,0 +1,26 @@
# Git
.git
.gitignore
# Environment & credentials
.env
docs/credentials/
# IDE
.vscode/
.idea/
# OS files
Thumbs.db
.DS_Store
# Docker (avoid recursive copy)
docker/
# Documentation (not needed in image)
CLAUDE.md
*.md
# Temp files
*.log
*.tmp

View File

@ -2,9 +2,9 @@
## PRIMA DI INIZIARE
- Leggi sempre questo file prima di iniziare qualsiasi lavoro
- Il progetto e' al **97% di completamento** (23.500+ righe di codice, 48 file sorgente)
- 5 commit su main, tutto deployato su Hetzner
- Manca: test end-to-end, Docker setup, bug fixing, polish UI
- Il progetto e' al **100% di completamento** (24.000+ righe di codice, 50+ file sorgente)
- 7 commit su main, tutto deployato e testato su Hetzner
- E2E test completati, bug fixing, Docker verificato, UI polished
## Panoramica
NIS2 Agile e' una piattaforma SaaS multi-tenant per supportare le aziende nella compliance alla Direttiva NIS2 (EU 2022/2555) e al D.Lgs. 138/2024 italiano. Include AI integration (Claude API) per gap analysis, generazione policy, classificazione incidenti e suggerimenti rischi.
@ -162,11 +162,12 @@ Schema: `docs/sql/001_initial_schema.sql` + `docs/sql/002_email_log.sql`
## Git
- **Repository**: https://git.certisource.it/AdminGit2026/nis2-agile
- **Token Gitea**: bcaec92cad4071c8b2f938d6201a6a392c09f626
- **Branch**: main (5 commit)
- **Branch**: main (7 commit)
- **Commit format**: `[AREA] Descrizione`
### Cronologia Commit
```
bcc5a2b [FIX] E2E testing - fix router, EmailService, frontend data mapping
6f4b457 [FEAT] Add EmailService, RateLimitService, ReportService + integrations
9aa2788 [FEAT] Add onboarding wizard with visura camerale and CertiSource integration
73e78ea [FEAT] Add all frontend pages - complete UI for NIS2 platform
@ -192,10 +193,17 @@ Base: `/nis2/api/{controller}/{action}/{id?}`
### Onboarding: POST upload-visura, fetch-company, complete
### Admin: GET organizations, users, stats
## Cosa Manca (3%)
1. **Test end-to-end**: Registrare utente, onboarding, assessment, rischi, incidenti
2. **Bug fixing**: Correggere errori che emergono dai test
3. **Docker setup**: Verificare Dockerfile/docker-compose funzionanti
4. **UI polish**: Miglioramenti responsive, animazioni, micro-interazioni
## Stato Completamento
Tutti i moduli sono implementati e testati:
- [x] Test end-to-end: tutti gli endpoint API verificati
- [x] Bug fixing: router rewrite, EmailService parse fix, frontend data mapping
- [x] Docker setup: Dockerfile, docker-compose.yml, nginx.conf, php.ini verificati
- [x] UI polish: animazioni, skeleton loaders, mobile backdrop, print styles, a11y
### Bug Risolti (E2E Testing)
1. **Router /{id}/subAction** - Pattern matching riscritto completamente per gestire GET /assessments/1/questions, GET /organizations/2/members, etc.
2. **EmailService parse error** - PHP non supporta `??` dentro `{$var}` string interpolation, estratto a variabile
3. **Frontend data mapping** - Dashboard, Assessment, Onboarding avevano nomi campo diversi dal backend
4. **Field name mismatches** - annual_turnover→annual_turnover_eur, question_id→question_code, compliance_level→response_value
*Ultimo aggiornamento: 2026-02-17*

View File

@ -1,18 +1,20 @@
FROM php:8.4-fpm-alpine
# Extensions
RUN docker-php-ext-install pdo pdo_mysql
# System dependencies
RUN apk add --no-cache curl-dev
# Curl extension
RUN apk add --no-cache curl-dev && docker-php-ext-install curl
# PHP extensions
RUN docker-php-ext-install pdo pdo_mysql curl opcache
# Config
# Custom PHP config
COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini
WORKDIR /var/www/nis2-agile
COPY . .
RUN chown -R www-data:www-data /var/www/nis2-agile
# Create uploads directory and set permissions
RUN mkdir -p /var/www/nis2-agile/public/uploads/visure \
&& chown -R www-data:www-data /var/www/nis2-agile
EXPOSE 9000

View File

@ -11,14 +11,17 @@ services:
volumes:
- ../application:/var/www/nis2-agile/application
- ../public:/var/www/nis2-agile/public
- nis2-uploads:/var/www/nis2-agile/public/uploads
env_file:
- ../.env
environment:
- APP_ENV=${APP_ENV:-production}
- APP_DEBUG=${APP_DEBUG:-false}
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=${DB_DATABASE:-nis2_agile_db}
- DB_USERNAME=${DB_USERNAME:-nis2_user}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-nis2_agile_db}
- DB_USER=${DB_USER:-nis2_user}
- DB_PASS=${DB_PASS}
- JWT_SECRET=${JWT_SECRET}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
networks:
@ -29,7 +32,7 @@ services:
# ── Nginx Web Server ─────────────────────────────────────────────────────
web:
image: nginx:1.25-alpine
image: nginx:1.27-alpine
container_name: nis2-web
restart: unless-stopped
ports:
@ -37,6 +40,7 @@ services:
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ../public:/var/www/nis2-agile/public:ro
- nis2-uploads:/var/www/nis2-agile/public/uploads:ro
networks:
- nis2-network
depends_on:
@ -48,17 +52,18 @@ services:
container_name: nis2-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE:-nis2_agile_db}
MYSQL_USER: ${DB_USERNAME:-nis2_user}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpass}
MYSQL_DATABASE: ${DB_NAME:-nis2_agile_db}
MYSQL_USER: ${DB_USER:-nis2_user}
MYSQL_PASSWORD: ${DB_PASS}
ports:
- "${DB_PORT:-3306}:3306"
- "${DB_EXPOSE_PORT:-3307}:3306"
volumes:
- nis2-db-data:/var/lib/mysql
- ../docs/sql/001_initial_schema.sql:/docker-entrypoint-initdb.d/001_initial_schema.sql:ro
- ../docs/sql/002_email_log.sql:/docker-entrypoint-initdb.d/002_email_log.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-rootpass}"]
interval: 10s
timeout: 5s
retries: 5
@ -70,6 +75,8 @@ services:
volumes:
nis2-db-data:
driver: local
nis2-uploads:
driver: local
# ── Networks ─────────────────────────────────────────────────────────────
networks:

View File

@ -32,6 +32,9 @@ server {
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Pass Authorization header (required for JWT auth)
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
fastcgi_param HTTP_PROXY "";
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;

30
docker/php.ini Normal file
View File

@ -0,0 +1,30 @@
[PHP]
; ── Upload Limits ──────────────────────────────────────────────────────────
upload_max_filesize = 20M
post_max_size = 25M
; ── Memory & Execution ─────────────────────────────────────────────────────
memory_limit = 256M
max_execution_time = 300
max_input_time = 60
; ── Error Handling ─────────────────────────────────────────────────────────
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php-errors.log
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
; ── Timezone ───────────────────────────────────────────────────────────────
date.timezone = Europe/Rome
; ── Session ────────────────────────────────────────────────────────────────
session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1
; ── OPcache ────────────────────────────────────────────────────────────────
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 4000
opcache.validate_timestamps = 0

View File

@ -1639,3 +1639,331 @@ tbody tr:last-child td {
padding: 16px;
}
}
/* ── Page Load Transition ────────────────────────────────────────── */
.main-content {
animation: fadeInUp 0.35s ease both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Skeleton Loaders ────────────────────────────────────────────── */
.skeleton {
background: linear-gradient(90deg, var(--gray-200) 25%, var(--gray-100) 50%, var(--gray-200) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--border-radius-sm);
}
.skeleton-text {
height: 14px;
margin-bottom: 8px;
width: 80%;
}
.skeleton-text-sm {
height: 10px;
margin-bottom: 6px;
width: 60%;
}
.skeleton-title {
height: 20px;
margin-bottom: 12px;
width: 50%;
}
.skeleton-circle {
border-radius: 50%;
}
.skeleton-card {
padding: 24px;
background: var(--card-bg);
border-radius: var(--border-radius-lg);
box-shadow: var(--card-shadow);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── Mobile Sidebar Backdrop ─────────────────────────────────────── */
.sidebar-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(2px);
z-index: 99;
opacity: 0;
transition: opacity var(--transition);
}
.sidebar-backdrop.visible {
opacity: 1;
}
@media (max-width: 768px) {
.sidebar-backdrop.visible {
display: block;
}
}
/* ── Focus Visible (Keyboard Accessibility) ──────────────────────── */
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.btn:focus-visible {
box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.3);
}
.form-input:focus-visible,
.form-select:focus-visible,
.form-textarea:focus-visible {
outline: none;
}
.sidebar-nav a:focus-visible {
outline-offset: -2px;
outline-color: var(--primary-light);
}
/* ── Button Loading State ────────────────────────────────────────── */
.btn.loading {
position: relative;
color: transparent !important;
pointer-events: none;
}
.btn.loading::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.btn-secondary.loading::after,
.btn-ghost.loading::after {
border-color: rgba(0, 0, 0, 0.15);
border-top-color: var(--gray-600);
}
/* ── Staggered Card Entrance ─────────────────────────────────────── */
.stats-grid > * {
animation: fadeInUp 0.4s ease both;
}
.stats-grid > *:nth-child(1) { animation-delay: 0.05s; }
.stats-grid > *:nth-child(2) { animation-delay: 0.1s; }
.stats-grid > *:nth-child(3) { animation-delay: 0.15s; }
.stats-grid > *:nth-child(4) { animation-delay: 0.2s; }
/* ── Custom Scrollbar (Sidebar) ──────────────────────────────────── */
.sidebar::-webkit-scrollbar {
width: 4px;
}
.sidebar::-webkit-scrollbar-track {
background: transparent;
}
.sidebar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.sidebar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* ── Tooltip ─────────────────────────────────────────────────────── */
[data-tooltip] {
position: relative;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%) scale(0.95);
background: var(--gray-800);
color: #fff;
padding: 5px 10px;
border-radius: var(--border-radius-sm);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity var(--transition-fast), transform var(--transition-fast);
z-index: 50;
}
[data-tooltip]:hover::after {
opacity: 1;
transform: translateX(-50%) scale(1);
}
/* ── Clickable Table Rows ────────────────────────────────────────── */
tbody tr.clickable {
cursor: pointer;
}
tbody tr.clickable:hover {
background: var(--primary-bg);
}
tbody tr.clickable:active {
background: rgba(26, 115, 232, 0.12);
}
/* ── Alert Banners ───────────────────────────────────────────────── */
.alert {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
border-radius: var(--border-radius);
font-size: 0.875rem;
margin-bottom: 16px;
border-left: 4px solid;
}
.alert-info {
background: var(--info-bg);
border-left-color: var(--info);
color: var(--gray-700);
}
.alert-warning {
background: var(--warning-bg);
border-left-color: var(--warning);
color: var(--gray-700);
}
.alert-danger {
background: var(--danger-bg);
border-left-color: var(--danger);
color: var(--gray-700);
}
.alert-success {
background: var(--secondary-bg);
border-left-color: var(--secondary);
color: var(--gray-700);
}
.alert svg {
width: 20px;
height: 20px;
flex-shrink: 0;
margin-top: 1px;
}
/* ── Tabs ────────────────────────────────────────────────────────── */
.tabs {
display: flex;
border-bottom: 2px solid var(--gray-200);
margin-bottom: 24px;
overflow-x: auto;
}
.tab {
padding: 10px 20px;
font-size: 0.875rem;
font-weight: 600;
color: var(--gray-500);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
background: none;
border-top: none;
border-left: none;
border-right: none;
font-family: inherit;
}
.tab:hover {
color: var(--gray-700);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* ── Print Styles ────────────────────────────────────────────────── */
@media print {
.sidebar,
.sidebar-toggle,
.sidebar-backdrop,
.content-header-actions,
.notification-container,
.modal-overlay,
.btn {
display: none !important;
}
.main-content {
margin-left: 0 !important;
animation: none !important;
}
.content-header {
position: static;
border-bottom: 2px solid #000;
padding: 16px 0;
}
.content-body {
padding: 16px 0;
}
.card {
box-shadow: none;
border: 1px solid #ddd;
break-inside: avoid;
}
.stats-grid > * {
animation: none !important;
}
body {
background: #fff;
color: #000;
font-size: 12pt;
}
a {
color: #000;
text-decoration: underline;
}
.badge {
border: 1px solid currentColor;
}
}

View File

@ -295,13 +295,35 @@ function _setupMobileToggle() {
if (!document.querySelector('.sidebar-toggle')) {
const toggle = document.createElement('button');
toggle.className = 'sidebar-toggle';
toggle.setAttribute('aria-label', 'Apri menu');
toggle.innerHTML = '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
toggle.addEventListener('click', () => {
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.classList.toggle('open');
});
toggle.addEventListener('click', () => _toggleSidebar());
document.body.appendChild(toggle);
}
// Crea backdrop per mobile
if (!document.querySelector('.sidebar-backdrop')) {
const backdrop = document.createElement('div');
backdrop.className = 'sidebar-backdrop';
backdrop.addEventListener('click', () => _toggleSidebar(false));
document.body.appendChild(backdrop);
}
}
function _toggleSidebar(forceState) {
const sidebar = document.getElementById('sidebar');
const backdrop = document.querySelector('.sidebar-backdrop');
if (!sidebar) return;
const isOpen = typeof forceState === 'boolean' ? forceState : !sidebar.classList.contains('open');
if (isOpen) {
sidebar.classList.add('open');
if (backdrop) backdrop.classList.add('visible');
} else {
sidebar.classList.remove('open');
if (backdrop) backdrop.classList.remove('visible');
}
}
@ -454,6 +476,27 @@ function iconCog() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>';
}
/**
* Imposta lo stato di caricamento su un bottone.
* @param {HTMLElement|string} btn - Elemento o ID
* @param {boolean} loading - true per attivare, false per disattivare
* @param {string} [originalText] - Testo originale da ripristinare
*/
function setButtonLoading(btn, loading, originalText) {
if (typeof btn === 'string') btn = document.getElementById(btn);
if (!btn) return;
if (loading) {
btn._originalText = btn.textContent;
btn.classList.add('loading');
btn.disabled = true;
} else {
btn.classList.remove('loading');
btn.disabled = false;
if (originalText) btn.textContent = originalText;
else if (btn._originalText) btn.textContent = btn._originalText;
}
}
function iconPlus() {
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>';
}