From e443ba8f3cabb49abc0d265b31a50336f345feff Mon Sep 17 00:00:00 2001 From: subnetMusk Date: Mon, 11 May 2026 17:44:14 +0200 Subject: [PATCH 01/22] Ho definito la struttura generale del progetto e configurato il file compose per l'avvio dei servizi necessari --- .dockerignore | 21 + .env.example | 103 +++-- .gitignore | 4 + README.md | 94 +++-- ai-worker/Dockerfile | 23 ++ ai-worker/app/analysis.py | 25 ++ ai-worker/app/main.py | 39 ++ ai-worker/app/ocr.py | 14 + ai-worker/app/pdf_utils.py | 22 + ai-worker/app/schemas.py | 35 ++ ai-worker/requirements.txt | 5 + app/Filament/Pages/Dashboard.php | 83 ++++ .../Resources/CommunicationResource.php | 41 +- .../Resources/OriginalDocumentResource.php | 8 +- .../Pages/ListOriginalDocuments.php | 5 +- app/Providers/AppServiceProvider.php | 5 +- app/Providers/Filament/AdminPanelProvider.php | 54 ++- app/Services/BedrockService.php | 43 +- composer.json | 1 + composer.lock | 57 ++- config/services.php | 19 +- docker-compose.yml | 152 +++++++ docker/nginx/default.conf | 27 ++ docker/php/Dockerfile | 39 ++ docker/php/entrypoint.sh | 144 +++++++ docs/poc-scope.md | 54 +++ phpunit.xml | 1 + public/css/nexum-filament.css | 385 ++++++++++++++++++ public/images/eggon_logo_43542.png | Bin 0 -> 22752 bytes .../views/filament/pages/dashboard.blade.php | 140 +++++++ 30 files changed, 1546 insertions(+), 97 deletions(-) create mode 100644 .dockerignore create mode 100644 ai-worker/Dockerfile create mode 100644 ai-worker/app/analysis.py create mode 100644 ai-worker/app/main.py create mode 100644 ai-worker/app/ocr.py create mode 100644 ai-worker/app/pdf_utils.py create mode 100644 ai-worker/app/schemas.py create mode 100644 ai-worker/requirements.txt create mode 100644 app/Filament/Pages/Dashboard.php create mode 100644 docker-compose.yml create mode 100644 docker/nginx/default.conf create mode 100644 docker/php/Dockerfile create mode 100644 docker/php/entrypoint.sh create mode 100644 docs/poc-scope.md create mode 100644 public/css/nexum-filament.css create mode 100644 public/images/eggon_logo_43542.png create mode 100644 resources/views/filament/pages/dashboard.blade.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2b96658 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.github +.idea +.vscode + +vendor +node_modules + +.env +.env.* +!.env.example + +storage/app/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +storage/logs/* + +ai-worker/.venv +ai-worker/__pycache__ +ai-worker/app/__pycache__ diff --git a/.env.example b/.env.example index 6466991..04b551d 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,14 @@ -APP_NAME=Laravel +APP_NAME=NEXUM APP_ENV=local APP_KEY= APP_DEBUG=true -APP_URL=http://localhost +APP_URL=http://localhost:8080 -APP_LOCALE=en -APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US +APP_LOCALE=it +APP_FALLBACK_LOCALE=it +APP_FAKER_LOCALE=it_IT APP_MAINTENANCE_DRIVER=file -# APP_MAINTENANCE_STORE=database - -# PHP_CLI_SERVER_WORKERS=4 BCRYPT_ROUNDS=12 @@ -21,11 +18,12 @@ LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug DB_CONNECTION=pgsql -DB_HOST=127.0.0.1 +DB_HOST=postgres DB_PORT=5432 -DB_DATABASE=poc_swe -DB_USERNAME=root -DB_PASSWORD= +DB_DATABASE=nexum +DB_USERNAME=nexum +DB_PASSWORD=nexum +POSTGRES_PORT=5432 SESSION_DRIVER=database SESSION_LIFETIME=120 @@ -34,34 +32,79 @@ SESSION_PATH=/ SESSION_DOMAIN=null BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=database -# CACHE_PREFIX= - -MEMCACHED_HOST=127.0.0.1 +CACHE_STORE=redis +QUEUE_CONNECTION=redis +FILESYSTEM_DISK=s3 REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 +REDIS_HOST=redis REDIS_PASSWORD=null REDIS_PORT=6379 +REDIS_DB=0 +REDIS_CACHE_DB=1 +REDIS_PORT_HOST=6379 -MAIL_MAILER=log +MAIL_MAILER=smtp MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 +MAIL_HOST=mailpit +MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS=noreply@nexum.local MAIL_FROM_NAME="${APP_NAME}" +MAILPIT_SMTP_PORT=1025 +MAILPIT_UI_PORT=8025 + +NGINX_PORT=8080 +COMPOSER_INSTALL_ON_STARTUP=true +LARAVEL_AUTOMATED_SETUP=true +LARAVEL_WAIT_FOR_SETUP=true +LARAVEL_SETUP_RETRIES=30 +LARAVEL_SETUP_WAIT_SECONDS=120 +FILAMENT_ADMIN_CREATE=true +FILAMENT_ADMIN_NAME="NEXUM Admin" +FILAMENT_ADMIN_EMAIL=admin@nexum.local +FILAMENT_ADMIN_PASSWORD=Password123! + +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +MINIO_BUCKET=nexum-local +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +# Default locale: MinIO S3-compatible. Per AWS S3 reale, sostituire endpoint, +# bucket e credenziali tramite variabili d'ambiente non versionate. +AWS_ACCESS_KEY_ID=minioadmin +AWS_SECRET_ACCESS_KEY=minioadmin +AWS_DEFAULT_REGION=eu-south-1 +AWS_BUCKET=nexum-local +AWS_ENDPOINT=http://minio:9000 +AWS_URL=http://localhost:9000/nexum-local +AWS_USE_PATH_STYLE_ENDPOINT=true + +AI_GENERATOR_DRIVER=fake +DOCUMENT_OCR_DRIVER=local +DOCUMENT_CLASSIFIER_DRIVER=fake +RECIPIENT_RESOLVER_DRIVER=fake +AI_WORKER_URL=http://ai-worker:8000 +AI_WORKER_PORT=8001 +AI_WORKER_ENV=local + +POC_CONFIDENCE_THRESHOLD=80 +ASSISTANT_FEEDBACK_MAX_LENGTH=1000 +DOCUMENT_MAX_UPLOAD_MB=25 +DISPATCH_MAIL_SIMULATION=true -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false +BEDROCK_ENABLED=false +BEDROCK_AWS_REGION=eu-central-1 +BEDROCK_MODEL_ID= +BEDROCK_CLASSIFIER_MODEL_ID= +BEDROCK_GUARDRAIL_ID= -BEDROCK_MODEL_ID=us.anthropic.claude-3-5-sonnet-20241022-v2:0 +TEXTRACT_ENABLED=false +TEXTRACT_AWS_REGION=eu-central-1 +TEXTRACT_S3_BUCKET= +TEXTRACT_SNS_TOPIC_ARN= +TEXTRACT_ROLE_ARN= VITE_APP_NAME="${APP_NAME}" diff --git a/.gitignore b/.gitignore index 867512a..68f3cd9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,12 @@ /public/hot /public/storage /storage/*.key +/storage/framework/poc-setup-complete /storage/pail /vendor +__pycache__/ +*.py[cod] +.venv/ CLAUDE.md /specs _ide_helper.php diff --git a/README.md b/README.md index 5ad1377..d7dbcda 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,84 @@ -

Laravel Logo

+# NEXUM / aLittleByte PoC -

-Build Status -Total Downloads -Latest Stable Version -License -

+Proof of Concept universitaria per i moduli: -## About Laravel +- AI Assistant Generativo; +- AI Co-Pilot documentale per Consulenti del Lavoro. -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +La base applicativa e' Laravel con interfaccia amministrativa Filament. Il progetto resta volutamente leggero: dimostra i flussi principali dell'Analisi dei Requisiti, mentre la rifinitura architetturale e funzionale viene rimandata al PB. -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +## Stack locale -Laravel is accessible, powerful, and provides tools required for large, robust applications. +- Laravel 12 +- Filament 3 +- PostgreSQL +- Redis e Laravel Queue +- MinIO come storage S3-compatible locale +- Mailpit per test email +- AI worker FastAPI per OCR/parsing/split placeholder +- Bedrock e Textract configurabili, disabilitati di default -## Learning Laravel +## Perimetro PoC -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. +Il perimetro funzionale e le esclusioni sono descritti in [docs/poc-scope.md](docs/poc-scope.md). -In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. +## Avvio ambiente -You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. +Prima copia l'esempio di configurazione: -## Agentic Development +```bash +cp .env.example .env +``` -Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: +Poi avvia i servizi e il bootstrap applicativo: ```bash -composer require laravel/boost --dev +docker compose up -d --build +``` + +Durante l'avvio il container `app` crea `.env` da `.env.example` se manca, installa automaticamente le dipendenze Composer se `vendor/` manca o se cambia `composer.lock`, genera `APP_KEY`, esegue le migrazioni e crea l'utente Filament locale se non esiste. + +Credenziali Filament locali di default: -php artisan boost:install +```text +Email: admin@nexum.local +Password: Password123! ``` -Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. +Se vuoi disabilitare o personalizzare il bootstrap automatico: -## Contributing +```env +COMPOSER_INSTALL_ON_STARTUP=false +LARAVEL_AUTOMATED_SETUP=false +FILAMENT_ADMIN_CREATE=false +FILAMENT_ADMIN_EMAIL=admin@nexum.local +FILAMENT_ADMIN_PASSWORD=Password123! +``` + +Servizi locali principali: -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +- Laravel/Nginx: `http://localhost:8080` +- Filament: `http://localhost:8080/admin` +- MinIO console: `http://localhost:9001` +- Mailpit: `http://localhost:8025` +- AI worker: `http://localhost:8001/health` -## Code of Conduct +## Modalita AI -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +Per default `BEDROCK_ENABLED=false`, quindi l'applicazione usa risposte fake deterministiche utili alla demo locale. -## Security Vulnerabilities +Per provare Bedrock reale: + +```env +BEDROCK_ENABLED=true +BEDROCK_AWS_REGION=eu-central-1 +BEDROCK_MODEL_ID= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +``` -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. +Le credenziali reali non devono essere versionate. -## License +## Placeholder intenzionali -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +OCR reale, Textract, split documentale robusto, matching destinatario, metriche avanzate e consegna email reale sono predisposti ma non implementati in modo completo. Nella PoC vengono simulati o lasciati come punti di integrazione. diff --git a/ai-worker/Dockerfile b/ai-worker/Dockerfile new file mode 100644 index 0000000..0d8a954 --- /dev/null +++ b/ai-worker/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ocrmypdf \ + poppler-utils \ + tesseract-ocr \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/ai-worker/app/analysis.py b/ai-worker/app/analysis.py new file mode 100644 index 0000000..c0b6f06 --- /dev/null +++ b/ai-worker/app/analysis.py @@ -0,0 +1,25 @@ +from app.schemas import AnalyzeDocumentRequest, DocumentTaskResponse + + +def analyze_document_placeholder(request: AnalyzeDocumentRequest) -> DocumentTaskResponse: + return DocumentTaskResponse( + document_id=request.document_id, + status="placeholder", + message="Analisi documentale PoC non ancora implementata.", + data={ + "storage_path": request.storage_path, + "manual_classification": request.manual_classification, + "manual_metadata": request.manual_metadata, + "result": { + "document_type": request.manual_classification or "cedolino", + "employee_name": "Non disponibile", + "company": request.manual_metadata.get("company", "Non disponibile"), + "document_date": request.manual_metadata.get("document_date"), + "page_count": None, + "description": "Risultato simulato per PoC.", + "confidence": 0.8, + "recipient": "Non disponibile", + "split_status": "placeholder", + }, + }, + ) diff --git a/ai-worker/app/main.py b/ai-worker/app/main.py new file mode 100644 index 0000000..587b119 --- /dev/null +++ b/ai-worker/app/main.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI + +from app.analysis import analyze_document_placeholder +from app.ocr import run_ocr_placeholder +from app.pdf_utils import parse_pdf_placeholder, split_pdf_placeholder +from app.schemas import ( + AnalyzeDocumentRequest, + DocumentTaskResponse, + OcrRequest, + ParsePdfRequest, + SplitDocumentRequest, +) + +app = FastAPI(title="NEXUM AI Worker", version="0.1.0") + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/ocr", response_model=DocumentTaskResponse) +def ocr(request: OcrRequest) -> DocumentTaskResponse: + return run_ocr_placeholder(request) + + +@app.post("/parse-pdf", response_model=DocumentTaskResponse) +def parse_pdf(request: ParsePdfRequest) -> DocumentTaskResponse: + return parse_pdf_placeholder(request) + + +@app.post("/split-document", response_model=DocumentTaskResponse) +def split_document(request: SplitDocumentRequest) -> DocumentTaskResponse: + return split_pdf_placeholder(request) + + +@app.post("/analyze-document", response_model=DocumentTaskResponse) +def analyze_document(request: AnalyzeDocumentRequest) -> DocumentTaskResponse: + return analyze_document_placeholder(request) diff --git a/ai-worker/app/ocr.py b/ai-worker/app/ocr.py new file mode 100644 index 0000000..42a9dcb --- /dev/null +++ b/ai-worker/app/ocr.py @@ -0,0 +1,14 @@ +from app.schemas import DocumentTaskResponse, OcrRequest + + +def run_ocr_placeholder(request: OcrRequest) -> DocumentTaskResponse: + return DocumentTaskResponse( + document_id=request.document_id, + status="placeholder", + message="OCR locale/Textract non ancora implementato.", + data={ + "storage_path": request.storage_path, + "driver": request.driver, + "language": request.language, + }, + ) diff --git a/ai-worker/app/pdf_utils.py b/ai-worker/app/pdf_utils.py new file mode 100644 index 0000000..c06fc58 --- /dev/null +++ b/ai-worker/app/pdf_utils.py @@ -0,0 +1,22 @@ +from app.schemas import DocumentTaskResponse, ParsePdfRequest, SplitDocumentRequest + + +def parse_pdf_placeholder(request: ParsePdfRequest) -> DocumentTaskResponse: + return DocumentTaskResponse( + document_id=request.document_id, + status="placeholder", + message="Parsing PDF non ancora implementato.", + data={"storage_path": request.storage_path}, + ) + + +def split_pdf_placeholder(request: SplitDocumentRequest) -> DocumentTaskResponse: + return DocumentTaskResponse( + document_id=request.document_id, + status="placeholder", + message="Split documentale non ancora implementato.", + data={ + "storage_path": request.storage_path, + "strategy": request.strategy, + }, + ) diff --git a/ai-worker/app/schemas.py b/ai-worker/app/schemas.py new file mode 100644 index 0000000..22c53b3 --- /dev/null +++ b/ai-worker/app/schemas.py @@ -0,0 +1,35 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class BaseDocumentTask(BaseModel): + document_id: str = Field(..., description="Identificativo applicativo del documento.") + storage_path: str = Field(..., description="Percorso del file nello storage S3-compatible.") + + +class OcrRequest(BaseDocumentTask): + driver: Literal["local", "textract"] = "local" + language: str = "ita+eng" + + +class ParsePdfRequest(BaseDocumentTask): + extract_text: bool = True + + +class SplitDocumentRequest(BaseDocumentTask): + strategy: Literal["none", "page", "recipient", "classifier"] = "none" + + +class AnalyzeDocumentRequest(BaseDocumentTask): + manual_classification: str | None = None + manual_metadata: dict[str, Any] = Field(default_factory=dict) + run_ocr: bool = True + run_split: bool = True + + +class DocumentTaskResponse(BaseModel): + document_id: str + status: Literal["placeholder", "queued", "completed", "failed"] + message: str + data: dict[str, Any] = Field(default_factory=dict) diff --git a/ai-worker/requirements.txt b/ai-worker/requirements.txt new file mode 100644 index 0000000..f11284d --- /dev/null +++ b/ai-worker/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +pydantic==2.11.4 +python-multipart==0.0.20 +pypdf==5.4.0 diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php new file mode 100644 index 0000000..bfb7319 --- /dev/null +++ b/app/Filament/Pages/Dashboard.php @@ -0,0 +1,83 @@ +count(); + $drafts = Communication::query() + ->where('status', CommunicationStatus::Draft) + ->count(); + + return [ + ['value' => $total, 'label' => 'Contenuti generati'], + ['value' => $drafts, 'label' => 'Bozze da rivedere'], + ['value' => max(0, $total - $drafts), 'label' => 'Contenuti finalizzati'], + ]; + } + + public function getCopilotMetrics(): array + { + $documents = OriginalDocument::query()->count(); + $failed = OriginalDocument::query() + ->where('processing_status', ProcessingStatus::Failed) + ->count(); + $pendingDispatch = SubDocument::query() + ->where('send_status', SendStatus::Pending) + ->count(); + + return [ + ['value' => $documents, 'label' => 'Documenti caricati'], + ['value' => $pendingDispatch, 'label' => 'Invii da completare'], + ['value' => $failed, 'label' => 'Anomalie da verificare'], + ]; + } + + public function getRecentCommunications(): array + { + return Communication::query() + ->latest() + ->limit(3) + ->get(['generated_title', 'tone', 'created_at']) + ->map(fn (Communication $communication): array => [ + 'title' => $communication->generated_title ?: 'Bozza senza titolo', + 'meta' => trim(($communication->tone ?: 'Tono non indicato').' - '.$communication->created_at?->format('d/m/Y H:i')), + ]) + ->all(); + } + + public function getRecentDocuments(): array + { + return OriginalDocument::query() + ->latest() + ->limit(3) + ->get(['original_filename', 'processing_status', 'created_at']) + ->map(fn (OriginalDocument $document): array => [ + 'title' => $document->original_filename, + 'meta' => $document->processing_status->label().' - '.$document->created_at?->format('d/m/Y H:i'), + ]) + ->all(); + } +} diff --git a/app/Filament/Resources/CommunicationResource.php b/app/Filament/Resources/CommunicationResource.php index 357f04f..f8a328e 100644 --- a/app/Filament/Resources/CommunicationResource.php +++ b/app/Filament/Resources/CommunicationResource.php @@ -19,29 +19,50 @@ class CommunicationResource extends Resource protected static ?string $navigationIcon = 'heroicon-o-chat-bubble-left-right'; - protected static ?string $navigationLabel = 'AI Generativa'; + protected static ?string $navigationGroup = 'AI Assistant'; + + protected static ?string $navigationLabel = 'Generazione contenuti'; + + protected static ?int $navigationSort = 10; protected static ?string $modelLabel = 'Comunicazione'; - protected static ?string $pluralModelLabel = 'AI Generativa'; + protected static ?string $pluralModelLabel = 'Generazione contenuti'; public static function form(Form $form): Form { return $form ->schema([ Forms\Components\Textarea::make('prompt') - ->label('Contenuto richiesto') + ->label('Prompt') + ->helperText('Descrivi il contenuto da generare. La PoC produce una bozza revisionabile.') ->required() - ->rows(4) + ->rows(5) ->columnSpanFull(), - Forms\Components\TextInput::make('tone') + Forms\Components\Select::make('tone') ->label('Tono') - ->required() - ->maxLength(100), - Forms\Components\TextInput::make('style') + ->options([ + 'Chiaro e diretto' => 'Chiaro e diretto', + 'Più istituzionale' => 'Più istituzionale', + 'Più sintetico' => 'Più sintetico', + 'Empatico' => 'Empatico', + 'Tecnico' => 'Tecnico', + ]) + ->default('Chiaro e diretto') + ->native(false) + ->required(), + Forms\Components\Select::make('style') ->label('Stile') - ->required() - ->maxLength(100), + ->options([ + 'Testo informativo' => 'Testo informativo', + 'Avviso operativo' => 'Avviso operativo', + 'Aggiornamento breve' => 'Aggiornamento breve', + 'News portale' => 'News portale', + 'Email interna' => 'Email interna', + ]) + ->default('Testo informativo') + ->native(false) + ->required(), ]); } diff --git a/app/Filament/Resources/OriginalDocumentResource.php b/app/Filament/Resources/OriginalDocumentResource.php index 9964420..af7e910 100644 --- a/app/Filament/Resources/OriginalDocumentResource.php +++ b/app/Filament/Resources/OriginalDocumentResource.php @@ -23,11 +23,15 @@ class OriginalDocumentResource extends Resource protected static ?string $navigationIcon = 'heroicon-o-document-arrow-up'; - protected static ?string $navigationLabel = 'AI Copilot'; + protected static ?string $navigationGroup = 'Co-Pilot CdL'; + + protected static ?string $navigationLabel = 'Documenti analizzati'; + + protected static ?int $navigationSort = 20; protected static ?string $modelLabel = 'Documento'; - protected static ?string $pluralModelLabel = 'AI Copilot'; + protected static ?string $pluralModelLabel = 'Documenti analizzati'; public static function form(Form $form): Form { diff --git a/app/Filament/Resources/OriginalDocumentResource/Pages/ListOriginalDocuments.php b/app/Filament/Resources/OriginalDocumentResource/Pages/ListOriginalDocuments.php index 1a51e44..d6dee07 100644 --- a/app/Filament/Resources/OriginalDocumentResource/Pages/ListOriginalDocuments.php +++ b/app/Filament/Resources/OriginalDocumentResource/Pages/ListOriginalDocuments.php @@ -3,7 +3,6 @@ namespace App\Filament\Resources\OriginalDocumentResource\Pages; use App\Filament\Resources\OriginalDocumentResource; -use Filament\Actions; use Filament\Resources\Pages\ListRecords; class ListOriginalDocuments extends ListRecords @@ -12,8 +11,6 @@ class ListOriginalDocuments extends ListRecords protected function getHeaderActions(): array { - return [ - Actions\CreateAction::make(), - ]; + return []; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c90a8d8..6aed797 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -27,9 +27,12 @@ public function register(): void }); $this->app->singleton(BedrockService::class, function ($app) { + $bedrockEnabled = (bool) config('services.bedrock.enabled'); + return new BedrockService( - $app->make(BedrockRuntimeClient::class), + $bedrockEnabled ? $app->make(BedrockRuntimeClient::class) : null, config('services.bedrock.model_id'), + $bedrockEnabled, ); }); diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 21881b9..47d9438 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,20 +2,21 @@ namespace App\Providers\Filament; +use App\Filament\Pages\Dashboard; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; -use Filament\Pages; use Filament\Panel; use Filament\PanelProvider; -use Filament\Support\Colors\Color; -use Filament\Widgets; +use Filament\View\PanelsRenderHook; +use Illuminate\Contracts\Support\Htmlable; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; +use Illuminate\Support\HtmlString; use Illuminate\View\Middleware\ShareErrorsFromSession; class AdminPanelProvider extends PanelProvider @@ -26,21 +27,52 @@ public function panel(Panel $panel): Panel ->default() ->id('admin') ->path('admin') - ->brandName('Nexum') + ->brandName('NEXUM') + ->brandLogo(asset('images/eggon_logo_43542.png')) + ->brandLogoHeight('3.25rem') ->login() ->colors([ - 'primary' => Color::Amber, + 'primary' => [ + 50 => 'f5f8fb', + 100 => 'edf5fb', + 200 => 'd7e4ef', + 300 => 'bfd3e4', + 400 => '8eb8d7', + 500 => '6fa6cf', + 600 => '4f8bb9', + 700 => '2f678f', + 800 => '285675', + 900 => '18324a', + 950 => '0f1720', + ], ]) + ->renderHook( + PanelsRenderHook::STYLES_AFTER, + fn (): Htmlable => new HtmlString(''), + ) + ->renderHook( + PanelsRenderHook::SCRIPTS_AFTER, + fn (): Htmlable => new HtmlString(<<<'HTML' + + HTML), + ) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') - ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') ->pages([ - Pages\Dashboard::class, + Dashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') - ->widgets([ - Widgets\AccountWidget::class, - Widgets\FilamentInfoWidget::class, - ]) + ->widgets([]) ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, diff --git a/app/Services/BedrockService.php b/app/Services/BedrockService.php index ff6fdb5..c1ce9b0 100644 --- a/app/Services/BedrockService.php +++ b/app/Services/BedrockService.php @@ -9,8 +9,9 @@ class BedrockService { public function __construct( - private readonly BedrockRuntimeClient $client, - private readonly string $modelId, + private readonly ?BedrockRuntimeClient $client, + private readonly ?string $modelId, + private readonly bool $enabled = true, ) {} /** @@ -22,6 +23,15 @@ public function __construct( */ public function generateCommunication(string $prompt, string $tone, string $style): array { + if (! $this->enabled) { + return [ + 'title' => 'Bozza comunicazione NEXUM', + 'body' => "Contenuto generato in modalita PoC con tono {$tone} e stile {$style}. Prompt di partenza: {$prompt}", + ]; + } + + $this->ensureConfigured(); + $userMessage = "Genera una comunicazione aziendale con tono '{$tone}' e stile '{$style}'.\n\nContenuto richiesto: {$prompt}\n\nRispondi SOLO con JSON valido con le chiavi: title (stringa), body (stringa)."; try { @@ -63,6 +73,14 @@ public function generateCommunication(string $prompt, string $tone, string $styl */ public function splitDocument(string $pdfPath): array { + if (! $this->enabled) { + return [ + ['employee_name' => 'Mario Rossi', 'start_page' => 1, 'end_page' => 1], + ]; + } + + $this->ensureConfigured(); + $pdfContent = base64_encode(file_get_contents(storage_path('app/'.$pdfPath))); $prompt = "Analizza questo PDF di cedolini aziendali. Identifica tutti i dipendenti presenti.\nPer ogni dipendente restituisci un array JSON con: employee_name (stringa), start_page (intero, 1-indexed), end_page (intero, 1-indexed).\nRispondi SOLO con JSON valido (array). Se non ci sono dipendenti distinti, restituisci un array vuoto."; @@ -111,6 +129,20 @@ public function splitDocument(string $pdfPath): array */ public function extractFields(string $subPdfPath): array { + if (! $this->enabled) { + return [ + 'employee_first_name' => 'Mario', + 'employee_last_name' => 'Rossi', + 'company_name' => 'Azienda Demo Srl', + 'document_date' => now()->toDateString(), + 'document_type' => 'Cedolino', + 'description' => 'Dati estratti in modalita PoC.', + 'confidence_score' => (int) env('POC_CONFIDENCE_THRESHOLD', 80), + ]; + } + + $this->ensureConfigured(); + $pdfContent = base64_encode(file_get_contents(storage_path('app/'.$subPdfPath))); $prompt = "Estrai i seguenti campi da questo cedolino PDF.\nRispondi SOLO con JSON valido con le chiavi: employee_first_name, employee_last_name, company_name, document_date (formato YYYY-MM-DD), document_type, description (max 200 caratteri), confidence_score (intero 0-100 che indica la tua confidenza nell'estrazione).\nUsa null per i campi non trovati."; @@ -157,4 +189,11 @@ public function extractFields(string $subPdfPath): array throw new \RuntimeException('Errore nella chiamata a Bedrock (extract): '.$e->getMessage(), previous: $e); } } + + private function ensureConfigured(): void + { + if (! $this->client || ! $this->modelId) { + throw new \RuntimeException('Bedrock non configurato: impostare BEDROCK_ENABLED=true e BEDROCK_MODEL_ID.'); + } + } } diff --git a/composer.json b/composer.json index bc89422..997db4c 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "filament/filament": "^3.3", "laravel/framework": "^12.0", "laravel/tinker": "^3.0", + "league/flysystem-aws-s3-v3": "^3.32", "setasign/fpdf": "^1.8", "setasign/fpdi": "^2.6" }, diff --git a/composer.lock b/composer.lock index 7f63cdf..850f693 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5e9b74545655c8dbe54ed21588ef5fd4", + "content-hash": "ae05ea1633e4bf29188401ebb8d99616", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2947,6 +2947,61 @@ }, "time": "2026-03-25T07:59:30+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.32.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.295.10", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + }, + "time": "2026-02-25T16:46:44+00:00" + }, { "name": "league/flysystem-local", "version": "3.31.0", diff --git a/config/services.php b/config/services.php index 8c2079b..e6eed8b 100644 --- a/config/services.php +++ b/config/services.php @@ -36,8 +36,23 @@ ], 'bedrock' => [ - 'model_id' => env('BEDROCK_MODEL_ID', 'us.anthropic.claude-3-5-sonnet-20241022-v2:0'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'enabled' => env('BEDROCK_ENABLED', false), + 'model_id' => env('BEDROCK_MODEL_ID'), + 'classifier_model_id' => env('BEDROCK_CLASSIFIER_MODEL_ID'), + 'guardrail_id' => env('BEDROCK_GUARDRAIL_ID'), + 'region' => env('BEDROCK_AWS_REGION', env('AWS_DEFAULT_REGION', 'eu-central-1')), + ], + + 'textract' => [ + 'enabled' => env('TEXTRACT_ENABLED', false), + 'region' => env('TEXTRACT_AWS_REGION', env('AWS_DEFAULT_REGION', 'eu-central-1')), + 's3_bucket' => env('TEXTRACT_S3_BUCKET'), + 'sns_topic_arn' => env('TEXTRACT_SNS_TOPIC_ARN'), + 'role_arn' => env('TEXTRACT_ROLE_ARN'), + ], + + 'ai_worker' => [ + 'url' => env('AI_WORKER_URL', 'http://ai-worker:8000'), ], ]; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..612a738 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,152 @@ +services: + nginx: + image: nginx:1.27-alpine + depends_on: + - app + ports: + - "${NGINX_PORT:-8080}:80" + volumes: + - ./:/var/www/html:ro + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - poc + + app: + build: + context: . + dockerfile: docker/php/Dockerfile + environment: + COMPOSER_INSTALL_ON_STARTUP: ${COMPOSER_INSTALL_ON_STARTUP:-true} + LARAVEL_AUTOMATED_SETUP: ${LARAVEL_AUTOMATED_SETUP:-true} + LARAVEL_SETUP_RETRIES: ${LARAVEL_SETUP_RETRIES:-30} + FILAMENT_ADMIN_CREATE: ${FILAMENT_ADMIN_CREATE:-true} + FILAMENT_ADMIN_NAME: ${FILAMENT_ADMIN_NAME:-NEXUM Admin} + FILAMENT_ADMIN_EMAIL: ${FILAMENT_ADMIN_EMAIL:-admin@nexum.local} + FILAMENT_ADMIN_PASSWORD: ${FILAMENT_ADMIN_PASSWORD:-Password123!} + volumes: + - ./:/var/www/html + depends_on: + - postgres + - redis + - minio + - mailpit + networks: + - poc + + queue: + build: + context: . + dockerfile: docker/php/Dockerfile + command: php artisan queue:work redis --sleep=3 --tries=3 --timeout=120 + environment: + COMPOSER_INSTALL_ON_STARTUP: ${COMPOSER_INSTALL_ON_STARTUP:-true} + LARAVEL_WAIT_FOR_SETUP: ${LARAVEL_WAIT_FOR_SETUP:-true} + LARAVEL_SETUP_WAIT_SECONDS: ${LARAVEL_SETUP_WAIT_SECONDS:-120} + volumes: + - ./:/var/www/html + depends_on: + - app + - postgres + - redis + networks: + - poc + + scheduler: + build: + context: . + dockerfile: docker/php/Dockerfile + command: sh -lc "while true; do php artisan schedule:run --verbose --no-interaction; sleep 60; done" + environment: + COMPOSER_INSTALL_ON_STARTUP: ${COMPOSER_INSTALL_ON_STARTUP:-true} + LARAVEL_WAIT_FOR_SETUP: ${LARAVEL_WAIT_FOR_SETUP:-true} + LARAVEL_SETUP_WAIT_SECONDS: ${LARAVEL_SETUP_WAIT_SECONDS:-120} + volumes: + - ./:/var/www/html + depends_on: + - app + - postgres + - redis + networks: + - poc + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${DB_DATABASE:-nexum} + POSTGRES_USER: ${DB_USERNAME:-nexum} + POSTGRES_PASSWORD: ${DB_PASSWORD:-nexum} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - poc + + redis: + image: redis:7-alpine + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data + networks: + - poc + + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - minio-data:/data + networks: + - poc + + minio-init: + image: minio/mc:RELEASE.2025-04-16T18-13-26Z + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin}; do + sleep 2; + done; + mc mb --ignore-existing local/${MINIO_BUCKET:-nexum-local}; + " + networks: + - poc + + mailpit: + image: axllent/mailpit:v1.24 + ports: + - "${MAILPIT_SMTP_PORT:-1025}:1025" + - "${MAILPIT_UI_PORT:-8025}:8025" + networks: + - poc + + ai-worker: + build: + context: ./ai-worker + environment: + AI_WORKER_ENV: ${AI_WORKER_ENV:-local} + OCR_DRIVER: ${DOCUMENT_OCR_DRIVER:-local} + ANALYSIS_DRIVER: ${DOCUMENT_CLASSIFIER_DRIVER:-fake} + POC_CONFIDENCE_THRESHOLD: ${POC_CONFIDENCE_THRESHOLD:-80} + TEXTRACT_ENABLED: ${TEXTRACT_ENABLED:-false} + BEDROCK_ENABLED: ${BEDROCK_ENABLED:-false} + ports: + - "${AI_WORKER_PORT:-8001}:8000" + networks: + - poc + +networks: + poc: + driver: bridge + +volumes: + postgres-data: + redis-data: + minio-data: diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..279d50e --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + + root /var/www/html/public; + index index.php index.html; + + client_max_body_size 50M; + resolver 127.0.0.11 valid=30s ipv6=off; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + set $php_fpm app:9000; + fastcgi_pass $php_fpm; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..8faa9cb --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,39 @@ +FROM php:8.4-fpm-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + git \ + libfreetype6-dev \ + libicu-dev \ + libjpeg62-turbo-dev \ + libpng-dev \ + libpq-dev \ + libzip-dev \ + unzip \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" \ + bcmath \ + gd \ + intl \ + opcache \ + pcntl \ + pdo_pgsql \ + pgsql \ + zip \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +ENV COMPOSER_ALLOW_SUPERUSER=1 + +WORKDIR /var/www/html + +COPY docker/php/entrypoint.sh /usr/local/bin/poc-entrypoint +RUN chmod +x /usr/local/bin/poc-entrypoint + +ENTRYPOINT ["poc-entrypoint"] +CMD ["php-fpm"] diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh new file mode 100644 index 0000000..dbfd918 --- /dev/null +++ b/docker/php/entrypoint.sh @@ -0,0 +1,144 @@ +#!/bin/sh +set -eu + +cd /var/www/html + +setup_marker="storage/framework/poc-setup-complete" + +run_with_retries() { + description="$1" + shift + + attempt=1 + max_attempts="${LARAVEL_SETUP_RETRIES:-30}" + + until "$@"; do + if [ "$attempt" -ge "$max_attempts" ]; then + echo "$description failed after $max_attempts attempts." + return 1 + fi + + echo "$description not ready yet; retrying in 2 seconds..." + attempt=$((attempt + 1)) + sleep 2 + done +} + +filament_admin_exists() { + FILAMENT_ADMIN_EMAIL="$1" php <<'PHP' +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +exit(App\Models\User::where('email', getenv('FILAMENT_ADMIN_EMAIL'))->exists() ? 0 : 1); +PHP +} + +if [ "${COMPOSER_INSTALL_ON_STARTUP:-true}" = "true" ] && [ -f composer.json ]; then + if [ -f composer.lock ]; then + composer_hash="$(sha256sum composer.lock | awk '{ print $1 }')" + else + composer_hash="$(sha256sum composer.json | awk '{ print $1 }')" + fi + + stamp_file="vendor/.composer-lock.sha256" + needs_install=false + + if [ ! -f vendor/autoload.php ]; then + needs_install=true + elif [ ! -f "$stamp_file" ]; then + needs_install=true + elif [ "$(cat "$stamp_file" 2>/dev/null || true)" != "$composer_hash" ]; then + needs_install=true + fi + + if [ "$needs_install" = "true" ]; then + mkdir -p vendor + lock_dir="vendor/.composer-install.lock" + lock_acquired=false + + while [ "$lock_acquired" = "false" ]; do + if mkdir "$lock_dir" 2>/dev/null; then + lock_acquired=true + trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT INT TERM + else + echo "Composer install already running in another container; waiting..." + sleep 2 + + if [ -f vendor/autoload.php ] \ + && [ -f "$stamp_file" ] \ + && [ "$(cat "$stamp_file" 2>/dev/null || true)" = "$composer_hash" ]; then + needs_install=false + break + fi + fi + done + + if [ "$needs_install" = "true" ]; then + echo "Installing Composer dependencies..." + composer install --no-interaction --prefer-dist --optimize-autoloader + echo "$composer_hash" > "$stamp_file" + fi + fi +fi + +if [ "${LARAVEL_AUTOMATED_SETUP:-false}" = "true" ]; then + mkdir -p storage/framework + rm -f "$setup_marker" + + if [ ! -f .env ] && [ -f .env.example ]; then + echo "Creating local .env from .env.example..." + cp .env.example .env + fi + + if [ -f .env ]; then + app_key="$(grep -E '^APP_KEY=' .env | tail -n 1 | cut -d '=' -f 2- || true)" + + if [ -z "$app_key" ]; then + echo "Generating Laravel application key..." + php artisan key:generate --force --no-interaction + fi + fi + + run_with_retries "Database migration" php artisan migrate --force --no-interaction + + if [ "${FILAMENT_ADMIN_CREATE:-true}" = "true" ]; then + admin_name="${FILAMENT_ADMIN_NAME:-NEXUM Admin}" + admin_email="${FILAMENT_ADMIN_EMAIL:-admin@nexum.local}" + admin_password="${FILAMENT_ADMIN_PASSWORD:-Password123!}" + + if filament_admin_exists "$admin_email"; then + echo "Filament admin user already exists: $admin_email" + else + echo "Creating Filament admin user: $admin_email" + php artisan make:filament-user \ + --name="$admin_name" \ + --email="$admin_email" \ + --password="$admin_password" \ + --no-interaction + fi + fi + + touch "$setup_marker" +fi + +if [ "${LARAVEL_WAIT_FOR_SETUP:-false}" = "true" ]; then + waited=0 + max_wait="${LARAVEL_SETUP_WAIT_SECONDS:-120}" + + while [ ! -f "$setup_marker" ]; do + if [ "$waited" -ge "$max_wait" ]; then + echo "Laravel automated setup did not complete within $max_wait seconds." + exit 1 + fi + + echo "Waiting for Laravel automated setup to complete..." + waited=$((waited + 2)) + sleep 2 + done +fi + +exec "$@" diff --git a/docs/poc-scope.md b/docs/poc-scope.md new file mode 100644 index 0000000..c65eacf --- /dev/null +++ b/docs/poc-scope.md @@ -0,0 +1,54 @@ +# Perimetro funzionale della PoC + +Questa PoC dimostra solo i flussi principali descritti nell'Analisi dei Requisiti. +La rifinitura di dettaglio, le regole complete e le scelte definitive di UI/UX sono rimandate al PB. + +## AI Assistant Generativo + +La PoC include: + +- generazione di una bozza a partire da prompt, tono e stile; +- validazione minima del prompt mancante o insufficiente; +- anteprima di titolo, testo e immagine di copertina placeholder; +- modifica manuale di titolo e testo; +- rigenerazione della bozza tramite provider fake o Bedrock se configurato; +- eliminazione della bozza corrente; +- salvataggio in bozza; +- invio email simulato tramite Mailpit; +- storico delle generazioni con ricerca e filtro base; +- rating da 1 a 5 e commento opzionale; +- dashboard minima con numero generazioni, prompt salvati, rating medio e feedback recenti. + +## AI Co-Pilot documentale + +La PoC include: + +- upload singolo di documenti PDF; +- controllo minimo di formato e duplicato tramite hash del file; +- inserimento opzionale di classificazione e metadati iniziali in upload; +- avvio asincrono dell'analisi documentale tramite Laravel Queue; +- OCR/parsing placeholder tramite AI worker locale; +- classificazione documento placeholder; +- estrazione metadati principali: dipendente, azienda, nome file, data, pagine, tipologia, descrizione; +- calcolo o simulazione del confidence score; +- split documentale placeholder, con collegamento tra documento originale e porzione destinata al dipendente; +- storico documenti ordinato dal piu recente; +- filtri principali: ricerca libera, stato invio, soglia di confidenza, mese e anno; +- vista dettaglio documento con dati estratti; +- modifica manuale dei campi editabili; +- generazione bozza di invio con destinatario, oggetto e testo; +- invio simulato tramite Mailpit; +- dashboard minima AI Co-Pilot con documenti analizzati, classificazioni corrette simulate, documenti sotto soglia, destinatari riconosciuti e tempo medio di analisi. + +## Esclusioni consapevoli dalla PoC + +Non sono inclusi nella PoC iniziale: + +- gestione avanzata di ruoli, permessi e policy; +- audit trail completo e policy di conservazione; +- retry avanzato, prove di consegna e tracciamento letture; +- entity resolution completa su anagrafiche reali; +- split PDF realmente affidabile per documenti complessi; +- OCR/Textract e Bedrock in modalita production; +- dashboard analitiche avanzate o esportazioni complesse; +- Kubernetes, Terraform, monitoring enterprise e integrazioni NEXUM reali. diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..4453d71 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,6 +19,7 @@ + diff --git a/public/css/nexum-filament.css b/public/css/nexum-filament.css new file mode 100644 index 0000000..97be67a --- /dev/null +++ b/public/css/nexum-filament.css @@ -0,0 +1,385 @@ +:root { + --nexum-surface-page: #f5f8fb; + --nexum-surface: #ffffff; + --nexum-surface-soft: #edf5fb; + --nexum-surface-muted: #f8fbfd; + --nexum-border: #d7e4ef; + --nexum-border-strong: #bfd3e4; + --nexum-text: #18324a; + --nexum-muted: #63798d; + --nexum-primary: #6fa6cf; + --nexum-primary-deep: #2f678f; + --nexum-accent: #f28a52; + --nexum-accent-soft: #fff1e8; + --nexum-shadow: 0 10px 28px rgba(38, 82, 120, 0.08); + --nexum-radius: 8px; +} + +.dark { + --nexum-surface-page: #0f1720; + --nexum-surface: #16212c; + --nexum-surface-soft: #223343; + --nexum-surface-muted: #1c2a36; + --nexum-border: #2c4052; + --nexum-border-strong: #3c566c; + --nexum-text: #e8f0f7; + --nexum-muted: #a8b8c6; + --nexum-primary: #79add3; + --nexum-primary-deep: #9dcced; + --nexum-accent: #f09a62; + --nexum-accent-soft: #3a2c25; + --nexum-shadow: 0 14px 34px rgba(0, 0, 0, 0.22); +} + +body.fi-body, +.fi-layout, +.fi-main-ctn { + background: var(--nexum-surface-page); + color: var(--nexum-text); + font-family: "Avenir Next", "Segoe UI", Arial, sans-serif; +} + +.fi-sidebar { + background: var(--nexum-surface); + border-right: 1px solid var(--nexum-border); + box-shadow: none; +} + +.fi-sidebar-header { + background: var(--nexum-surface); + border-bottom: 1px solid var(--nexum-border); +} + +.fi-logo img, +.fi-sidebar-header img { + border-radius: var(--nexum-radius); + object-fit: contain; +} + +.dark .fi-logo img, +.dark .fi-sidebar-header img { + filter: brightness(0) invert(1); + opacity: 0.96; +} + +.fi-sidebar-group-label { + color: var(--nexum-muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.fi-sidebar-item-button { + border-radius: var(--nexum-radius); + color: var(--nexum-text); + transition: background 160ms ease, color 160ms ease; +} + +.fi-sidebar-item-button:hover, +.fi-sidebar-item-button:focus-visible { + background: var(--nexum-surface-soft); +} + +.fi-sidebar-item.fi-active .fi-sidebar-item-button { + background: var(--nexum-primary); + color: #ffffff; +} + +.fi-sidebar-item.fi-active .fi-sidebar-item-icon, +.fi-sidebar-item.fi-active .fi-sidebar-item-label { + color: #ffffff; +} + +.fi-topbar, +.fi-topbar nav { + background: color-mix(in srgb, var(--nexum-surface) 92%, transparent); + border-bottom-color: var(--nexum-border); + box-shadow: none; +} + +.fi-main { + max-width: 1120px; +} + +.fi-header-heading { + color: var(--nexum-text); + font-size: 1.55rem; + letter-spacing: 0; +} + +.fi-header-subheading, +.fi-breadcrumbs, +.fi-breadcrumbs a, +.fi-ta-text, +.fi-fo-field-wrp-helper-text, +.fi-in-text, +.fi-section-description { + color: var(--nexum-muted); +} + +.fi-section, +.fi-ta-ctn, +.fi-modal-window, +.fi-dropdown-panel, +.fi-simple-main { + background: var(--nexum-surface); + border: 1px solid var(--nexum-border); + border-radius: var(--nexum-radius); + box-shadow: var(--nexum-shadow); +} + +.fi-section-header { + border-bottom-color: var(--nexum-border); +} + +.fi-section-header-heading, +.fi-ta-header-heading, +.fi-modal-heading, +.fi-fo-field-wrp-label span { + color: var(--nexum-text); + font-weight: 700; +} + +.fi-input-wrp, +.fi-select-input, +.fi-textarea, +.fi-input { + border-radius: var(--nexum-radius); +} + +.fi-input-wrp { + background: var(--nexum-surface); + border-color: var(--nexum-border); +} + +.fi-input, +.fi-select-input, +.fi-textarea { + color: var(--nexum-text); +} + +.fi-input-wrp:focus-within { + border-color: var(--nexum-primary); + box-shadow: 0 0 0 3px rgba(111, 166, 207, 0.22); +} + +.fi-btn { + border-radius: var(--nexum-radius); + font-weight: 700; +} + +.fi-btn-color-primary { + background: var(--nexum-primary); +} + +.fi-btn-color-primary:hover, +.fi-btn-color-primary:focus-visible { + background: var(--nexum-primary-deep); +} + +.fi-badge { + border-radius: 999px; +} + +.fi-ta-row:hover { + background: var(--nexum-surface-muted); +} + +.fi-ta-header, +.fi-ta-content, +.fi-pagination { + background: transparent; +} + +.fi-simple-layout { + background: + radial-gradient(circle at 18% 18%, rgba(111, 166, 207, 0.14), transparent 26rem), + var(--nexum-surface-page); +} + +.fi-simple-main { + max-width: 440px; +} + +.fi-simple-header img { + max-height: 4.5rem; + border-radius: var(--nexum-radius); +} + +.nexum-dashboard { + display: grid; + gap: 18px; +} + +.nexum-hero, +.nexum-panel { + background: var(--nexum-surface); + border: 1px solid var(--nexum-border); + border-radius: var(--nexum-radius); + box-shadow: var(--nexum-shadow); +} + +.nexum-hero { + display: grid; + gap: 18px; + padding: 24px; +} + +.nexum-hero h2 { + max-width: 760px; + margin: 0; + color: var(--nexum-text); + font-size: 1.7rem; + line-height: 1.18; +} + +.nexum-hero p:not(.nexum-eyebrow) { + max-width: 800px; + margin: 10px 0 0; + color: var(--nexum-muted); + line-height: 1.6; +} + +.nexum-panel { + display: grid; + gap: 18px; + padding: 22px; +} + +.nexum-panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.nexum-panel h3 { + margin: 0; + color: var(--nexum-text); + font-size: 1.22rem; + line-height: 1.3; +} + +.nexum-eyebrow { + margin: 0 0 6px; + color: var(--nexum-primary-deep); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.nexum-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.nexum-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 10px 14px; + border-radius: var(--nexum-radius); + font-weight: 700; + text-decoration: none; + transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.nexum-action:hover, +.nexum-action:focus-visible { + box-shadow: 0 8px 18px rgba(47, 103, 143, 0.14); + outline: none; + transform: translateY(-1px); +} + +.nexum-action-primary { + background: var(--nexum-primary); + color: #ffffff; +} + +.nexum-action-secondary { + background: var(--nexum-surface-soft); + color: var(--nexum-text); +} + +.nexum-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.nexum-flow-list, +.nexum-metric-list, +.nexum-history-list { + display: grid; + gap: 10px; +} + +.nexum-flow-list a, +.nexum-metric-list div, +.nexum-history-list div { + display: grid; + gap: 6px; + padding: 14px; + border: 1px solid var(--nexum-border); + border-radius: var(--nexum-radius); + background: var(--nexum-surface-muted); + text-decoration: none; +} + +.nexum-flow-list a:hover, +.nexum-flow-list a:focus-visible { + border-color: var(--nexum-border-strong); + background: var(--nexum-surface-soft); + outline: none; +} + +.nexum-flow-list strong, +.nexum-history-list strong { + color: var(--nexum-text); + line-height: 1.35; +} + +.nexum-flow-list span, +.nexum-history-list span, +.nexum-metric-list span { + color: var(--nexum-muted); + line-height: 1.45; +} + +.nexum-metric-list { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.nexum-metric-list strong { + color: var(--nexum-primary-deep); + font-size: 1.35rem; +} + +@media (max-width: 900px) { + .nexum-grid { + grid-template-columns: 1fr; + } + + .nexum-hero, + .nexum-panel { + padding: 18px; + } +} + +@media (max-width: 640px) { + .fi-main { + padding-inline: 1rem; + } + + .nexum-hero h2 { + font-size: 1.35rem; + } + + .nexum-action { + width: 100%; + } +} diff --git a/public/images/eggon_logo_43542.png b/public/images/eggon_logo_43542.png new file mode 100644 index 0000000000000000000000000000000000000000..29f15d636d8e45fca8bb598dc67b815a87a0316d GIT binary patch literal 22752 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≫w$<<|*-!*l6Y$EmC!iwK9?x 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 TD(h?6$!JUst?t+8dp!F;R(+_# literal 0 HcmV?d00001 diff --git a/resources/views/filament/pages/dashboard.blade.php b/resources/views/filament/pages/dashboard.blade.php new file mode 100644 index 0000000..56dade0 --- /dev/null +++ b/resources/views/filament/pages/dashboard.blade.php @@ -0,0 +1,140 @@ +@php + $assistantMetrics = $this->getAssistantMetrics(); + $copilotMetrics = $this->getCopilotMetrics(); + $recentCommunications = $this->getRecentCommunications(); + $recentDocuments = $this->getRecentDocuments(); +@endphp + + +
+
+
+

Console operativa

+

Gestione assistita di comunicazioni interne e documenti del personale.

+

+ NEXUM supporta redattori HR e operatori CdL nella preparazione dei contenuti, + classificazione documentale, verifica degli esiti e tracciamento delle consegne. +

+
+ + +
+ +
+
+
+

Moduli

+

Da dove partire

+
+
+ + +
+ +
+
+
+
+

AI Assistant

+

Stato contenuti

+
+
+ +
+ @foreach ($assistantMetrics as $metric) +
+ {{ $metric['value'] }} + {{ $metric['label'] }} +
+ @endforeach +
+
+ +
+
+
+

Co-Pilot CdL

+

Stato documenti

+
+
+ +
+ @foreach ($copilotMetrics as $metric) +
+ {{ $metric['value'] }} + {{ $metric['label'] }} +
+ @endforeach +
+
+
+ +
+
+
+
+

Storico prompt

+

Ultime generazioni

+
+
+ +
+ @forelse ($recentCommunications as $item) +
+ {{ $item['title'] }} + {{ $item['meta'] }} +
+ @empty +
+ Nessuna bozza generata + Usa il modulo AI Assistant per creare il primo contenuto. +
+ @endforelse +
+
+ +
+
+
+

Storico documenti

+

Ultime analisi

+
+
+ +
+ @forelse ($recentDocuments as $item) +
+ {{ $item['title'] }} + {{ $item['meta'] }} +
+ @empty +
+ Nessun documento caricato + Carica un PDF dal modulo Co-Pilot per avviare la demo documentale. +
+ @endforelse +
+
+
+
+
From 4a8cc4fe70d70e39b86709f9f71b62458f94793b Mon Sep 17 00:00:00 2001 From: subnetMusk Date: Mon, 11 May 2026 22:11:12 +0200 Subject: [PATCH 02/22] allineata la UI di base a quella del mockup --- app/Filament/Pages/Dashboard.php | 8 +- app/Filament/Resources/UserResource.php | 113 + .../UserResource/Pages/CreateUser.php | 11 + .../Resources/UserResource/Pages/EditUser.php | 20 + .../UserResource/Pages/ListUsers.php | 20 + .../Controllers/Auth/UserLoginController.php | 43 + app/Http/Controllers/Poc/AppApiController.php | 281 ++ .../Controllers/Poc/SessionController.php | 21 + app/Models/User.php | 11 +- database/factories/UserFactory.php | 8 + ..._11_200000_add_is_admin_to_users_table.php | 23 + database/seeders/DatabaseSeeder.php | 1 + docker/php/entrypoint.sh | 15 + public/css/nexum-filament.css | 54 + public/nexum-app/app.js | 2250 +++++++++++++++++ public/nexum-app/eggon_logo_43542.png | Bin 0 -> 22752 bytes public/nexum-app/styles.css | 1493 +++++++++++ resources/views/auth/user-login.blade.php | 110 + .../views/filament/pages/dashboard.blade.php | 140 +- resources/views/poc/app.blade.php | 709 ++++++ routes/web.php | 41 +- tests/Feature/ExampleTest.php | 2 +- tests/Feature/PocAppRoutesTest.php | 159 ++ 23 files changed, 5398 insertions(+), 135 deletions(-) create mode 100644 app/Filament/Resources/UserResource.php create mode 100644 app/Filament/Resources/UserResource/Pages/CreateUser.php create mode 100644 app/Filament/Resources/UserResource/Pages/EditUser.php create mode 100644 app/Filament/Resources/UserResource/Pages/ListUsers.php create mode 100644 app/Http/Controllers/Auth/UserLoginController.php create mode 100644 app/Http/Controllers/Poc/AppApiController.php create mode 100644 app/Http/Controllers/Poc/SessionController.php create mode 100644 database/migrations/2026_05_11_200000_add_is_admin_to_users_table.php create mode 100644 public/nexum-app/app.js create mode 100644 public/nexum-app/eggon_logo_43542.png create mode 100644 public/nexum-app/styles.css create mode 100644 resources/views/auth/user-login.blade.php create mode 100644 resources/views/poc/app.blade.php create mode 100644 tests/Feature/PocAppRoutesTest.php diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php index bfb7319..8568275 100644 --- a/app/Filament/Pages/Dashboard.php +++ b/app/Filament/Pages/Dashboard.php @@ -12,15 +12,15 @@ class Dashboard extends BaseDashboard { - protected static ?string $navigationGroup = 'Panoramica'; + protected static ?string $navigationGroup = 'Amministrazione'; protected static ?string $navigationIcon = 'heroicon-o-squares-2x2'; - protected static ?string $navigationLabel = 'Overview'; + protected static ?string $navigationLabel = 'Dashboard'; - protected static ?int $navigationSort = -10; + protected static ?int $navigationSort = 0; - protected static ?string $title = 'Overview operativa'; + protected static ?string $title = 'Amministrazione credenziali'; protected static string $view = 'filament.pages.dashboard'; diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php new file mode 100644 index 0000000..a165b57 --- /dev/null +++ b/app/Filament/Resources/UserResource.php @@ -0,0 +1,113 @@ +schema([ + Forms\Components\Section::make('Dati account') + ->schema([ + Forms\Components\TextInput::make('name') + ->label('Nome') + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('email') + ->label('Email') + ->email() + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + Forms\Components\Toggle::make('is_admin') + ->label('Accesso amministrativo') + ->helperText('Abilita l’accesso al pannello /admin. Gli utenti non admin accedono solo all’applicativo.') + ->default(false) + ->disabled(fn (?User $record): bool => $record?->id === auth()->id()) + ->dehydrated(fn (?User $record): bool => $record?->id !== auth()->id()), + ]) + ->columns(2), + Forms\Components\Section::make('Credenziali') + ->description('La password viene salvata con hashing Laravel. In modifica lascia vuoto il campo per conservarla.') + ->schema([ + Forms\Components\TextInput::make('password') + ->label('Password') + ->password() + ->revealable() + ->required(fn (string $operation): bool => $operation === 'create') + ->rule(Password::min(12)->letters()->mixedCase()->numbers()) + ->same('password_confirmation') + ->dehydrated(fn (?string $state): bool => filled($state)), + Forms\Components\TextInput::make('password_confirmation') + ->label('Conferma password') + ->password() + ->revealable() + ->required(fn (string $operation): bool => $operation === 'create') + ->dehydrated(false), + ]) + ->columns(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label('Nome') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('email') + ->label('Email') + ->searchable() + ->sortable(), + Tables\Columns\IconColumn::make('is_admin') + ->label('Admin') + ->boolean() + ->sortable(), + Tables\Columns\TextColumn::make('created_at') + ->label('Creato il') + ->dateTime('d/m/Y H:i') + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make() + ->visible(fn (User $record): bool => $record->id !== auth()->id()), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListUsers::route('/'), + 'create' => Pages\CreateUser::route('/create'), + 'edit' => Pages\EditUser::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/UserResource/Pages/CreateUser.php b/app/Filament/Resources/UserResource/Pages/CreateUser.php new file mode 100644 index 0000000..78a3894 --- /dev/null +++ b/app/Filament/Resources/UserResource/Pages/CreateUser.php @@ -0,0 +1,11 @@ +visible(fn (): bool => $this->record->id !== auth()->id()), + ]; + } +} diff --git a/app/Filament/Resources/UserResource/Pages/ListUsers.php b/app/Filament/Resources/UserResource/Pages/ListUsers.php new file mode 100644 index 0000000..ce9770e --- /dev/null +++ b/app/Filament/Resources/UserResource/Pages/ListUsers.php @@ -0,0 +1,20 @@ +label('Crea utente'), + ]; + } +} diff --git a/app/Http/Controllers/Auth/UserLoginController.php b/app/Http/Controllers/Auth/UserLoginController.php new file mode 100644 index 0000000..96efa24 --- /dev/null +++ b/app/Http/Controllers/Auth/UserLoginController.php @@ -0,0 +1,43 @@ +user()) { + return redirect()->route('poc.app'); + } + + return view('auth.user-login'); + } + + /** + * @throws ValidationException + */ + public function store(Request $request): RedirectResponse + { + $credentials = $request->validate([ + 'email' => ['required', 'email:rfc'], + 'password' => ['required', 'string'], + ]); + + if (! Auth::attempt($credentials, $request->boolean('remember'))) { + throw ValidationException::withMessages([ + 'email' => 'Credenziali non valide.', + ]); + } + + $request->session()->regenerate(); + + return redirect()->intended(route('poc.app')); + } +} diff --git a/app/Http/Controllers/Poc/AppApiController.php b/app/Http/Controllers/Poc/AppApiController.php new file mode 100644 index 0000000..ffdb9e2 --- /dev/null +++ b/app/Http/Controllers/Poc/AppApiController.php @@ -0,0 +1,281 @@ +user(); + + return response()->json([ + 'csrfToken' => csrf_token(), + 'user' => [ + 'name' => $user->name, + 'email' => $user->email, + 'initials' => $this->initials($user->name), + 'isAdmin' => $user->is_admin, + ], + 'links' => [ + 'users' => $user->is_admin ? UserResource::getUrl('index') : null, + 'logout' => route('poc.logout'), + ], + ]); + } + + public function state(): JsonResponse + { + return response()->json($this->stateData()); + } + + public function generateCommunication(Request $request, BedrockService $bedrock): JsonResponse + { + $validated = $request->validate([ + 'prompt' => ['required', 'string', 'min:12', 'max:5000'], + 'audience' => ['required', 'string', Rule::in(self::AUDIENCES)], + 'tone' => ['required', 'string', Rule::in(self::TONES)], + 'style' => ['required', 'string', Rule::in(self::STYLES)], + 'channel' => ['required', 'string', Rule::in(self::CHANNELS)], + ]); + + $style = $validated['style'].' / '.$validated['channel']; + $prompt = implode("\n", [ + 'Pubblico: '.$validated['audience'], + 'Canale: '.$validated['channel'], + 'Richiesta: '.$validated['prompt'], + ]); + + try { + $generated = $bedrock->generateCommunication($prompt, $validated['tone'], $style); + } catch (\Throwable $e) { + Log::warning('PoC communication generation failed', ['message' => $e->getMessage()]); + + return response()->json([ + 'message' => 'Generazione non disponibile. Verifica la configurazione AI e riprova.', + ], 502); + } + + $communication = Communication::create([ + 'prompt' => $validated['prompt'], + 'tone' => $validated['tone'], + 'style' => $style, + 'generated_title' => $generated['title'], + 'generated_body' => $generated['body'], + 'status' => CommunicationStatus::Draft, + ]); + + return response()->json([ + 'message' => 'Bozza generata correttamente.', + 'communication' => $this->serializeCommunication($communication), + 'state' => $this->stateData(), + ], 201); + } + + public function runDocumentOcr(Request $request): JsonResponse + { + $validated = $request->validate([ + 'document' => ['required', 'file', 'mimetypes:application/pdf', 'max:10240'], + ]); + + $file = $validated['document']; + $path = $file->store('documents/originals', 'local'); + + $original = OriginalDocument::create([ + 'file_path' => $path, + 'original_filename' => $file->getClientOriginalName(), + 'processing_status' => ProcessingStatus::Processing, + ]); + + $workerMessage = 'OCR locale/Textract predisposto; campi non ancora disponibili.'; + + try { + $response = Http::timeout(8) + ->acceptJson() + ->post(rtrim((string) config('services.ai_worker.url'), '/').'/ocr', [ + 'document_id' => (string) $original->id, + 'storage_path' => $path, + 'driver' => config('services.textract.enabled') ? 'textract' : 'local', + 'language' => 'ita+eng', + ]); + + if ($response->successful()) { + $workerMessage = $response->json('message') ?: $workerMessage; + } + } catch (\Throwable $e) { + Log::info('PoC OCR worker unavailable; keeping null extracted fields', [ + 'original_document_id' => $original->id, + 'message' => $e->getMessage(), + ]); + } + + $subDocument = SubDocument::create([ + 'original_document_id' => $original->id, + 'file_path' => $path, + 'start_page' => 1, + 'end_page' => 1, + ]); + + ExtractedData::create([ + 'sub_document_id' => $subDocument->id, + 'employee_first_name' => null, + 'employee_last_name' => null, + 'company_name' => null, + 'document_date' => null, + 'document_type' => null, + 'description' => null, + 'confidence_score' => null, + ]); + + $original->update(['processing_status' => ProcessingStatus::Completed]); + + return response()->json([ + 'message' => $workerMessage, + 'document' => $this->serializeDocument($subDocument->fresh(['originalDocument', 'extractedData'])), + 'state' => $this->stateData(), + ], 201); + } + + private function stateData(): array + { + $communications = Communication::query()->latest()->limit(6)->get(); + $documents = SubDocument::query() + ->with(['originalDocument', 'extractedData']) + ->latest() + ->limit(40) + ->get(); + + $documentCount = OriginalDocument::query()->count(); + $sentCount = SubDocument::query()->where('send_status', 'sent')->count(); + $reviewCount = ExtractedData::query() + ->where(function ($query) { + $query->whereNull('confidence_score') + ->orWhere('confidence_score', '<', (int) env('POC_CONFIDENCE_THRESHOLD', 80)); + }) + ->count(); + + return [ + 'assistant' => [ + 'metrics' => [ + ['value' => Communication::query()->count(), 'label' => 'Contenuti generati'], + ['value' => Communication::query()->where('status', CommunicationStatus::Draft)->count(), 'label' => 'Bozze da rivedere'], + ['value' => 0, 'label' => 'Feedback raccolti'], + ['value' => 'n/d', 'label' => 'Rating medio'], + ], + 'history' => $communications + ->map(fn (Communication $communication): array => $this->serializeCommunication($communication)) + ->values() + ->all(), + ], + 'copilot' => [ + 'metrics' => [ + ['value' => $documentCount, 'label' => 'Documenti analizzati'], + ['value' => $reviewCount, 'label' => 'Da verificare'], + ['value' => max(0, $documents->count() - $reviewCount - $sentCount), 'label' => 'Pronti per invio'], + ['value' => $sentCount, 'label' => 'Inviati'], + ['value' => 'n/d', 'label' => 'Tempo medio analisi'], + ], + 'documents' => $documents + ->map(fn (SubDocument $document): array => $this->serializeDocument($document)) + ->values() + ->all(), + ], + ]; + } + + private function serializeCommunication(Communication $communication): array + { + return [ + 'id' => $communication->id, + 'prompt' => $communication->prompt, + 'tone' => $communication->tone, + 'style' => $communication->style, + 'title' => $communication->generated_title, + 'body' => $communication->generated_body, + 'status' => $communication->status->label(), + 'createdAt' => $communication->created_at?->format('d/m/Y H:i'), + ]; + } + + private function serializeDocument(SubDocument $subDocument): array + { + $original = $subDocument->originalDocument; + $data = $subDocument->extractedData; + $employee = trim(implode(' ', array_filter([ + $data?->employee_first_name, + $data?->employee_last_name, + ]))); + $confidence = $data?->confidence_score; + $pages = max(1, ((int) $subDocument->end_page - (int) $subDocument->start_page) + 1); + + return [ + 'id' => 'sub-'.$subDocument->id, + 'title' => $data?->document_type ?: $original?->original_filename, + 'employee' => $employee !== '' ? $employee : null, + 'company' => $data?->company_name, + 'file' => $original?->original_filename, + 'date' => $data?->document_date?->format('d/m/Y'), + 'pages' => $pages, + 'type' => $data?->document_type, + 'description' => $data?->description, + 'confidence' => $confidence, + 'deliveryStatus' => $subDocument->send_status->label(), + 'previewLines' => [ + 'Anteprima PDF non disponibile nella PoC.', + 'OCR locale/Textract predisposto.', + 'Campi mostrati come non disponibili finché non vengono estratti.', + ], + ]; + } + + private function initials(string $name): string + { + $parts = array_values(array_filter(preg_split('/\s+/', trim($name)) ?: [])); + + if ($parts === []) { + return 'U'; + } + + return strtoupper(substr($parts[0], 0, 1).substr($parts[1] ?? '', 0, 1)); + } +} diff --git a/app/Http/Controllers/Poc/SessionController.php b/app/Http/Controllers/Poc/SessionController.php new file mode 100644 index 0000000..a884320 --- /dev/null +++ b/app/Http/Controllers/Poc/SessionController.php @@ -0,0 +1,21 @@ +logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8b02fca..e17452c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,13 +4,15 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; +use Filament\Models\Contracts\FilamentUser; +use Filament\Panel; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; #[Hidden(['password', 'remember_token'])] -class User extends Authenticatable +class User extends Authenticatable implements FilamentUser { /** @use HasFactory */ use HasFactory, Notifiable; @@ -24,6 +26,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'is_admin', ]; @@ -36,7 +39,13 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', + 'is_admin' => 'boolean', 'password' => 'hashed', ]; } + + public function canAccessPanel(Panel $panel): bool + { + return $panel->getId() === 'admin' && $this->is_admin; + } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index c4ceb07..4abcee0 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,6 +29,7 @@ public function definition(): array 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), + 'is_admin' => false, 'remember_token' => Str::random(10), ]; } @@ -42,4 +43,11 @@ public function unverified(): static 'email_verified_at' => null, ]); } + + public function admin(): static + { + return $this->state(fn (array $attributes) => [ + 'is_admin' => true, + ]); + } } diff --git a/database/migrations/2026_05_11_200000_add_is_admin_to_users_table.php b/database/migrations/2026_05_11_200000_add_is_admin_to_users_table.php new file mode 100644 index 0000000..81a7481 --- /dev/null +++ b/database/migrations/2026_05_11_200000_add_is_admin_to_users_table.php @@ -0,0 +1,23 @@ +boolean('is_admin')->default(false)->after('password')->index(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex(['is_admin']); + $table->dropColumn('is_admin'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..a6535b0 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -20,6 +20,7 @@ public function run(): void User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', + 'is_admin' => false, ]); } } diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index dbfd918..e26655e 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -37,6 +37,19 @@ exit(App\Models\User::where('email', getenv('FILAMENT_ADMIN_EMAIL'))->exists() ? PHP } +mark_filament_admin() { + FILAMENT_ADMIN_EMAIL="$1" php <<'PHP' +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +App\Models\User::where('email', getenv('FILAMENT_ADMIN_EMAIL'))->update(['is_admin' => true]); +PHP +} + if [ "${COMPOSER_INSTALL_ON_STARTUP:-true}" = "true" ] && [ -f composer.json ]; then if [ -f composer.lock ]; then composer_hash="$(sha256sum composer.lock | awk '{ print $1 }')" @@ -120,6 +133,8 @@ if [ "${LARAVEL_AUTOMATED_SETUP:-false}" = "true" ]; then --password="$admin_password" \ --no-interaction fi + + mark_filament_admin "$admin_email" fi touch "$setup_marker" diff --git a/public/css/nexum-filament.css b/public/css/nexum-filament.css index 97be67a..785d6e8 100644 --- a/public/css/nexum-filament.css +++ b/public/css/nexum-filament.css @@ -261,6 +261,18 @@ body.fi-body, line-height: 1.3; } +.nexum-admin-dashboard { + display: grid; + gap: 18px; +} + +.nexum-admin-copy { + max-width: 760px; + margin: 0; + color: var(--nexum-muted); + line-height: 1.6; +} + .nexum-eyebrow { margin: 0 0 6px; color: var(--nexum-primary-deep); @@ -383,3 +395,45 @@ body.fi-body, width: 100%; } } + +body:has(.nexum-app-host) { + overflow: hidden; +} + +body:has(.nexum-app-host) .fi-sidebar, +body:has(.nexum-app-host) .fi-topbar, +body:has(.nexum-app-host) .fi-breadcrumbs, +body:has(.nexum-app-host) .fi-header { + display: none !important; +} + +body:has(.nexum-app-host) .fi-layout, +body:has(.nexum-app-host) .fi-main-ctn, +body:has(.nexum-app-host) .fi-main, +body:has(.nexum-app-host) .fi-page { + width: 100vw !important; + max-width: none !important; + min-height: 100vh !important; + margin: 0 !important; + padding: 0 !important; + background: #f5f8fb !important; +} + +body:has(.nexum-app-host) .fi-main { + display: block !important; +} + +.nexum-app-host { + width: 100vw; + height: 100vh; + min-height: 100vh; + background: #f5f8fb; +} + +.nexum-app-frame { + display: block; + width: 100vw; + height: 100vh; + border: 0; + background: #f5f8fb; +} diff --git a/public/nexum-app/app.js b/public/nexum-app/app.js new file mode 100644 index 0000000..40f4a2a --- /dev/null +++ b/public/nexum-app/app.js @@ -0,0 +1,2250 @@ +const views = { + overview: "Overview operativa", + assistant: "AI Assistant Generativo", + copilot: "AI Co-Pilot per i CdL" +}; + +const apiRoutes = { + session: "/poc/api/session", + state: "/poc/api/state", + communications: "/poc/api/communications", + documentOcr: "/poc/api/documents/ocr" +}; + +let csrfToken = ""; +let appState = null; + +const mainNavItems = document.querySelectorAll(".nav-item"); +const subNavItems = document.querySelectorAll(".nav-subitem"); +const sections = document.querySelectorAll(".view"); +const titleNode = document.getElementById("view-title"); +const themeToggle = document.getElementById("theme-toggle"); +const profileToggle = document.getElementById("profile-toggle"); +const profileMenu = document.getElementById("profile-menu"); +const profileInitials = document.getElementById("profile-initials"); +const profileName = document.getElementById("profile-name"); +const profileEmail = document.getElementById("profile-email"); +const profileUsersLink = document.getElementById("profile-users-link"); +const profileLogoutForm = document.getElementById("profile-logout-form"); +const profileLogoutToken = document.getElementById("profile-logout-token"); +const backToTopButton = document.getElementById("back-to-top"); +const storedTheme = window.localStorage.getItem("nexum-theme"); +const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; +let currentTheme = storedTheme || (systemPrefersDark ? "dark" : "light"); +let currentView = "overview"; + +function isVisibleNode(node) { + return Boolean(node) && !node.classList.contains("is-hidden"); +} + +function setText(node, value) { + if (node) { + node.textContent = value; + } +} + +function setValue(node, value) { + if (node) { + node.value = value; + } +} + +function setModelValue(node, value) { + if (!node) { + return; + } + + node.value = value; + node.dataset.modelValue = value; + node.closest(".field")?.classList.remove("has-manual-correction"); +} + +function normalizeMetricRows(values) { + return values.map((item) => Array.isArray(item) ? item : [String(item.value ?? "n/d"), item.label ?? "Dato"]); +} + +function renderMetricList(list, values) { + if (!list) { + return; + } + + list.replaceChildren(); + normalizeMetricRows(values).forEach(([value, label]) => { + const item = document.createElement("li"); + const strong = document.createElement("strong"); + const span = document.createElement("span"); + strong.textContent = value; + span.textContent = label; + item.append(strong, span); + list.append(item); + }); +} + +function humanApiError(error) { + if (error?.errors) { + return Object.values(error.errors).flat().join(" "); + } + + return error?.message || "Operazione non disponibile."; +} + +async function apiRequest(url, options = {}) { + const headers = { + "Accept": "application/json", + "X-Requested-With": "XMLHttpRequest", + ...(options.headers || {}) + }; + + if (csrfToken && options.method && options.method !== "GET") { + headers["X-CSRF-TOKEN"] = csrfToken; + } + + const response = await fetch(url, { + credentials: "same-origin", + ...options, + headers + }); + + const contentType = response.headers.get("content-type") || ""; + const data = contentType.includes("application/json") ? await response.json() : {}; + + if (!response.ok) { + throw data; + } + + return data; +} + +async function postJson(url, payload) { + return apiRequest(url, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); +} + +function updateProfile(session) { + csrfToken = session.csrfToken || ""; + setText(profileInitials, session.user?.initials || "--"); + setText(profileName, session.user?.name || "Utente"); + setText(profileEmail, session.user?.email || "Sessione attiva"); + + if (profileUsersLink) { + const hasUsersLink = Boolean(session.links?.users); + profileUsersLink.classList.toggle("hidden", !hasUsersLink); + if (hasUsersLink) { + profileUsersLink.href = session.links.users; + } + } + if (profileLogoutForm && session.links?.logout) { + profileLogoutForm.action = session.links.logout; + } + if (profileLogoutToken) { + profileLogoutToken.value = csrfToken; + } +} + +function metricRowsText(list) { + return Array.from(list?.querySelectorAll("li") || []) + .map((item) => { + const value = item.querySelector("strong")?.textContent || ""; + const label = item.querySelector("span")?.textContent || ""; + return `${label}: ${value}`; + }) + .join("\n"); +} + +function downloadTextReport(filename, content) { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function applyTheme(theme) { + currentTheme = theme; + document.documentElement.dataset.theme = theme; + window.localStorage.setItem("nexum-theme", theme); + themeToggle?.setAttribute("aria-pressed", String(theme === "dark")); + themeToggle?.setAttribute( + "aria-label", + theme === "dark" ? "Attiva tema chiaro" : "Attiva tema scuro" + ); +} + +applyTheme(currentTheme); + +themeToggle?.addEventListener("click", () => { + applyTheme(currentTheme === "dark" ? "light" : "dark"); +}); + +profileToggle?.addEventListener("click", () => { + const hidden = profileMenu?.classList.toggle("hidden"); + profileToggle.setAttribute("aria-expanded", String(!hidden)); +}); + +document.addEventListener("click", (event) => { + if (!profileMenu || !profileToggle) { + return; + } + + if (profileMenu.contains(event.target) || profileToggle.contains(event.target)) { + return; + } + + profileMenu.classList.add("hidden"); + profileToggle.setAttribute("aria-expanded", "false"); +}); + +function updateBackToTopVisibility() { + backToTopButton?.classList.toggle("visible", window.scrollY > 360); +} + +function syncSubnavWithScroll() { + const activeTargets = Array.from(subNavItems) + .filter((button) => button.dataset.view === currentView && isVisibleNode(button)) + .map((button) => document.getElementById(button.dataset.target)) + .filter((target) => isVisibleNode(target)); + + if (activeTargets.length === 0) { + activateSubnav("workspace-top"); + return; + } + + const nearPageBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 8; + if (nearPageBottom) { + activateSubnav(activeTargets[activeTargets.length - 1].id); + return; + } + + const anchorOffset = 120; + let activeId = activeTargets[0].id; + let smallestDistance = Number.POSITIVE_INFINITY; + + activeTargets.forEach((target) => { + const distance = Math.abs(target.getBoundingClientRect().top - anchorOffset); + if (distance < smallestDistance) { + smallestDistance = distance; + activeId = target.id; + } + }); + + activateSubnav(activeId); +} + +function handleScroll() { + updateBackToTopVisibility(); + syncSubnavWithScroll(); +} + +window.addEventListener("scroll", handleScroll, { passive: true }); + +backToTopButton?.addEventListener("click", () => { + document.getElementById("workspace-top")?.scrollIntoView({ behavior: "smooth", block: "start" }); +}); + +updateBackToTopVisibility(); + +function setView(viewName) { + if (!views[viewName]) { + return; + } + + mainNavItems.forEach((button) => { + button.classList.toggle("active", button.dataset.view === viewName); + }); + + sections.forEach((section) => { + section.classList.toggle("active", section.dataset.view === viewName); + }); + + currentView = viewName; + setText(titleNode, views[viewName]); +} + +function activateSubnav(targetId) { + subNavItems.forEach((button) => { + button.classList.toggle("active", button.dataset.target === targetId); + }); +} + +function setStepLocked(stepId, locked) { + const step = document.querySelector(`[data-step="${stepId}"]`); + if (!step) { + return; + } + + step.classList.toggle("locked", locked); + step.setAttribute("aria-disabled", String(locked)); + + step.querySelectorAll("button, input, select, textarea").forEach((control) => { + control.disabled = locked; + }); +} + +function unlockSteps(stepIds) { + stepIds.forEach((stepId) => setStepLocked(stepId, false)); +} + +function goTo(viewName, targetId) { + setView(viewName); + activateSubnav(targetId); + + window.requestAnimationFrame(() => { + const target = document.getElementById(targetId); + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }); +} + +document.querySelectorAll(".nav-item, .nav-subitem").forEach((button) => { + button.addEventListener("click", () => { + goTo(button.dataset.view, button.dataset.target); + }); +}); + +document.querySelectorAll("[data-jump]").forEach((button) => { + button.addEventListener("click", () => { + goTo(button.dataset.jump, button.dataset.target); + }); +}); + +syncSubnavWithScroll(); + +const promptInput = document.getElementById("prompt-input"); +const toneSelect = document.getElementById("tone-select"); +const styleSelect = document.getElementById("style-select"); +const channelSelect = document.getElementById("channel-select"); +const audienceSelect = document.getElementById("audience-select"); +const generateButton = document.getElementById("generate-button"); +const savePromptButton = document.getElementById("save-prompt-button"); +const cancelDraftButton = document.getElementById("cancel-draft-button"); +const regenerateButton = document.getElementById("regenerate-button"); +const saveDraftButton = document.getElementById("save-draft-button"); +const exportButton = document.getElementById("export-button"); +const exportFormatSelect = document.getElementById("export-format-select"); +const sendButton = document.getElementById("send-button"); +const recipientCategorySelect = document.getElementById("recipient-category-select"); +const recipientEmailInput = document.getElementById("recipient-email-input"); +const generatedTitleInput = document.getElementById("generated-title-input"); +const generatedBodyInput = document.getElementById("generated-body-input"); +const coverPreview = document.getElementById("cover-preview"); +const coverLabel = document.getElementById("cover-label"); +const coverUploadButton = document.getElementById("cover-upload-button"); +const coverFileInput = document.getElementById("cover-file-input"); +const assistantStatus = document.getElementById("assistant-status"); +const assistantComposeNote = document.getElementById("assistant-compose-note"); +const promptHistory = document.getElementById("prompt-history"); +const promptSearch = document.getElementById("prompt-search"); +const promptFilter = document.getElementById("prompt-filter"); +const promptEmpty = document.getElementById("prompt-empty"); +const metaChars = document.getElementById("meta-chars"); +const metaTime = document.getElementById("meta-time"); +const analyticsExportButton = document.getElementById("analytics-export-button"); +const analyticsStatus = document.getElementById("analytics-status"); +const assistantMetricPeriod = document.getElementById("assistant-metric-period"); +const assistantMetricChannel = document.getElementById("assistant-metric-channel"); +const assistantMetricList = document.getElementById("assistant-metric-list"); +const assistantUsageBreakdown = document.getElementById("assistant-usage-breakdown"); +const assistantRatingBreakdown = document.getElementById("assistant-rating-breakdown"); +const assistantFeedbackList = document.getElementById("assistant-feedback-list"); + +[ + "assistant-review", + "assistant-feedback" +].forEach((stepId) => setStepLocked(stepId, true)); + +function topicFromPrompt(prompt) { + const normalized = prompt.toLowerCase(); + + if (normalized.includes("cedolin")) { + return { + title: "Cedolini disponibili nell'area documentale", + subject: "i cedolini del mese", + action: "accedere all'area documentale e consultare il cedolino nella sezione dedicata", + benefit: "trovare il documento senza passaggi manuali o richieste al team HR" + }; + } + + if (normalized.includes("ferie") || normalized.includes("permess")) { + return { + title: "Aggiornamento procedura ferie e permessi", + subject: "la procedura di richiesta ferie e permessi", + action: "inserire le nuove richieste dal percorso aggiornato nel portale", + benefit: "ridurre errori e tempi di approvazione" + }; + } + + if (normalized.includes("benefit")) { + return { + title: "Aggiornamento benefit aziendali", + subject: "le informazioni sui benefit aziendali", + action: "consultare la scheda aggiornata nell'area comunicazioni", + benefit: "avere indicazioni più chiare su servizi, scadenze e modalità di accesso" + }; + } + + return { + title: "Nuova area documentale disponibile su NEXUM", + subject: "la nuova area documentale NEXUM", + action: "entrare in NEXUM e aprire la sezione Storico documenti", + benefit: "consultare comunicazioni, cedolini e materiali condivisi in modo più semplice" + }; +} + +function buildTitle(prompt, channel) { + const topic = topicFromPrompt(prompt); + + if (channel === "News portale") { + return topic.title.replace(" disponibile", ""); + } + + if (channel === "Notifica rapida") { + return topic.title + .replace("Nuova area documentale disponibile su NEXUM", "Area documentale aggiornata") + .replace("Aggiornamento ", ""); + } + + return topic.title; +} + +function buildBody(prompt, tone, style, channel, audience) { + const topic = topicFromPrompt(prompt); + const audienceLabel = audience.toLowerCase(); + const opening = + tone === "Più istituzionale" + ? `Gentili colleghi, vi informiamo che ${topic.subject} è ora disponibile.` + : tone === "Più sintetico" + ? `${topic.title}.` + : `Ciao, ${topic.subject} è ora disponibile.`; + + const benefitLine = + tone === "Più istituzionale" + ? `L'aggiornamento consente a ${audienceLabel} di ${topic.benefit}, mantenendo un accesso ordinato e tracciabile.` + : `La novità permette a ${audienceLabel} di ${topic.benefit}.`; + + const actionLine = + style === "Avviso operativo" + ? `Azione richiesta: ${topic.action}. In caso di dati non corretti, segnala l'anomalia al referente HR.` + : style === "Aggiornamento breve" + ? `Per procedere, ${topic.action}.` + : `Puoi ${topic.action}; le informazioni restano raccolte nello stesso spazio e sono disponibili quando servono.`; + + if (channel === "Notifica rapida") { + return `${opening} ${actionLine}`; + } + + if (channel === "News portale") { + return `${opening}\n\n${benefitLine}\n\n${actionLine}`; + } + + const closing = + tone === "Più istituzionale" + ? "Grazie per la collaborazione." + : "Grazie e buona consultazione."; + + return `${opening}\n\n${benefitLine}\n\n${actionLine}\n\n${closing}`; +} + +function updateMeta() { + if (!generatedBodyInput) { + return; + } + + const chars = generatedBodyInput.value.length; + setText(metaChars, `${chars} caratteri`); + setText(metaTime, `${Math.max(1, Math.round(chars / 500))} min lettura`); +} + +let coverObjectUrl = ""; + +function resetCover(label = "Cover generata per il canale scelto") { + if (coverObjectUrl) { + URL.revokeObjectURL(coverObjectUrl); + coverObjectUrl = ""; + } + if (coverPreview) { + coverPreview.style.backgroundImage = ""; + coverPreview.classList.remove("has-cover"); + } + setText(coverLabel, label); + if (coverFileInput) { + coverFileInput.value = ""; + } +} + +function tagsFor(channel, tone, favorite = false) { + const tags = []; + if (favorite) { + tags.push("preferito"); + } + if (channel === "Email interna") { + tags.push("email"); + } + if (channel === "News portale") { + tags.push("portale"); + } + if (tone === "Più sintetico") { + tags.push("sintetico"); + } + if (tone === "Più istituzionale") { + tags.push("istituzionale"); + } + if (tone === "Chiaro e diretto") { + tags.push("chiaro"); + } + return tags.join(" "); +} + +function setFavoriteButtonState(button, favorite) { + button.classList.toggle("active", favorite); + button.setAttribute("aria-pressed", String(favorite)); + button.setAttribute("aria-label", favorite ? "Rimuovi dai preferiti" : "Aggiungi ai preferiti"); +} + +function appendHistoryItem(title, prompt, channel, tone, favorite = false) { + if (!promptHistory) { + return; + } + + const item = document.createElement("li"); + item.className = "history-item"; + item.dataset.tags = tagsFor(channel, tone, favorite); + + const main = document.createElement("div"); + main.className = "history-main"; + + const strong = document.createElement("strong"); + strong.textContent = title; + + const meta = document.createElement("span"); + meta.textContent = `${channel} · ${tone} · adesso`; + + const action = document.createElement("button"); + action.className = "text-button"; + action.type = "button"; + action.dataset.reusePrompt = prompt; + action.textContent = "Riusa"; + + const favoriteButton = document.createElement("button"); + favoriteButton.className = "favorite-button"; + favoriteButton.type = "button"; + favoriteButton.dataset.favorite = ""; + favoriteButton.textContent = "★"; + setFavoriteButtonState(favoriteButton, favorite); + + const actions = document.createElement("div"); + actions.className = "history-actions"; + actions.append(favoriteButton, action); + + main.append(strong, meta); + item.append(main, actions); + promptHistory.prepend(item); + applyPromptFilters(); +} + +function validatePrompt() { + const prompt = promptInput?.value.trim() || ""; + if (prompt.length < 12) { + setText(assistantComposeNote, "Aggiungi qualche dettaglio al prompt prima di generare."); + promptInput?.focus(); + return false; + } + return true; +} + +async function generateDraft() { + if (!validatePrompt()) { + return; + } + + setText(assistantComposeNote, "Generazione in corso."); + if (generateButton) { + generateButton.disabled = true; + } + + try { + const result = await postJson(apiRoutes.communications, { + prompt: promptInput.value.trim(), + audience: audienceSelect?.value || "Tutti i dipendenti", + tone: toneSelect?.value || "Chiaro e diretto", + style: styleSelect?.value || "Testo informativo", + channel: channelSelect?.value || "Email interna" + }); + + setValue(generatedTitleInput, result.communication?.title || ""); + setValue(generatedBodyInput, result.communication?.body || ""); + updateMeta(); + resetCover("Cover non generata nella PoC"); + setText(assistantStatus, result.communication?.status || "Bozza pronta"); + setText(assistantComposeNote, result.message || "Bozza generata e pronta per la revisione."); + + if (recipientCategorySelect && audienceSelect) { + recipientCategorySelect.value = audienceSelect.value; + } + + if (result.state) { + applyAppState(result.state); + } + + unlockSteps(["assistant-review", "assistant-feedback"]); + resetRatingState(); + goTo("assistant", "assistant-review"); + } catch (error) { + setText(assistantComposeNote, humanApiError(error)); + } finally { + if (generateButton) { + generateButton.disabled = false; + } + } +} + +generateButton?.addEventListener("click", () => { + generateDraft(); +}); + +regenerateButton?.addEventListener("click", () => { + generateDraft(); +}); + +function resetDraft() { + setValue(generatedTitleInput, ""); + setValue(generatedBodyInput, ""); + setValue(recipientCategorySelect, ""); + setValue(recipientEmailInput, ""); + resetCover(); + setText(assistantStatus, "Bozza annullata"); + setText(assistantComposeNote, "Bozza annullata. Puoi modificare il prompt e generarne una nuova."); + updateMeta(); + resetRatingState(); + setStepLocked("assistant-review", true); + setStepLocked("assistant-feedback", true); + goTo("assistant", "assistant-compose"); +} + +cancelDraftButton?.addEventListener("click", resetDraft); + +coverUploadButton?.addEventListener("click", () => { + coverFileInput?.click(); +}); + +coverFileInput?.addEventListener("change", () => { + const file = coverFileInput.files?.[0]; + if (!file) { + return; + } + + const supportedTypes = ["image/png", "image/jpeg", "image/webp"]; + if (!supportedTypes.includes(file.type)) { + setText(assistantStatus, "Formato immagine non supportato. Usa PNG, JPG o WebP."); + coverFileInput.value = ""; + return; + } + + if (file.size > 5 * 1024 * 1024) { + setText(assistantStatus, "Immagine troppo pesante. Scegli un file sotto 5 MB."); + coverFileInput.value = ""; + return; + } + + if (coverObjectUrl) { + URL.revokeObjectURL(coverObjectUrl); + } + coverObjectUrl = URL.createObjectURL(file); + if (coverPreview) { + coverPreview.style.backgroundImage = `linear-gradient(rgba(15, 23, 32, 0.18), rgba(15, 23, 32, 0.42)), url("${coverObjectUrl}")`; + coverPreview.classList.add("has-cover"); + } + setText(coverLabel, file.name); + setText(assistantStatus, "Cover sostituita."); +}); + +savePromptButton?.addEventListener("click", () => { + if (!validatePrompt()) { + return; + } + + setText(assistantComposeNote, "Salvataggio prompt dedicato predisposto; genera una bozza per registrare lo storico."); +}); + +saveDraftButton?.addEventListener("click", () => { + setText(assistantStatus, "Bozza salvata"); +}); + +exportButton?.addEventListener("click", () => { + const format = exportFormatSelect?.value || "PDF"; + setText(assistantStatus, `${format} pronto per il download`); +}); + +function parseExplicitRecipients(value) { + return value + .split(/[\s,;]+/) + .map((email) => email.trim()) + .filter(Boolean); +} + +function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +sendButton?.addEventListener("click", () => { + const category = recipientCategorySelect?.value || ""; + const explicitRecipients = parseExplicitRecipients(recipientEmailInput?.value || ""); + const invalidRecipients = explicitRecipients.filter((email) => !isValidEmail(email)); + + if (!category && explicitRecipients.length === 0) { + setText(assistantStatus, "Seleziona una categoria o indica almeno una persona."); + recipientCategorySelect?.focus(); + return; + } + + if (invalidRecipients.length > 0) { + setText(assistantStatus, `Controlla questi indirizzi: ${invalidRecipients.join(", ")}`); + recipientEmailInput?.focus(); + return; + } + + if (!ratingSubmitted) { + setText(assistantStatus, "Valuta la bozza prima dell'invio."); + setText(ratingNote, "Seleziona e invia una valutazione prima di procedere."); + ratingNote?.classList.remove("hidden"); + goTo("assistant", "assistant-feedback"); + return; + } + + const parts = []; + if (category) { + parts.push(category); + } + if (explicitRecipients.length > 0) { + parts.push(`${explicitRecipients.length} destinatari specifici`); + } + + setText(assistantStatus, `Invio registrato per: ${parts.join(" + ")}`); +}); + +generatedBodyInput?.addEventListener("input", updateMeta); +updateMeta(); + +function applyPromptFilters() { + if (!promptHistory) { + return; + } + + const query = (promptSearch?.value || "").trim().toLowerCase(); + const filter = promptFilter?.value || ""; + let visibleCount = 0; + + promptHistory.querySelectorAll(".history-item").forEach((item) => { + const text = item.textContent.toLowerCase(); + const tags = item.dataset.tags || ""; + const matchesQuery = !query || text.includes(query); + const matchesFilter = !filter || tags.includes(filter); + const visible = matchesQuery && matchesFilter; + item.classList.toggle("hidden", !visible); + if (visible) { + visibleCount += 1; + } + }); + + promptEmpty?.classList.toggle("hidden", visibleCount > 0); +} + +promptSearch?.addEventListener("input", applyPromptFilters); +promptFilter?.addEventListener("change", applyPromptFilters); + +promptHistory?.addEventListener("click", (event) => { + const favoriteButton = event.target.closest("[data-favorite]"); + if (favoriteButton) { + const item = favoriteButton.closest(".history-item"); + const tags = new Set((item?.dataset.tags || "").split(" ").filter(Boolean)); + const favorite = favoriteButton.getAttribute("aria-pressed") !== "true"; + + if (favorite) { + tags.add("preferito"); + } else { + tags.delete("preferito"); + } + + if (item) { + item.dataset.tags = Array.from(tags).join(" "); + } + setFavoriteButtonState(favoriteButton, favorite); + applyPromptFilters(); + return; + } + + const button = event.target.closest("[data-reuse-prompt]"); + if (!button) { + return; + } + + if (promptInput) { + promptInput.value = button.dataset.reusePrompt; + } + setText(assistantComposeNote, "Prompt caricato. Puoi modificarlo o generare una nuova bozza."); + goTo("assistant", "assistant-compose"); +}); + +let selectedRating = 0; +let ratingSubmitted = false; +const ratingButtons = document.querySelectorAll("[data-rating]"); +const ratingSubmitButton = document.getElementById("rating-submit-button"); +const ratingNote = document.getElementById("rating-note"); +const ratingComment = document.getElementById("rating-comment"); + +function updateRating(value) { + selectedRating = value; + const toneClass = + selectedRating <= 2 + ? "rating-low" + : selectedRating === 3 + ? "rating-mid" + : "rating-high"; + + ratingButtons.forEach((button) => { + const rating = Number(button.dataset.rating); + const active = rating <= selectedRating; + button.classList.toggle("active", active); + button.classList.toggle("rating-low", active && toneClass === "rating-low"); + button.classList.toggle("rating-mid", active && toneClass === "rating-mid"); + button.classList.toggle("rating-high", active && toneClass === "rating-high"); + button.setAttribute("aria-pressed", String(active)); + }); + ratingNote?.classList.add("hidden"); +} + +ratingButtons.forEach((button) => { + button.addEventListener("click", () => { + updateRating(Number(button.dataset.rating)); + }); +}); + +function resetRatingState() { + selectedRating = 0; + ratingSubmitted = false; + updateRating(0); + ratingButtons.forEach((button) => { + button.disabled = false; + }); + if (ratingComment) { + ratingComment.disabled = false; + } + if (ratingSubmitButton) { + ratingSubmitButton.disabled = false; + } + ratingNote?.classList.add("hidden"); +} + +updateRating(selectedRating); + +ratingSubmitButton?.addEventListener("click", () => { + if (selectedRating === 0) { + setText(ratingNote, "Seleziona una valutazione prima di inviare."); + ratingNote?.classList.remove("hidden"); + return; + } + + setText(ratingNote, "Grazie, feedback registrato."); + ratingNote?.classList.remove("hidden"); + ratingSubmitted = true; + ratingButtons.forEach((button) => { + button.disabled = true; + }); + if (ratingComment) { + ratingComment.disabled = true; + } + ratingSubmitButton.disabled = true; +}); + +function renderPromptHistory(items = []) { + if (!promptHistory) { + return; + } + + promptHistory.replaceChildren(); + items.forEach((item) => { + const row = document.createElement("li"); + row.className = "history-item"; + row.dataset.tags = `${item.style || ""} ${item.tone || ""}`.toLowerCase(); + + const main = document.createElement("div"); + main.className = "history-main"; + + const title = document.createElement("strong"); + title.textContent = item.title || "Bozza senza titolo"; + + const meta = document.createElement("span"); + meta.textContent = `${item.style || "Stile non disponibile"} · ${item.tone || "Tono non disponibile"} · ${item.createdAt || "Data non disponibile"}`; + + const actions = document.createElement("div"); + actions.className = "history-actions"; + + const reuse = document.createElement("button"); + reuse.className = "text-button"; + reuse.type = "button"; + reuse.dataset.reusePrompt = item.prompt || ""; + reuse.textContent = "Riusa"; + + actions.append(reuse); + main.append(title, meta); + row.append(main, actions); + promptHistory.append(row); + }); + + promptEmpty?.classList.toggle("hidden", items.length > 0); +} + +function applyAssistantState(state) { + const metrics = state?.metrics || []; + const history = state?.history || []; + + renderMetricList(assistantMetricList, metrics); + renderMetricList(assistantUsageBreakdown, [ + [String(history.length), "Bozze presenti nello storico"] + ]); + renderMetricList(assistantRatingBreakdown, [ + ["n/d", "Feedback non disponibili"] + ]); + renderMetricList(assistantFeedbackList, [ + ["n/d", "Nessun feedback registrato"] + ]); + renderPromptHistory(history); + applyPromptFilters(); +} + +analyticsExportButton?.addEventListener("click", () => { + const period = assistantMetricPeriod?.value || "Ultimi 30 giorni"; + const channel = assistantMetricChannel?.value || "Tutti"; + downloadTextReport( + "report-metriche-assistant.txt", + [ + "Report metriche AI Assistant", + `Periodo: ${period}`, + `Canale: ${channel}`, + "", + metricRowsText(assistantMetricList), + "", + "Utilizzo operativo", + metricRowsText(assistantUsageBreakdown), + "", + "Qualita percepita", + metricRowsText(assistantRatingBreakdown), + "", + "Feedback recenti", + metricRowsText(assistantFeedbackList) + ].join("\n") + ); + setText(analyticsStatus, `Report Assistant scaricato (${period}, canale: ${channel}).`); + analyticsStatus?.classList.remove("hidden"); +}); + +function updateAssistantAnalytics() { + if (appState?.assistant) { + applyAssistantState(appState.assistant); + return; + } + + renderMetricList(assistantMetricList, [ + ["0", "Contenuti generati"], + ["0", "Bozze da rivedere"], + ["0", "Feedback raccolti"], + ["n/d", "Rating medio"] + ]); + renderMetricList(assistantUsageBreakdown, [["0", "Contenuti disponibili"]]); + renderMetricList(assistantRatingBreakdown, [["n/d", "Feedback non disponibili"]]); + renderMetricList(assistantFeedbackList, [["n/d", "Nessun feedback registrato"]]); + return; + + const period = assistantMetricPeriod?.value || "Ultimi 30 giorni"; + const channel = assistantMetricChannel?.value || "Tutti"; + const shortPeriod = period === "Ultimi 7 giorni"; + const currentMonth = period === "Mese corrente"; + const baseGenerated = shortPeriod ? 14 : currentMonth ? 31 : 38; + const baseSaved = shortPeriod ? 5 : currentMonth ? 10 : 13; + const channelShare = { + "Email interna": 0.48, + "News portale": 0.32, + "Notifica rapida": 0.2 + }; + const channelFactor = channel === "Tutti" ? 1 : channelShare[channel] || 1; + const generated = Math.max(1, Math.round(baseGenerated * channelFactor)); + const saved = Math.max(1, Math.round(baseSaved * channelFactor)); + const rating = channel === "Notifica rapida" ? "4,4" : channel === "News portale" ? "4,7" : "4,6"; + const feedbackTotal = Math.max(2, Math.round((shortPeriod ? 9 : currentMonth ? 24 : 31) * channelFactor)); + const dailyAverage = Math.max(1, Math.round(generated / (shortPeriod ? 7 : 30))); + + renderMetricList(assistantMetricList, [ + [String(generated), "Contenuti generati"], + [String(saved), "Prompt salvati"], + [rating, "Rating medio"], + [String(feedbackTotal), "Feedback raccolti"] + ]); + + const channelRows = channel === "Tutti" + ? Object.entries(channelShare).map(([name, share]) => [name, `${Math.max(1, Math.round(baseGenerated * share))} contenuti`]) + : [ + [channel, `${generated} contenuti`], + ["Prompt salvati", `${saved} configurazioni`], + ["Media giornaliera", `${dailyAverage} contenuti/giorno`] + ]; + + renderMetricList(assistantUsageBreakdown, channelRows); + + renderMetricList(assistantRatingBreakdown, [ + ["5 stelle", `${Math.max(1, Math.round(feedbackTotal * 0.58))} feedback`], + ["4 stelle", `${Math.max(1, Math.round(feedbackTotal * 0.34))} feedback`], + ["3 stelle o meno", `${Math.max(0, feedbackTotal - Math.round(feedbackTotal * 0.58) - Math.round(feedbackTotal * 0.34))} feedback`] + ]); + + renderMetricList(assistantFeedbackList, [ + ["5/5", channel === "Tutti" ? "Testo pronto senza correzioni rilevanti" : `Output efficace per ${channel.toLowerCase()}`], + ["4/5", shortPeriod ? "Richieste più sintetiche nei prompt recenti" : "Buona struttura, apertura da rendere più sintetica"] + ]); +} + +assistantMetricPeriod?.addEventListener("change", updateAssistantAnalytics); +assistantMetricChannel?.addEventListener("change", updateAssistantAnalytics); +updateAssistantAnalytics(); + +const uploadBox = document.getElementById("upload-box"); +const documentFileInput = document.getElementById("document-file-input"); +const uploadState = document.getElementById("upload-state"); +const uploadOutput = document.getElementById("upload-output"); +const copilotMetricPeriod = document.getElementById("copilot-metric-period"); +const copilotMetricStatus = document.getElementById("copilot-metric-status"); +const copilotMetricList = document.getElementById("copilot-metric-list"); +const copilotDocumentBreakdown = document.getElementById("copilot-document-breakdown"); +const copilotQualityBreakdown = document.getElementById("copilot-quality-breakdown"); +const copilotExportButton = document.getElementById("copilot-export-button"); +const copilotExportStatus = document.getElementById("copilot-export-status"); +const documentSearch = document.getElementById("document-search"); +const documentDeliveryFilter = document.getElementById("document-delivery-filter"); +const documentConfidenceMode = document.getElementById("document-confidence-mode"); +const documentConfidenceThreshold = document.getElementById("document-confidence-threshold"); +const documentMonthFilter = document.getElementById("document-month-filter"); +const documentYearFilter = document.getElementById("document-year-filter"); +const documentPageSizeSelect = document.getElementById("document-page-size-select"); +const documentResetFilters = document.getElementById("document-reset-filters"); +const documentFilterStatus = document.getElementById("document-filter-status"); +const documentHistory = document.getElementById("document-history"); +const documentEmpty = document.getElementById("document-empty"); +const documentSummaryList = document.getElementById("document-summary-list"); +const documentPageInfo = document.getElementById("document-page-info"); +const documentPrevButton = document.getElementById("document-prev-button"); +const documentNextButton = document.getElementById("document-next-button"); +const copilotDetailSection = document.getElementById("copilot-detail"); +const detailTitle = document.getElementById("detail-title"); +const detailPreviewTitle = document.getElementById("detail-preview-title"); +const detailPreviewMeta = document.getElementById("detail-preview-meta"); +const detailPreviewLines = document.getElementById("detail-preview-lines"); +const detailEmployeeInput = document.getElementById("detail-employee-input"); +const detailCompanyInput = document.getElementById("detail-company-input"); +const detailFileInput = document.getElementById("detail-file-input"); +const detailDateInput = document.getElementById("detail-date-input"); +const detailPagesInput = document.getElementById("detail-pages-input"); +const detailTypeInput = document.getElementById("detail-type-input"); +const detailDescriptionInput = document.getElementById("detail-description-input"); +const detailConfidenceInput = document.getElementById("detail-confidence-input"); +const detailDeliveryStatusInput = document.getElementById("detail-delivery-status-input"); +const detailEditButton = document.getElementById("detail-edit-button"); +const detailSaveButton = document.getElementById("detail-save-button"); +const detailCancelButton = document.getElementById("detail-cancel-button"); +const detailSendButton = document.getElementById("detail-send-button"); +const detailStatusMessage = document.getElementById("detail-status-message"); +const detailSendDraft = document.getElementById("detail-send-draft"); +const sendRecipientInput = document.getElementById("send-recipient-input"); +const sendSubjectInput = document.getElementById("send-subject-input"); +const sendBodyInput = document.getElementById("send-body-input"); +const sendConfirmButton = document.getElementById("send-confirm-button"); +const sendCancelButton = document.getElementById("send-cancel-button"); +const sendStatusMessage = document.getElementById("send-status-message"); +let currentAnalysisDocumentId = "cedolino-giulia-conti"; +let activeDocumentId = currentAnalysisDocumentId; +let detailPreviewMode = "original"; +let currentDocumentPage = 1; +let documentPageSize = 6; +const confidenceReviewThreshold = 80; + +const documentDetails = { + "cedolino-marco-rinaldi": { + title: "Cedolino mensile - Marco Rinaldi", + batchId: "cedolini-aprile-2026", + sourceTitle: "Lotto cedolini aprile 2026", + employee: "Marco Rinaldi", + company: "Eggon S.r.l.", + file: "cedolini-aprile-2026.pdf", + date: "30/04/2026", + pages: "1", + type: "Cedolino mensile", + description: "Cedolino mensile estratto dal lotto di aprile.", + confidence: "98%", + deliveryStatus: "Non inviato", + previewLines: [ + "Pagina 1: Marco Rinaldi - cedolino mensile", + "Pagina 2: Elena Ferri - cedolino mensile", + "Pagina 3: Giulia Conti - confidenza destinatario 78%" + ], + recipients: [ + { name: "Marco Rinaldi", page: "pagina 1", confidence: 98, status: "confirmed", documentId: "cedolino-marco-rinaldi" } + ] + }, + "cedolino-elena-ferri": { + title: "Cedolino mensile - Elena Ferri", + batchId: "cedolini-aprile-2026", + sourceTitle: "Lotto cedolini aprile 2026", + employee: "Elena Ferri", + company: "Eggon S.r.l.", + file: "cedolini-aprile-2026.pdf", + date: "30/04/2026", + pages: "1", + type: "Cedolino mensile", + description: "Cedolino mensile estratto dal lotto di aprile.", + confidence: "95%", + deliveryStatus: "Non inviato", + previewLines: [ + "Pagina 1: Marco Rinaldi - cedolino mensile", + "Pagina 2: Elena Ferri - cedolino mensile", + "Pagina 3: Giulia Conti - confidenza destinatario 78%" + ], + recipients: [ + { name: "Elena Ferri", page: "pagina 2", confidence: 95, status: "confirmed", documentId: "cedolino-elena-ferri" } + ] + }, + "cedolino-giulia-conti": { + title: "Cedolino mensile - Giulia Conti", + batchId: "cedolini-aprile-2026", + sourceTitle: "Lotto cedolini aprile 2026", + employee: "Giulia Conti", + company: "Eggon S.r.l.", + file: "cedolini-aprile-2026.pdf", + date: "30/04/2026", + pages: "1", + type: "Cedolino mensile", + description: "Cedolino mensile estratto dal lotto di aprile.", + confidence: "78%", + deliveryStatus: "Non inviato", + previewLines: [ + "Pagina 1: Marco Rinaldi - cedolino mensile", + "Pagina 2: Elena Ferri - cedolino mensile", + "Pagina 3: Giulia Conti - confidenza destinatario 78%" + ], + recipients: [ + { name: "Giulia Conti", page: "pagina 3", confidence: 78, status: "needs-review", documentId: "cedolino-giulia-conti" } + ] + }, + "contratto-onboarding": { + title: "Contratto onboarding - Luca Bianchi", + batchId: "contratto-onboarding", + employee: "Luca Bianchi", + company: "Nexum Labs", + file: "onboarding-luca-bianchi.pdf", + date: "12/04/2026", + pages: "8", + type: "Contratto", + description: "Contratto di assunzione con allegati amministrativi.", + confidence: "96%", + deliveryStatus: "Non inviato", + previewLines: [ + "Pagina 1: dati anagrafici e azienda", + "Pagine 2-6: clausole contrattuali", + "Pagine 7-8: allegati amministrativi" + ], + recipients: [ + { name: "Luca Bianchi", page: "documento completo", confidence: 96, status: "confirmed", documentId: "contratto-onboarding" } + ] + }, + "comunicazione-benefit": { + title: "Comunicazione benefit - Dipendenti sede centrale", + batchId: "comunicazione-benefit", + employee: "Dipendenti sede centrale", + company: "Eggon S.r.l.", + file: "benefit-marzo-2026.pdf", + date: "25/03/2026", + pages: "2", + type: "Comunicazione HR", + description: "Comunicazione interna sui benefit aziendali.", + confidence: "98%", + deliveryStatus: "Inviato", + previewLines: [ + "Pagina 1: riepilogo benefit", + "Pagina 2: istruzioni di accesso al portale" + ], + recipients: [ + { name: "Dipendenti sede centrale", page: "documento completo", confidence: 98, status: "sent", documentId: "comunicazione-benefit" } + ] + }, + "cedolino-sara-martini": { + title: "Cedolino mensile - Sara Martini", + batchId: "cedolini-aprile-2026-b", + employee: "Sara Martini", + company: "Eggon S.r.l.", + file: "cedolini-aprile-2026-b.pdf", + date: "30/04/2026", + pages: "1", + type: "Cedolino mensile", + description: "Cedolino mensile estratto dal secondo lotto di aprile.", + confidence: "91%", + deliveryStatus: "Non inviato", + previewLines: [ + "Pagina 1: Sara Martini - cedolino mensile", + "Pagina 2: Paolo Greco - cedolino mensile", + "Pagina 3: Nadia Costa - confidenza destinatario 76%" + ], + recipients: [ + { name: "Sara Martini", page: "pagina 1", confidence: 91, status: "confirmed", documentId: "cedolino-sara-martini" } + ] + }, + "cedolino-paolo-greco": { + title: "Cedolino mensile - Paolo Greco", + batchId: "cedolini-aprile-2026-b", + employee: "Paolo Greco", + company: "Eggon S.r.l.", + file: "cedolini-aprile-2026-b.pdf", + date: "30/04/2026", + pages: "1", + type: "Cedolino mensile", + description: "Cedolino mensile estratto dal secondo lotto di aprile.", + confidence: "93%", + deliveryStatus: "Inviato", + previewLines: [ + "Pagina 1: Sara Martini - cedolino mensile", + "Pagina 2: Paolo Greco - cedolino mensile", + "Pagina 3: Nadia Costa - confidenza destinatario 76%" + ], + recipients: [ + { name: "Paolo Greco", page: "pagina 2", confidence: 93, status: "sent", documentId: "cedolino-paolo-greco" } + ] + }, + "cedolino-nadia-costa": { + title: "Cedolino mensile - Nadia Costa", + batchId: "cedolini-aprile-2026-b", + employee: "Nadia Costa", + company: "Eggon S.r.l.", + file: "cedolini-aprile-2026-b.pdf", + date: "30/04/2026", + pages: "1", + type: "Cedolino mensile", + description: "Cedolino mensile estratto dal secondo lotto di aprile.", + confidence: "76%", + deliveryStatus: "Non inviato", + previewLines: [ + "Pagina 1: Sara Martini - cedolino mensile", + "Pagina 2: Paolo Greco - cedolino mensile", + "Pagina 3: Nadia Costa - confidenza destinatario 76%" + ], + recipients: [ + { name: "Nadia Costa", page: "pagina 3", confidence: 76, status: "needs-review", documentId: "cedolino-nadia-costa" } + ] + }, + "contratto-maria-neri": { + title: "Contratto onboarding - Maria Neri", + batchId: "contratto-maria-neri", + employee: "Maria Neri", + company: "Nexum Labs", + file: "onboarding-maria-neri.pdf", + date: "18/04/2026", + pages: "7", + type: "Contratto", + description: "Contratto di assunzione con allegati amministrativi.", + confidence: "94%", + deliveryStatus: "Non inviato", + previewLines: [ + "Pagina 1: dati anagrafici e azienda", + "Pagine 2-6: condizioni contrattuali", + "Pagina 7: allegato amministrativo" + ], + recipients: [ + { name: "Maria Neri", page: "documento completo", confidence: 94, status: "confirmed", documentId: "contratto-maria-neri" } + ] + }, + "comunicazione-welfare": { + title: "Comunicazione welfare - Team HR", + batchId: "comunicazione-welfare", + employee: "Team HR", + company: "Eggon S.r.l.", + file: "welfare-aprile-2026.pdf", + date: "05/04/2026", + pages: "2", + type: "Comunicazione HR", + description: "Comunicazione interna sul piano welfare aggiornato.", + confidence: "97%", + deliveryStatus: "Inviato", + previewLines: [ + "Pagina 1: riepilogo piano welfare", + "Pagina 2: modalita di accesso" + ], + recipients: [ + { name: "Team HR", page: "documento completo", confidence: 97, status: "sent", documentId: "comunicazione-welfare" } + ] + } +}; + +Object.keys(documentDetails).forEach((key) => { + delete documentDetails[key]; +}); + +const copilotRun = { + processed: false, + recipients: [] +}; + +let selectedRecipientId = "giulia-conti"; + +let currentDocumentState = { + label: "Da verificare", + statusClass: "", + itemClass: "needs-review", + stateTags: "verifica bassa-confidenza" +}; + +function isDocumentSent(detail) { + return detail?.deliveryStatus === "Inviato" || (detail?.recipients || []).some((recipient) => recipient.status === "sent"); +} + +function displayDeliveryStatus(detail) { + return isDocumentSent(detail) ? "Inviato" : "Non inviato"; +} + +function confidenceNumber(detail) { + const parsed = Number.parseInt(String(detail?.confidence || "").replace("%", ""), 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function dateParts(detail) { + const match = String(detail?.date || "").match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + return { + month: match?.[2] || "", + year: match?.[3] || "" + }; +} + +function createRecipient(name, confidence, page, status, note, documentId) { + return { + id: name.toLowerCase().replace(/\s+/g, "-"), + documentId, + name, + confidence, + page, + status, + note + }; +} + +function getBatchDocumentIds(documentId) { + const batchId = documentDetails[documentId]?.batchId || documentId; + return Object.keys(documentDetails).filter((id) => (documentDetails[id].batchId || id) === batchId); +} + +function recipientFromDetail(documentId) { + const detail = documentDetails[documentId]; + const sourceRecipient = detail?.recipients?.[0]; + + return createRecipient( + detail?.employee || "Non disponibile", + sourceRecipient?.confidence || confidenceNumber(detail), + sourceRecipient?.page || "documento completo", + sourceRecipient?.status || (isDocumentSent(detail) ? "sent" : "confirmed"), + sourceRecipient?.status === "needs-review" + ? "Richiede conferma operatore" + : sourceRecipient?.status === "sent" || isDocumentSent(detail) + ? "Invio già tracciato" + : "Confermato da OCR", + documentId + ); +} + +function documentStateFromDetail(detail) { + const recipients = detail.recipients || []; + const hasReview = recipients.some((recipient) => recipient.status === "needs-review"); + const hasSent = isDocumentSent(detail); + + if (hasSent) { + return { + label: "Inviato", + statusClass: "sent", + itemClass: "", + stateTags: `inviato ${detail.type} ${detail.company} ${detail.employee}` + }; + } + + if (hasReview) { + return { + label: "Da verificare", + statusClass: "", + itemClass: "needs-review", + stateTags: `verifica bassa-confidenza ${detail.type} ${detail.company} ${detail.employee}` + }; + } + + return { + label: "Confermato", + statusClass: "confirmed", + itemClass: "confirmed", + stateTags: `confermato ${detail.type} ${detail.company} ${detail.employee}` + }; +} + +function resetCopilotRecipients() { + copilotRun.recipients = [ + createRecipient("Marco Rinaldi", 98, "pagina 1", "confirmed", "Destinatario rilevato nello stesso lotto", "cedolino-marco-rinaldi"), + createRecipient("Elena Ferri", 95, "pagina 2", "confirmed", "Destinatario rilevato nello stesso lotto", "cedolino-elena-ferri"), + createRecipient("Giulia Conti", 78, "pagina 3", "needs-review", "Richiede conferma operatore", "cedolino-giulia-conti") + ]; + selectedRecipientId = "giulia-conti"; + currentAnalysisDocumentId = "cedolino-giulia-conti"; +} + +function syncRecipientsIntoDocument() { + if (copilotRun.recipients.length === 0) { + return; + } + + const previewLines = copilotRun.recipients.map((recipient) => ( + `${recipient.page}: ${recipient.name} - confidenza ${recipient.confidence}%` + )); + + copilotRun.recipients.forEach((recipient) => { + const detail = documentDetails[recipient.documentId]; + if (!detail) { + return; + } + + detail.employee = recipient.name; + detail.confidence = `${recipient.confidence}%`; + detail.deliveryStatus = recipient.status === "sent" ? "Inviato" : "Non inviato"; + detail.previewLines = previewLines; + detail.recipients = [{ + name: recipient.name, + page: recipient.page, + confidence: recipient.confidence, + status: recipient.status, + documentId: recipient.documentId + }]; + }); +} + +function buildDocumentTags(documentId, state) { + const detail = documentDetails[documentId]; + return [ + state.stateTags, + detail.type, + detail.company, + detail.employee, + detail.file, + detail.date, + displayDeliveryStatus(detail).toLowerCase().replace(" ", "-"), + `confidenza-${confidenceNumber(detail)}` + ] + .join(" ") + .toLowerCase(); +} + +function updateDocumentCard(documentId, state = documentStateFromDetail(documentDetails[documentId])) { + const item = document.querySelector(`[data-document-id="${documentId}"]`); + const status = item?.querySelector("[data-document-status]"); + const summary = item?.querySelector("[data-document-summary]"); + const meta = item?.querySelector(".document-meta"); + const detail = documentDetails[documentId]; + if (!item || !status || !detail) { + return; + } + + item.dataset.tags = buildDocumentTags(documentId, state); + item.classList.toggle("needs-review", state.itemClass === "needs-review"); + item.classList.toggle("confirmed", state.itemClass === "confirmed"); + item.classList.toggle("sent", state.statusClass === "sent"); + status.textContent = state.label; + status.className = state.statusClass + ? `document-status ${state.statusClass}` + : "document-status"; + + if (summary) { + summary.textContent = `${detail.employee} · ${detail.company} · confidenza ${detail.confidence || "Non disponibile"}`; + } + + if (meta) { + meta.replaceChildren(); + [ + `File: ${detail.file}`, + `Data: ${detail.date}`, + detail.pages === "1" ? "Pagina singola" : `Pagine: ${detail.pages}` + ].forEach((value) => { + const itemMeta = document.createElement("span"); + itemMeta.textContent = value; + meta.append(itemMeta); + }); + } +} + +function updateCurrentDocumentCard() { + updateDocumentCard(currentAnalysisDocumentId, currentDocumentState); +} + +function updateAllDocumentCards() { + applyDocumentFilters({ keepPage: true }); +} + +const detailEditableInputs = [ + detailEmployeeInput, + detailCompanyInput, + detailDateInput, + detailTypeInput, + detailDescriptionInput +]; + +const detailReadonlyInputs = [ + detailFileInput, + detailPagesInput, + detailConfidenceInput, + detailDeliveryStatusInput +]; + +function setDetailEditMode(editing) { + detailEditableInputs.forEach((input) => { + if (input) { + input.readOnly = !editing; + input.disabled = !editing; + input.tabIndex = editing ? 0 : -1; + } + }); + detailReadonlyInputs.forEach((input) => { + if (input) { + input.readOnly = true; + input.disabled = true; + input.tabIndex = -1; + } + }); + document.querySelector(".document-inspector")?.classList.toggle("is-editing", editing); + detailEditButton?.classList.toggle("hidden", editing); + detailSaveButton?.classList.toggle("hidden", !editing); + detailCancelButton?.classList.toggle("hidden", !editing); +} + +function setDetailValues(detail) { + const inspector = document.querySelector(".document-inspector"); + inspector?.classList.toggle("confidence-low", confidenceNumber(detail) < confidenceReviewThreshold); + inspector?.classList.toggle("confidence-high", confidenceNumber(detail) >= confidenceReviewThreshold); + + setText(detailTitle, detail.title); + setValue(detailEmployeeInput, detail.employee); + setValue(detailCompanyInput, detail.company); + setValue(detailFileInput, detail.file); + setValue(detailDateInput, detail.date); + setValue(detailPagesInput, detail.pages); + setValue(detailTypeInput, detail.type); + setValue(detailDescriptionInput, detail.description); + setValue(detailConfidenceInput, detail.confidence || "Non disponibile"); + setValue(detailDeliveryStatusInput, displayDeliveryStatus(detail)); +} + +function renderDetailPreview(detail) { + setText(detailPreviewTitle, detail.title); + const pageText = detail.pages === "1" ? "1 pagina" : `${detail.pages} pagine`; + setText(detailPreviewMeta, `${detail.file} · ${pageText}`); + + document.querySelectorAll("[data-detail-preview]").forEach((button) => { + button.classList.toggle("active", button.dataset.detailPreview === detailPreviewMode); + }); + + const recipients = detail.recipients || []; + const selectedRecipient = recipients.find((recipient) => recipient.name === detail.employee) || recipients[0]; + const splitLines = selectedRecipient + ? [`${selectedRecipient.page}: ${selectedRecipient.name} - ${detail.type}`] + : [detail.previewLines?.[0]].filter(Boolean); + const fullLines = detail.previewLines?.length ? detail.previewLines : splitLines; + const lines = detailPreviewMode === "split" + ? splitLines + : fullLines; + + if (!detailPreviewLines) { + return; + } + + detailPreviewLines.replaceChildren(); + detailPreviewLines.classList.toggle("pdf-preview-mode", detailPreviewMode === "original"); + + if (detailPreviewMode === "original") { + const frame = document.createElement("div"); + frame.className = "pdf-placeholder-frame"; + + const chrome = document.createElement("div"); + chrome.className = "pdf-placeholder-chrome"; + + const title = document.createElement("strong"); + title.textContent = "Anteprima PDF originale"; + + const hint = document.createElement("span"); + hint.textContent = "Viewer integrato previsto"; + + const body = document.createElement("div"); + body.className = "pdf-placeholder-body"; + body.textContent = `${detail.file} - apertura del documento originale direttamente nell'applicativo.`; + + chrome.append(title, hint); + frame.append(chrome, body); + detailPreviewLines.append(frame); + return; + } + + (lines.length ? lines : ["Anteprima non disponibile"]).forEach((line) => { + const item = document.createElement("span"); + item.textContent = line; + detailPreviewLines.append(item); + }); +} + +function updateDocumentDetail(documentId = activeDocumentId) { + const detail = documentDetails[documentId]; + if (!detail || !copilotDetailSection) { + return; + } + + activeDocumentId = documentId; + setDetailValues(detail); + renderDetailPreview(detail); + setDetailEditMode(false); + documentHistory?.querySelectorAll("[data-document-detail]").forEach((button) => { + button.classList.toggle("active", button.dataset.documentDetail === documentId); + }); +} + +function hideSendDraft() { + detailSendDraft?.classList.add("is-hidden"); +} + +function buildSendDraft(detail) { + setValue(sendRecipientInput, detail.employee); + setValue(sendSubjectInput, `${detail.type} disponibile`); + setValue( + sendBodyInput, + `Gentile ${detail.employee},\n\nil documento "${detail.type}" del ${detail.date} è disponibile in allegato.\n\nCordiali saluti.` + ); + setText(sendStatusMessage, "Il documento selezionato viene allegato automaticamente."); + detailSendDraft?.classList.remove("is-hidden"); +} + +function showDocumentDetail(documentId, { openSend = false } = {}) { + copilotDetailSection?.classList.remove("is-hidden"); + detailPreviewMode = "original"; + updateDocumentDetail(documentId); + + if (openSend) { + buildSendDraft(documentDetails[documentId]); + } else { + hideSendDraft(); + } + + setText(detailStatusMessage, "Seleziona Modifica per correggere i campi consentiti dall'ADR."); + goTo("copilot", "copilot-detail"); +} + +function saveDetailChanges() { + const detail = documentDetails[activeDocumentId]; + if (!detail) { + return; + } + + detail.employee = detailEmployeeInput?.value.trim() || "Non disponibile"; + detail.company = detailCompanyInput?.value.trim() || "Non disponibile"; + detail.date = detailDateInput?.value.trim() || "Non disponibile"; + detail.type = detailTypeInput?.value.trim() || "Non disponibile"; + detail.description = detailDescriptionInput?.value.trim() || "Non disponibile"; + + const recipient = copilotRun.recipients.find((item) => item.documentId === activeDocumentId); + if (recipient) { + recipient.name = detail.employee; + recipient.status = recipient.status === "sent" ? "sent" : "confirmed"; + recipient.note = "Dati confermati dall'operatore"; + selectedRecipientId = recipient.id; + currentAnalysisDocumentId = activeDocumentId; + } + + detail.recipients = [{ + ...(detail.recipients?.[0] || {}), + name: detail.employee, + status: recipient?.status || detail.recipients?.[0]?.status || "confirmed", + documentId: activeDocumentId + }]; + detail.deliveryStatus = detail.recipients[0].status === "sent" ? "Inviato" : "Non inviato"; + + syncRecipientsIntoDocument(); + updateAllDocumentCards(); + updateDocumentDetail(activeDocumentId); + setText(detailStatusMessage, "Modifiche salvate sui campi editabili. L'invio selezionato è pronto."); +} + +function confirmDetailSend() { + const detail = documentDetails[activeDocumentId]; + const recipient = sendRecipientInput?.value.trim() || ""; + const subject = sendSubjectInput?.value.trim() || ""; + const body = sendBodyInput?.value.trim() || ""; + + if (!detail || !recipient || !subject || !body) { + setText(sendStatusMessage, "Destinatario, oggetto e testo sono obbligatori prima dell'invio."); + return; + } + + detail.deliveryStatus = "Inviato"; + detail.recipients = (detail.recipients || []).map((item) => ({ + ...item, + status: "sent", + documentId: activeDocumentId + })); + + const runRecipient = copilotRun.recipients.find((item) => item.documentId === activeDocumentId); + if (runRecipient) { + runRecipient.status = "sent"; + runRecipient.note = "Invio confermato dall'operatore"; + selectedRecipientId = runRecipient.id; + currentAnalysisDocumentId = activeDocumentId; + currentDocumentState = { + label: "Inviato", + statusClass: "sent", + itemClass: "", + stateTags: `inviato ${detail.type} ${detail.company} ${detail.employee}` + }; + syncRecipientsIntoDocument(); + } + + hideSendDraft(); + setText(sendStatusMessage, "Invio completato."); + setText(detailStatusMessage, "Invio completato. Stato invio aggiornato."); + updateAllDocumentCards(); + updateDocumentDetail(activeDocumentId); + updateCopilotAnalytics(); +} + +function notAvailable(value) { + return value === null || value === undefined || value === "" ? "Non disponibile" : String(value); +} + +function documentFromApi(item) { + const confidence = item.confidence === null || item.confidence === undefined ? "" : `${item.confidence}%`; + const employee = notAvailable(item.employee); + const company = notAvailable(item.company); + const type = notAvailable(item.type); + + return { + title: item.title || item.file || "Documento", + batchId: item.id, + employee, + company, + file: notAvailable(item.file), + date: notAvailable(item.date), + pages: item.pages ? String(item.pages) : "Non disponibile", + type, + description: notAvailable(item.description), + confidence, + deliveryStatus: item.deliveryStatus || "Da inviare", + previewLines: item.previewLines || [ + "Anteprima non disponibile.", + "Campi non ancora rilevati." + ], + recipients: [ + { + name: employee, + page: item.pages ? `${item.pages} pagina/e` : "Non disponibile", + confidence: Number.isFinite(Number(item.confidence)) ? Number(item.confidence) : 0, + status: item.deliveryStatus === "Inviato" + ? "sent" + : confidence === "" + ? "needs-review" + : "confirmed", + documentId: item.id + } + ] + }; +} + +function applyDocumentState(documents = []) { + Object.keys(documentDetails).forEach((key) => { + delete documentDetails[key]; + }); + + documents.forEach((item) => { + documentDetails[item.id] = documentFromApi(item); + }); + + const firstDocumentId = Object.keys(documentDetails)[0] || ""; + activeDocumentId = firstDocumentId; + currentAnalysisDocumentId = firstDocumentId; + selectedRecipientId = firstDocumentId; + copilotRun.processed = documents.length > 0; + copilotRun.recipients = firstDocumentId ? Object.keys(documentDetails).map(recipientFromDetail) : []; + + if (documents.length === 0) { + copilotDetailSection?.classList.add("is-hidden"); + setText(uploadState, "In attesa di caricamento"); + setText(uploadOutput, "Le entry di invio compariranno nello storico sottostante."); + } + + resetDocumentFilters(); + applyDocumentFilters(); +} + +function applyCopilotState(state) { + renderMetricList(copilotMetricList, state?.metrics || []); + renderMetricList(copilotDocumentBreakdown, [ + [String(state?.documents?.length || 0), "Documenti nel periodo"], + [String((state?.documents || []).filter((item) => item.deliveryStatus === "Inviato").length), "Invii già completati"], + [String((state?.documents || []).filter((item) => item.deliveryStatus !== "Inviato").length), "Invii non completati"] + ]); + renderMetricList(copilotQualityBreakdown, [ + [`${confidenceReviewThreshold}%`, "Soglia di revisione umana"], + [String((state?.documents || []).filter((item) => item.confidence === null || item.confidence < confidenceReviewThreshold).length), "Documenti sotto soglia"], + ["n/d", "Tempo medio per documento"] + ]); + applyDocumentState(state?.documents || []); +} + +function applyAppState(state) { + appState = state || {}; + applyAssistantState(appState.assistant || {}); + applyCopilotState(appState.copilot || {}); + + const assistantMetrics = appState.assistant?.metrics || []; + const copilotMetrics = appState.copilot?.metrics || []; + const drafts = assistantMetrics.find((metric) => metric.label === "Bozze da rivedere")?.value ?? 0; + const review = copilotMetrics.find((metric) => metric.label === "Da verificare")?.value ?? 0; + const ready = copilotMetrics.find((metric) => metric.label === "Pronti per invio")?.value ?? 0; + + renderMetricList(document.getElementById("overview-priority-list"), [ + [String(drafts), "Bozze da rivedere"], + [String(review), "Invii con anomalie"], + [String(ready), "Invii pronti"] + ]); +} + +function updateCopilotAnalytics() { + if (appState?.copilot) { + applyCopilotState(appState.copilot); + return; + } + + renderMetricList(copilotMetricList, [ + ["0", "Documenti analizzati"], + ["0", "Da verificare"], + ["0", "Pronti per invio"], + ["0", "Inviati"], + ["n/d", "Tempo medio analisi"] + ]); + renderMetricList(copilotDocumentBreakdown, [ + ["0", "Documenti nel periodo"], + ["0", "Invii già completati"], + ["0", "Invii non completati"] + ]); + renderMetricList(copilotQualityBreakdown, [ + [`${confidenceReviewThreshold}%`, "Soglia di revisione umana"], + ["0", "Documenti sotto soglia"], + ["n/d", "Tempo medio per documento"] + ]); + return; + + const period = copilotMetricPeriod?.value || "Ultimi 30 giorni"; + const status = copilotMetricStatus?.value || "Tutti"; + const allEntries = Object.values(documentDetails); + const visibleEntries = allEntries.filter((detail) => { + if (status === "Inviato") { + return displayDeliveryStatus(detail) === "Inviato"; + } + + if (status === "Non inviato") { + return displayDeliveryStatus(detail) !== "Inviato"; + } + + if (status === "Sotto soglia") { + return confidenceNumber(detail) < confidenceReviewThreshold; + } + + return true; + }); + const periodFactor = period === "Ultimi 7 giorni" ? 0.42 : period === "Mese corrente" ? 0.78 : 1; + const analyzed = Math.max(visibleEntries.length, Math.round(184 * periodFactor * (visibleEntries.length / allEntries.length))); + const belowVisible = visibleEntries.filter((detail) => confidenceNumber(detail) < confidenceReviewThreshold).length; + const belowShare = visibleEntries.length ? Math.round((belowVisible / visibleEntries.length) * 100) : 0; + const belowAnalyzed = Math.round((analyzed * belowShare) / 100); + const sentEntries = visibleEntries.filter((detail) => displayDeliveryStatus(detail) === "Inviato").length; + const nonSentEntries = visibleEntries.length - sentEntries; + const reviewEntries = visibleEntries.filter((detail) => ( + confidenceNumber(detail) < confidenceReviewThreshold + || detail.recipients?.some((recipient) => recipient.status === "needs-review") + )).length; + const correctRate = status === "Sotto soglia" ? "88%" : status === "Inviato" ? "99%" : "97%"; + const recipientRate = sentEntries > 0 && sentEntries === visibleEntries.length ? "96%" : "94%"; + const averageTime = period === "Ultimi 7 giorni" ? "16 sec" : "18 sec"; + + renderMetricList(copilotMetricList, [ + [String(analyzed), "Documenti analizzati"], + [correctRate, "Classificazioni corrette"], + [`${belowAnalyzed} (${belowShare}%)`, "Documenti sotto soglia di confidenza"], + [recipientRate, "Destinatari riconosciuti automaticamente"], + [averageTime, "Tempo medio analisi"] + ]); + + renderMetricList(copilotDocumentBreakdown, [ + [String(visibleEntries.length), "Invii nel filtro metriche"], + [String(sentEntries), "Invii completati"], + [String(nonSentEntries), "Invii non completati"], + [String(reviewEntries), "Revisioni umane richieste"] + ]); + + renderMetricList(copilotQualityBreakdown, [ + [`${confidenceReviewThreshold}%`, "Soglia di revisione umana"], + [`${belowAnalyzed} (${belowShare}%)`, "Documenti sotto soglia"], + [recipientRate, "Destinatari riconosciuti automaticamente"], + [averageTime, "Tempo medio per documento"] + ]); +} + +function getFilteredDocumentIds() { + const query = (documentSearch?.value || "").trim().toLowerCase(); + const deliveryFilter = documentDeliveryFilter?.value || ""; + const confidenceMode = documentConfidenceMode?.value || ""; + const parsedThreshold = Number.parseInt(documentConfidenceThreshold?.value || String(confidenceReviewThreshold), 10); + const threshold = Number.isFinite(parsedThreshold) ? parsedThreshold : confidenceReviewThreshold; + const monthFilter = documentMonthFilter?.value || ""; + const yearFilter = documentYearFilter?.value || ""; + + return Object.keys(documentDetails).filter((documentId) => { + const detail = documentDetails[documentId]; + const searchableText = `${detail.title} ${detail.employee} ${detail.company} ${detail.file} ${detail.type}`.toLowerCase(); + const deliveryStatus = displayDeliveryStatus(detail) === "Inviato" ? "inviato" : "non-inviato"; + const confidence = confidenceNumber(detail); + const parts = dateParts(detail); + const matchesQuery = !query || searchableText.includes(query); + const matchesDelivery = !deliveryFilter || deliveryStatus === deliveryFilter; + const matchesConfidence = !confidenceMode + || (confidenceMode === "lt" && confidence < threshold) + || (confidenceMode === "gte" && confidence >= threshold); + const matchesMonth = !monthFilter || parts.month === monthFilter; + const matchesYear = !yearFilter || parts.year === yearFilter; + return matchesQuery && matchesDelivery && matchesConfidence && matchesMonth && matchesYear; + }); +} + +function updateDocumentFilterStatus(filteredIds) { + const deliveryFilter = documentDeliveryFilter?.selectedOptions?.[0]?.textContent || "Tutti"; + const confidenceMode = documentConfidenceMode?.selectedOptions?.[0]?.textContent || "Qualsiasi"; + const threshold = documentConfidenceThreshold?.value || String(confidenceReviewThreshold); + const month = documentMonthFilter?.selectedOptions?.[0]?.textContent || "Tutti"; + const year = documentYearFilter?.selectedOptions?.[0]?.textContent || "Tutti"; + const confidenceText = confidenceMode === "Qualsiasi" ? confidenceMode : `${confidenceMode} ${threshold}%`; + const periodText = month === "Tutti" && year === "Tutti" ? "Tutti" : `${month} ${year}`; + + setText( + documentFilterStatus, + `${filteredIds.length} invii trovati - stato: ${deliveryFilter}, confidenza: ${confidenceText}, periodo: ${periodText}.` + ); +} + +function resetDocumentFilters() { + setValue(documentSearch, ""); + setValue(documentDeliveryFilter, ""); + setValue(documentConfidenceMode, ""); + setValue(documentConfidenceThreshold, String(confidenceReviewThreshold)); + setValue(documentMonthFilter, ""); + setValue(documentYearFilter, ""); +} + +function updateDocumentSummary(filteredIds) { + const details = filteredIds.map((documentId) => documentDetails[documentId]); + const review = details.filter((detail) => detail.recipients?.some((recipient) => recipient.status === "needs-review")).length; + const sent = details.filter((detail) => displayDeliveryStatus(detail) === "Inviato").length; + const ready = details.length - review - sent; + + renderMetricList(documentSummaryList, [ + [String(details.length), "Risultati filtrati"], + [String(review), "Da verificare"], + [String(Math.max(ready, 0)), "Pronti per invio"], + [String(sent), "Inviati"] + ]); +} + +function renderDocumentHistory(filteredIds) { + if (!documentHistory) { + return; + } + + const pageCount = Math.max(1, Math.ceil(filteredIds.length / documentPageSize)); + currentDocumentPage = Math.min(Math.max(currentDocumentPage, 1), pageCount); + const pageStart = (currentDocumentPage - 1) * documentPageSize; + const pageIds = filteredIds.slice(pageStart, pageStart + documentPageSize); + + documentHistory.replaceChildren(); + pageIds.forEach((documentId) => { + const detail = documentDetails[documentId]; + const state = documentStateFromDetail(detail); + const row = document.createElement("li"); + row.className = `document-row document-item ${state.itemClass || state.statusClass || ""}`; + row.dataset.documentId = documentId; + + const recipient = document.createElement("span"); + recipient.className = "document-cell-main"; + recipient.innerHTML = `${detail.employee}${detail.company}`; + + const type = document.createElement("span"); + type.textContent = detail.type; + + const file = document.createElement("span"); + file.className = "document-cell-file"; + file.textContent = detail.file; + + const date = document.createElement("span"); + date.textContent = detail.date; + + const confidence = document.createElement("span"); + confidence.textContent = detail.confidence || "Non disponibile"; + + const status = document.createElement("span"); + status.className = state.statusClass ? `document-status ${state.statusClass}` : "document-status"; + status.textContent = state.label; + + const actions = document.createElement("span"); + actions.className = "history-actions"; + + const detailButton = document.createElement("button"); + detailButton.className = "text-button"; + detailButton.type = "button"; + detailButton.dataset.documentDetail = documentId; + detailButton.textContent = "Consulta"; + + actions.append(detailButton); + row.append(recipient, type, file, date, confidence, status, actions); + documentHistory.append(row); + }); + + for (let index = pageIds.length; index < documentPageSize; index += 1) { + const placeholder = document.createElement("li"); + placeholder.className = "document-row document-row-placeholder"; + placeholder.setAttribute("aria-hidden", "true"); + for (let column = 0; column < 7; column += 1) { + const cell = document.createElement("span"); + cell.textContent = "\u00a0"; + placeholder.append(cell); + } + documentHistory.append(placeholder); + } + + setText(documentPageInfo, `Pagina ${currentDocumentPage} di ${pageCount} - ${filteredIds.length} risultati`); + if (documentPrevButton) { + documentPrevButton.disabled = currentDocumentPage <= 1; + } + if (documentNextButton) { + documentNextButton.disabled = currentDocumentPage >= pageCount; + } +} + +function applyDocumentFilters({ keepPage = false } = {}) { + if (!keepPage) { + currentDocumentPage = 1; + } + + const filteredIds = getFilteredDocumentIds(); + updateDocumentSummary(filteredIds); + updateDocumentFilterStatus(filteredIds); + renderDocumentHistory(filteredIds); + documentEmpty?.classList.toggle("hidden", filteredIds.length > 0); +} + +function setCurrentDocumentState(label, tags, statusClass, itemClass) { + currentDocumentState = { + label, + statusClass, + itemClass, + stateTags: tags + }; + updateCurrentDocumentCard(); +} + +function loadDocumentIntoFlow(documentId) { + const detail = documentDetails[documentId]; + if (!detail) { + return; + } + + currentAnalysisDocumentId = documentId; + copilotRun.processed = true; + copilotRun.recipients = getBatchDocumentIds(documentId).map(recipientFromDetail); + const selectedRecipient = copilotRun.recipients.find((recipient) => recipient.documentId === documentId) + || copilotRun.recipients.find((recipient) => recipient.status === "needs-review") + || copilotRun.recipients[0]; + selectedRecipientId = selectedRecipient?.id || ""; + currentAnalysisDocumentId = selectedRecipient?.documentId || documentId; + + const selectedDetail = documentDetails[currentAnalysisDocumentId] || detail; + setText(uploadState, `Invio selezionato: ${selectedDetail.title}`); + setText(uploadOutput, `${copilotRun.recipients.length} entry disponibili nello storico invii.`); + + currentDocumentState = documentStateFromDetail(selectedDetail); + updateAllDocumentCards(); + if (!copilotDetailSection?.classList.contains("is-hidden")) { + updateDocumentDetail(currentAnalysisDocumentId); + } + goTo("copilot", "copilot-documents"); +} + +async function processUpload(file) { + if (!file) { + return; + } + + if (file.type !== "application/pdf") { + setText(uploadState, "Formato non valido"); + setText(uploadOutput, "Carica un file PDF."); + return; + } + + uploadBox?.classList.add("processing"); + copilotRun.processed = false; + copilotRun.recipients = []; + setText(uploadState, "Analisi automatica in corso"); + setText(uploadOutput, "OCR locale/Textract in corso. Lo storico si aggiorna a fine analisi."); + + const formData = new FormData(); + formData.append("document", file); + + try { + const result = await apiRequest(apiRoutes.documentOcr, { + method: "POST", + body: formData + }); + + setText(uploadState, "Documento acquisito"); + setText(uploadOutput, result.message || "Campi inizializzati come non disponibili."); + + if (result.state) { + applyAppState(result.state); + } + + goTo("copilot", "copilot-documents"); + } catch (error) { + setText(uploadState, "Analisi non completata"); + setText(uploadOutput, humanApiError(error)); + } finally { + uploadBox?.classList.remove("processing"); + if (documentFileInput) { + documentFileInput.value = ""; + } + } +} + +uploadBox?.addEventListener("click", () => documentFileInput?.click()); + +documentFileInput?.addEventListener("change", () => { + processUpload(documentFileInput.files?.[0]); +}); + +documentSearch?.addEventListener("input", applyDocumentFilters); +documentDeliveryFilter?.addEventListener("change", applyDocumentFilters); +documentConfidenceMode?.addEventListener("change", applyDocumentFilters); +documentConfidenceThreshold?.addEventListener("input", applyDocumentFilters); +documentMonthFilter?.addEventListener("change", applyDocumentFilters); +documentYearFilter?.addEventListener("change", applyDocumentFilters); +documentPageSizeSelect?.addEventListener("change", () => { + const nextSize = Number.parseInt(documentPageSizeSelect.value, 10); + documentPageSize = Number.isFinite(nextSize) ? nextSize : 6; + applyDocumentFilters(); +}); +documentResetFilters?.addEventListener("click", () => { + resetDocumentFilters(); + applyDocumentFilters(); +}); +copilotMetricPeriod?.addEventListener("change", updateCopilotAnalytics); +copilotMetricStatus?.addEventListener("change", updateCopilotAnalytics); +copilotExportButton?.addEventListener("click", () => { + const period = copilotMetricPeriod?.value || "Ultimi 30 giorni"; + const status = copilotMetricStatus?.value || "Tutti"; + downloadTextReport( + "report-metriche-copilot.txt", + [ + "Report metriche AI Co-Pilot", + `Periodo: ${period}`, + `Stato: ${status}`, + "", + metricRowsText(copilotMetricList), + "", + "Volumi e stato invii", + metricRowsText(copilotDocumentBreakdown), + "", + "Qualita OCR", + metricRowsText(copilotQualityBreakdown) + ].join("\n") + ); + setText(copilotExportStatus, `Report Co-Pilot scaricato (${period}, stato: ${status}).`); + copilotExportStatus?.classList.remove("hidden"); +}); +documentPrevButton?.addEventListener("click", () => { + currentDocumentPage -= 1; + applyDocumentFilters({ keepPage: true }); +}); +documentNextButton?.addEventListener("click", () => { + currentDocumentPage += 1; + applyDocumentFilters({ keepPage: true }); +}); +documentHistory?.addEventListener("click", (event) => { + const detailButton = event.target.closest("[data-document-detail]"); + if (detailButton) { + showDocumentDetail(detailButton.dataset.documentDetail); + return; + } +}); + +document.querySelectorAll("[data-detail-preview]").forEach((button) => { + button.addEventListener("click", () => { + detailPreviewMode = button.dataset.detailPreview; + renderDetailPreview(documentDetails[activeDocumentId]); + }); +}); + +detailEditButton?.addEventListener("click", () => { + setDetailEditMode(true); + hideSendDraft(); + setText(detailStatusMessage, "Puoi modificare nome e cognome, azienda, data documento, tipologia e breve descrizione."); +}); + +detailCancelButton?.addEventListener("click", () => { + updateDocumentDetail(activeDocumentId); + setText(detailStatusMessage, "Modifica annullata."); +}); + +detailSaveButton?.addEventListener("click", saveDetailChanges); + +detailSendButton?.addEventListener("click", () => { + setDetailEditMode(false); + buildSendDraft(documentDetails[activeDocumentId]); +}); + +sendCancelButton?.addEventListener("click", () => { + hideSendDraft(); + setText(detailStatusMessage, "Invio annullato. Il documento resta non inviato."); +}); + +sendConfirmButton?.addEventListener("click", confirmDetailSend); + +async function initializeApp() { + try { + const session = await apiRequest(apiRoutes.session); + updateProfile(session); + + const state = await apiRequest(apiRoutes.state); + applyAppState(state); + } catch (error) { + setText(profileInitials, "--"); + setText(uploadOutput, "Sessione non inizializzata. Accedi di nuovo se le operazioni non rispondono."); + setText(assistantComposeNote, humanApiError(error)); + } +} + +applyDocumentFilters(); +updateAllDocumentCards(); +updateCopilotAnalytics(); +initializeApp(); diff --git a/public/nexum-app/eggon_logo_43542.png b/public/nexum-app/eggon_logo_43542.png new file mode 100644 index 0000000000000000000000000000000000000000..29f15d636d8e45fca8bb598dc67b815a87a0316d GIT binary patch literal 22752 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≫w$<<|*-!*l6Y$EmC!iwK9?x zWXTujT44anIHIu|Cnu0iy$suKBdJ%hyPIn<8@Yezbe%@iZ^EMNAYevzRYO`FFk-?8 z7ewtM7&cA|qAnp&Vvw)N8ZrT`W`X3Eo{SBTD&3K+wCD#~&2{YsqrKtdn`-m0v_&4- zvGZpM7B;HqPDhR$d+?g33(omm=B7+3X}Jo1kW6ZqrfP-)&P5Q5-F_;RH0SOI{yx#mMojbCGv$6W-2Y#zQp8P6$(+d6JPZj;`I%G9TV zN-Yz3oEuQo&rRgR(t?d#DkCADyq*!UqCuCX_;R-K_%Vj9mzQ81kTh>OZ>yqjd zk|SYTynE?e=2}3vccoZBGqLd-kPk-8rt@FX+>dH362MVNI(T~*$9t@7z1d4aS*24l zzNWnW4V>p>U~}V6DbDFwZc<@}2aZ)%R-bzmI__D7TO8{}fEgBX11gJQT|eJGgK8Ap zu)W*X&MVTfn~(?$%kMZ?=_l!zKdKP6&7~}g(+9zulb^arEohyW8X6dZK)D8OT<@j? zVw&T>{Ha}vu%rb!Au8eWHwx^<^EC}+b?e_kDDnHR&#j4X{48A13ClREbh~!(x=-QG z5!dpKqrEP&#>jgku9arxksXwDq$d{jxlb2!M#O`S6sA%{C5}Z_Q+{}coPbF{kz@PImVPP zAS50eJT)@bdTsB+oYU`DSJv6a1-gE^(02yA#yvIkPQ<7FVTX@69V4^W%@)D1S5UQE zYg+?LpmuY>d8w5(e8DhUcO5hRVWRF{eFkxPiPg^ z2O)^Ys#dX5ze~b@+~N-B7z!Wj$XM#dO}%;C_Cxq-^Ahxa25ov_-j_hqJz8dAj{*!vft{d-tv(ap+ZE;hE}y62nN8}#954cKB0eF4fJw%At; zug%7Je7Z1j1v^Nmm}4`;;Ln*6gm)fjtTm*2mXl333gL+x<~}o*oQ)*H#v0S`gOIdi z=W+%2*9|`+9D9thMbYum?z$bvj$`4P8s2flpT)IQirh&wzR4V3E_nBIs72Rg#Ngyr zdXbaAtiCe$tOwHk_E&1fV5|-{&A!tlbuydge0~^E@#m;rbRTQj*ctwD`Y0BDD?KRF zuUn$rgUhzMl5M3n=-qdK%Kl2OU2xMIo&#IlW+M4))_PFr#tb0uRP(HkSdG?qPQQs%l zA{E`6?DW8>HDpL*#sQhB5TtgeQ7wS1(b{dEK$Vf79qXUVYij^+dSH}GY`jh%-t9Y9 z^>P26x7}lA{yFOsxr~w-!~Mll@?$J`Z}wY{opQkk9|svQlmL#Nuwezl%+*zU#5Kt z9mS!(u%=K^Yf-&}-TOL=rP>5anqt(Y@A3v>m8pJ~ z;!at)cU(@XPYZs?Saj?0-2jpsCN*e#gXz7BX4|A{$;{SDEkgtOw842q7<@SI3AYx= z97EPh@T6?|;1CLPHxyerXeD6e)FXNEl4w%Uf#NVz{=W4wcD{rxMdKR1=)Dn`dD$x(MeQtI9s?zjiqkh7cpL)7!l?yGN~=)u$3P zc!IZ!1vkyE5lff=wO;htz{m33V+GYobsn51pl%S z*&T7?#9)ly#R%U^ujPW-mtpGO~Bprp5dz6ewQo^-n-$gO^4w4bEFjD;LC z!z2|HvyLM>uS0a}`9hB^!A)5Mg1_0T%iRN^1xq|P4HM26s78l?K5s!_t~)I+{lQv` z*UeVc3-eo>=asOn;y+_AEVeK zVP1=E?x=zaiDO^n#`XTi>xzHSnG0snucIRTmq&RYl<{peFr==^hI7kl`z5#2eS4I) z=(l|Py)@|PWHlofwVw2s)fB_vNY#DlJu_=MPs__WX>6-vMiF@SG@&~AC;8VYR>&sTi_#~v{0yw zI%<7xEG0lqn&4HcC_ABrc_89V*L5SuLxM{pNcLgD!7205!`ohI;^(KI7jSFaPw0L9 zKS7(bTJFB?j_t+8$3+;)+Qzx2%wmI4Q_+jD3}0Cd1@wu(78fi_<^=R{Z%3A;IP5+ZrLUH)Ce9Zm)Q;}ng>l1qW>^=kwd*LD}VJuA!F0sbv& zEIge3oN302m8>w^=2OXeB+ms#menM=`K?2R9CU}kS<8aU!m!q5GA6zWiI{R-)sJl+ zM55o>U<&2mLvVNZ`7AG)PrP8Lb1yjRyR(FYD}wdf;br#HHRgEcY3!V;At?Oqr4ihp z5qZkPc69%&cI=Y9&|YV@`a9d5-t%yPsMyvyJl-bO6ZIWV=8|vH!o%;|du3lS<`%!a zK~v&gmO1NB{NuL4C1uWz!IIK^WX56lWGQHhKQgH`oM->|9^ipHxgS2~%i>^ivrw7d zurBbqX~FEt?IXxV#zgnxC%6}j2!TqLB8i4CII%;h_cv}Myt=RZV(%;ZLch;5g5rdV zrdc+}KL&zojv>C?;Ue%W1Z8KIQMU0+%q4Vjgzn`vi>%1q^_2+IxfO0B&!&{iS1XaP z#Gz(G(v&voiZLpE57R5k@8P-o6OVF3^(7S(?;>z-6=O)d?v|OD!!W;4d&aR)>&J~z z+>WBF=R9g$Lur^FPOuH4W$>btOg-AyQsP~aE%8OX(3k8MYi^IbLkA(q8$u7iq9c%c zi0(q15TQlH^EXk(;me2JysJTiGC0eStHN!xLivurJ5Mnx{WNNg|J7`8XN30N(GRGw zGv^qq3NQUlqx)1yxlOx=he@$v9F5F$AcBnzlGzhknx?|XXHG~c-X+iOt;e`eIQGJ2 zTy0cAjhNKrHtsuHl-qJpn|TuJi$1vOo^8)2vk-@_C#OEK@!%m=U9U!QE&#$w?tf;B z$-e9iIv&z;zGe~>hgCG;z;?uFdNF;%oAmV`%O~d{eYkDc6h9G4vJ@Gi(lEI z)N_kfy5sL}HGOS7e?qS4M{Jxb8wLH!rXHV5ATq?($2t?-d8_wP`^@?Q!jjGlO4!1r zrP2Hh3<{R&k-7dmNtM*ohP_O0VTA1tK?1(O3gvq)qTk^s%0y0(_{~xkL)}FI`uv-1 z^t10+3R^K@IQEm-In|Fb7>!k_BSn!{R3tpiW;LXB*IQnHT$GJ0p^-WwE;K|_yFC2f zomfXv&LN<&u<$y$c}nvJ-FjF9*)?A8uWWZ5p?Ef_iZL%;_xCfpwaS*q`JL#6yUy`; zEn}Tr<1o=9AT)3u%g4%eKe2zSsEg_xfA?VzMh@H}_4_#IynO;FZaojs5|P0&OW95J z>#MnO>ra?)NKHhLsa17cC-*vJl#R58FN@k zwbGTmxoGWcoirg~66wsMn?@w#c&29oBJbv&1lBCw2i~G)Q-55)F30hD{fP+=Vt-Ec zseaWAI~vbhb2@P;?8XW1kKy_<8}b#9z`7s0ExU-%S=3&*51|XzDDCl}y4rvG48vEB zotGY_Zq++kO!$o%WW{t=p~V0|)FR_QPR+O>Rat8mtpaPFLE=8<*(H2rWApXe0X=XO zs*~QA>1GHC^Yp)6*rnCoMWuAzL9z9MV+bXm`KK%uK=OR|ccPmbWYZyJoIjt%8@T`Rczx(b? zALb_V1Gu?Xg+41@h9pp9W;j&si^TDdcTK_1#nZ&rjC=VZ5pF3*;HJ}3aMLgT?rg=V zmmxP!^pHI-?dQ%G$jMl^NWWwe)dYe*hSrXzPg`g+jH#xU{yy*@l?uT8pO;T1F8X?bYrIGYpQ%h%j(JSfrVl;)%I`LZT!A&}0y{Lr7L|GE=j z%RRRq_AJk}^_Tb}ii~_QFa2 zrOx;?du&Ii6Un%PRC8mWSObP~kBs%wd<7L?GRlxAW9-pK+$Sa)%FLxx-I}L+N_hn4 zyF}U1L7B#elAA5n*(!ZRcn)7XCMe)yNoJ|}K_%Eh?b-WAU+1VQruAODMk;KR{n9*l zm;W-B>3Q9K<6U#Cr4t-{$^S_PmDYE}zLy#lsd+GaZ)wqspSSD!zUW^3Q%~-Z!i4qCW2ZN@^J&%4vwL`5e3`B=#pQsg4^(-kO5 z(Uw9%O7ZzW*>fK&LtAhpfDHaY)$kt$~3bnaN(bROv6E|sZt{1*p;?h z*SIMNUzXS?CQPT5t^C-qu*_@~pHBK^=*}Lm8Gk3UMx^nu|^*P{pV$icV~h_E6Sn<# zl|s<_Yci%NIWMt2b_-f^vc~A@aOI?my!iKQF>e`~Ikql{KmR%mQW{JkVn-cxuDP+? z+z!$gO0gK9RT;o7cyk^SIDYy4T;rmMEH=oI&E6*-l>joL!Ral_6SX6j^o8;-jc!l( zlA?JRd=qS`Kj^UYUWTj-MzxrVpbn*;W7}nNQ09)NZbQmm(ZxZR2cGhEOA%K=do!u656dE;~BNxWf{}v)~t{v6uQUXRu>SXCJn5 z*^z3UX+_|{RLYv(avb2?r=MokUx(B@-%Q<5$rks3{H*V2TiA6!5?U$T^noj5b|O8V6-QcYPA3v z+;aM8^R^<#5!m)Z`*$Vz7QD3z(9)KYzpt0LABV9{(TCSE&@8w&HIZ3v#DsVZl7BKf z%382H2J!mbblq7lZRQS05jEKD3S-(Ht;yE-?l4IQ>$&}W(_}0{g3>KnK|9qqpO^fb zLdlnlCn>&5k28Uj%JnCZ-hy>^**DE3rd}*r(925$zNP?-((Fd*{8;~Gz^BziC^_bB zaQCgg0-S=5b_<(zD7t-FZfQcY6^_If3vS9y614_WA*z9*?W~C|KGUcAk-*&}o4gjD z=k|)EXl|_mwm(>P-F3uVg?qCqQv<=jajb-V0WVWmo+JvmwS4Okx8hn8X!#mDDkZT6 z)yKE|$r5?JT!An3JM``kXR?6HQZJjhMA7^0h^C4raL-ei>Qe<@&3mjRb59c2c{?9 zlUkSA?{A!rXAt027yn6-L)N&%5BKHSEGxf$j_w{4%Gqc5Ku9m@Mr;uIpvbELjXq4? zFRwx@PkIRp{|?=|1L;&0m7?ihE_ce`()z3GeLWl%DbutELw)A@gN1J`v(#=Qurb^`#*ZS#jC$w9{5cOT{6eJ?tr zJD2K7_1wC}EB9$HOn@oZVGEOTJcv0z3oQ0^taXLn@BHl|P1K>$cCbHu7RXoG4jjPe z7o8Yf%KiRkUoldqFVQX&f6*6Qk!QjEQkd?uxDMl}1j!L7k>?V?XW$k`^UITN#qrs9 z6MuqXPy6fiQyA>cdaSa=8=MlJeoHSurqv?-&aO3QkyX;{DM6Lc<{Wck@sl`&9w80C z7;Aaq1reIlpIOF`K0oLSEt%FUHV_YfLGMJbo>M!o5mbxj8UT*?A7sI82TVY8Fb1-= zGbU9l(VG_1+ogJa68hQ0TjsNR&7}(-oTxqF53F{;ZLfqffOogKdQ3l-E5xKCLKqx% zr8WFF!?-bD6P@7({6@4XvjQnQ3VH8lyDj24j}?yF8RtwuJTM1+p>8R!!kiZgO(}td zp9zN;AEx&T?yX;X*9K5!JBhj0q7^q5j+E7=Yb*L!$mqM1@9VeJ>z*r#ZD<+{CF?HdAPLx>hC0#8>Y9Q%9CCKxS;J* z*uaHQ67;)*hh&akft#uT4USjLY`4_i6zX7vFH5HoU@NW}tOh(wN0%SBwgr|&J6cla zKk^YCEZIrZ#}E3Ew-uIF2f1KRy)A8Qnuwq2W~+-Y!q2N^#_^%SOz@?SBIspC0=OssTF`kR`4)ZwvsT~|qju5<2Lmwi%=YeDv7iQU`2Nvn}WPLwDQQ;RJi zfzX|nIj?DsdA{rkDw1*@u@37X@dhk@c)jO?cPAI{DqYkzr1UsT_ci7C-JQv~xjNQ5 zZvEDTPFM$Wd!70>mLJ2qA6X5zDV(VAB)$lsZB9N0pAw#?g7kD0=eaBnHo8gMqfP{n zoJsgGwl=;s#|nAlwvRv{#?Y((3xLXd6+wO!a7lc+fz#-N9k;BAOTA}8Kk$1tZ+|HS$q6`yFt}f65mD(cxQmU zXKq_YK1V7qSu8DK*&pnfS&f1-xm#uU5)}%%vGtcO7MGdcc$K%{Mey^8$vf{Sjd$9R z8cO;Mirh6fsPDqhU2v%iXiqq43|(`pPdS;@E|`#4XRH>tkcPZUt4!(hDlmLXKH)$` zgHsRB-TCf(%U#+vqdRMXpQLwdMjW+7xfya>c2cy;rI}d>=#?^DaX$_jffos5wiJ@k z@8;-sSxuFW3Kl3$Y6xy?M#xUQBGD<#l`Ypuz{i-O>8c~w-&4|U5>GIoMUlBGPBp#q z6Ot>?3R@yBHb@yiyQ^SFkh(9LX8@lIuf%!Ax8!a0({<$~pnNuIuW+~kC8 zg;!{fF5$}d9ZDL~Uu$w1P7n)L9wUcFV(^sO4h>!r?A2YNjHn#6jp6T=Y|OII{zQme zawy$^^9-j^kOg5z>RO-2EbCF`PwH;cu+d}I!t0+lCluv-Y1NK?+F&Ezc-5e7CLWBj zS{7VF=WeQTwXm+AlEL`Un-6zfe9;m9D9kd0<3h5~PXQ8HY(Ub$10oyMu8T-!t2F!W z8hE~%--kvU9(=!ma{+MwSsWquBS|p-xC)AMZtLeols#Xe+(v3d--6L>zR#j2_!{@t zT)|z~1O5Pk9ta!PG5yqrj!@8pd0E43d1oxsTxmUZ3P`>(h8?uSMc%I;K=2{cxc=O5 zKY*McsFibi{VP&x*?oFbKkp~HG8{29=2k0fWuASAmdXY_P)7PlD`U5BXRZU&GrWwi+#KXf8Bn#0%M?j+JxHRr>b2v`m)fjXU9BxaS*7 zTQl|MH*16uAAyG)?@G*R=I>gaZ9M4ldtu?NnY7t?xxH{~TDxcck>c#EbUk^krORqr z<>P4aN~cMI+X!t!?x+ingbU+1#%AbA_jzwUodWS6Ulgx1|@?nVoDj9iRQlWIM7SsV%B zE(j8npLfjzP=Wbp@aG@tJ={uqBsx1j0TpfY+qyg-rsKL2-7qb;+I69AN?DnD+z>~SO+y2{utj$W9U~WieKsj|CO6KECu?t#nqcr^qLTTL$Z;3cVp~ z(UGq+S{-F1Owm2+>+9zH25LUq_uw;kin0&qF8HJcftlwZTgJ2J9tm9zKa>nstQ8P` zRk*eQU^gkNuTf-&cB!j=NNjeLIc z3KD=DHZ}QR25vPA=J5YSBFsqA`hWJxi!|i;kEE|@{4ye&%Xh`nJ1V{$7wIJ6`kwZA z2<7`@M4TQhJwXp$EYj)6#Nit$rofeoX>1(;?ry>N$=+)WS9#Qlw5#fsz4vba>`;Y_ zo)I-qvbh*iPKCU+@M_`=;)( ztr~E(xgun-_ebE%b!2(hjTb!cI zgx`Y$3}y`v_!FHK`vRiK}l8DmATxmurs{U z0g!WYX^-8T0aJ7p-FhUV?fJ52)+Z}W`mA}kp@RVGl!Pizt@SK-&I1~V@Ryd}pUPuf z#SA-D7}tI}+X(_3F+`#v^ETbNt5CP}_(O__UfQ*V=W2*@i*x^*Ct20${SbE>2oz^gLcsk2wsxq-ZuJT)qC8T`b@XiSo$+zFqKYgZ94bd4R zXwdyM(;mCvCVw+`>OBB1Q(ffQlkSpl)bS;4{{}zuBjgMOp?i*9EL(;>dl_dRi6p%p zG8!9sq>HP`^@}T)$RK=YUrn!`X%nq5EWt(D@vI1)cv?lD-%Dz(^vUEy=|91=7 zX!=K0XaiUGdc&d46G%b5+Bb7OtKTVKDp>s9jt21`o39%eH5GIiwkRN}5MWdEFvq|6 z(?1V>02r8&QsTBm2Gh=~Xn3l_@Ie2kAc6-v^V|_kx^;yLyfky%*L%Kz$nzxWJ)f zg4v8>Vh6PKm*wb6d$V zTVGf7KN9#V|L@?H`1|+Z+Pv*?h)*j}Y<-!}b=gwaRSlySt#vc}Mrp+1cyZB|ZI2~` zZx!09I)jMsd24y2*D0ehemF$5WSVk1sHh!wM5=%D8vG>r2y2kE&$(o}GnUC+e)@v& z`v@y7nJw!;4Amw(7AE|v0)~W-rg%%rsoRZ+E<(a6*5*nwReUp(_q&DTQ@V188M&sq zxoq=|nJa(Xwi3HR1b*0c91M6#XP$93YcYj&$of?5Uq;Cdx$|}MBfz;N^%@RekrVdsiaWivt+_eNE-J|PPtl^(X44y%o$@x6C2k*bu~1&!5>`T zp|2F6R+Pz#;SKHdX!PhDq%%7xhglLnXEUyMy_3pgrx7k#)2@x%1|ZcqM(#Z^)#k+7 zi9jjrXpLL8cwjNB`l@XBmYn3Qwh)RtVml1B-9eBe4t_z2FU@4OS&>`hiR2DKY4Bf& zuHmb)oUg2s@7}+5E07d(@7QSzq)is=IcMd+4T5q?n!5 z+}+v0!^KqV7QsbWEM2j`%=(lMH|-{adTud>d!c5js#8>`N1+r6S8Fw zgn#GE(sgLJXdGIxU-Li5J$5siUhv+5#PKrb!>hB;&lP z!6b^qMBJ-b%WD1KzPDEG+V7FHBZw6Lja+)y9ERkvHjE+FTPi(OS95hS|iRl*v+ zh2&T3eu$~qr5A{I&5NqLvsYBH^}5P_b1%EbVJztHzJ6WWecA#UsUR(7ZouK8I_$Su z{^6tDdJMely~NY@hZB?9J2Az~HT6{QQl0j;dcUTQBUs`2#{;L>N^7Bix@_>WX#MW_ zPM@C#RtpmC@IFoGYdJ3-N-5Q0E84XmfIOn%YT`=R3U<{NaiIVuq)ZuT&SaG_CoH_; zq*l<`LD-cg^0{#fKKZb4+H^^4s(M8#?2Zx$V?uJ9{4ot~-9fKE*21p$o& z8*uAyGLns`sfs0;VmGHc$y7`9_T6j;H4v!VFT#BM5^ni__Z;UJN*?Og+eCT3`M?1J zA-3O_SpJWwx&-Z-aih#3M1x4(z!sO@Vv_GPH?Twyc^|%M|ABU1cwo| zwVKy6qfW3$#`-+`dfRA~_fjntz9vb7!j~t;<6H-Pj{o?S6x#VQh@q$KL&xGNv!pef(tw--WwyAXIjB?M&(#Eb|gB=)sPwGqTG@Du{(( zScfhBKhat_Q3yL^J=y4M19wHJsLsR5e^28BhX}veQ%_`JEDlht96ET==9H!__yE-y zQ*h^RJf(cWQFro)u-U(+$$#^4Kalv@RlE{R(4&wU;^j=l=rkfL3Qio(6n=y($d?IoWW#{tR&((M~?{0yTM;MNhcamgHETW$~p;}jf+)%mbr3!Kgi7YcXl(5fW#qIJZO|P zSe@IAOH~V_Qv$=x>2Z@I0SLQs-*y*8t05;DMgj2z* zp)beMIRPK+MB!LpR`15I4aoBnEx**bzc(=8p$YJVbUC!f`boDwlO`VAl*>y_gi zRr_n9Fe>uMoK`^YIUOmT^SQnf?T8IvA(riz*T|m%65yud(htW`)02e|STD-^P!rn6>u7o5JMXyGx}?ZX0TX=I5ZwhL13QuV^T z(=ITsd$jTDNw=+)6^2nAI+TW47e#5;_ysgRX^1Fb#LQOrdnKo<05&y;FD83&Vv(#> z%Hx%3@>Yu8G40erF~$Gpc2_}LaYPB;ikaG*i#MD-&;>$SRjeqHusCNr7v8^;jOW-c z+lOPCDYFlh4#lZmRl8AXX13qS?jV2voQT>|li`q2jP5^I;fJ&P=E!!e^* zzN7KY>O@>RRlHJK85dnJy1nG%BRL`y)f^EO#-8H>iZHAQRJ*Vb5#Kxx@%nJk)?OO% zk!edqXJd8kqkNhKbF<7f*sWd6p&$F(*hnhUAoq+uj}qte`MnR=W&E-u#TG(RrfXSRWrJ{Q$uDveF>CzcU=G8W-C@Ck(Qw100-8`?y+fyj&i0xRUy?g3|Odj z@s+?}J<2IHK^2|zN8gK_s#~rn043S|W1494UtSQa*Y+klvaYQ``zqjs6__=tlP7 zIP(Xf356t7w;Tg&L{3rA`^LQ4S3(qx#{qL1@s(?Mtd`tisg!+s_ zu1iO0L~TS9LwGS)L`5wH^|^Soekmz~-3rKgU|j7x>LZHMGKX_7;}CakqsrRWR--Qf zf26d|y!H;R)wVwNUorU-N|G8j!&C%6CUR@I6IC~SfmvCc59+=>XUI8M@# z9^zKDRi0O-=NeVo7BeXbRJ*w`N2~+Nt?=5;+7N-H#(PQ!FN_xjRE$0O5krjp3a~iF zae^v+-3u?HjA-21*oULcAc>Yj<|EoV8C$sxCuW7+KS_rT$m8KALQ>0#w(pN@ERC2A zxT+0Naf}K7_b^psf0y7i#$KM_0+KnI$O+mAqO0OYa5Z{cM79NOQuyeik9x|jBlhR1 zC;IG=txDEX%ZQ%i@qp?!R_6;AaH7_m7liU8{NA$!cR5G-fBXal=l}n&d;idVBd1|U TD(h?6$!JUst?t+8dp!F;R(+_# literal 0 HcmV?d00001 diff --git a/public/nexum-app/styles.css b/public/nexum-app/styles.css new file mode 100644 index 0000000..c08cef8 --- /dev/null +++ b/public/nexum-app/styles.css @@ -0,0 +1,1493 @@ +:root { + --surface-page: #f5f8fb; + --surface: #ffffff; + --surface-soft: #edf5fb; + --surface-muted: #f8fbfd; + --border: #d7e4ef; + --border-strong: #bfd3e4; + --text: #18324a; + --muted: #63798d; + --primary: #6fa6cf; + --primary-deep: #2f678f; + --accent: #f28a52; + --accent-soft: #fff1e8; + --success-soft: #edf8f2; + --warning-soft: #fff6e8; + --warning-text: #8a5825; + --success-text: #276b4a; + --overlay-locked: rgba(245, 248, 251, 0.72); + --border-soft: #e4edf5; + --shadow: 0 10px 28px rgba(38, 82, 120, 0.08); + --radius: 8px; + --sidebar-width: 260px; + --workspace-max: 1120px; + --workspace-pad: 24px; +} + +[data-theme="dark"] { + --surface-page: #0f1720; + --surface: #16212c; + --surface-soft: #223343; + --surface-muted: #1c2a36; + --border: #2c4052; + --border-strong: #3c566c; + --text: #e8f0f7; + --muted: #a8b8c6; + --primary: #79add3; + --primary-deep: #9dcced; + --accent: #f09a62; + --accent-soft: #3a2c25; + --success-soft: #193528; + --warning-soft: #3b2d1b; + --warning-text: #f0c28f; + --success-text: #9fd7b7; + --overlay-locked: rgba(15, 23, 32, 0.62); + --border-soft: #263847; + --shadow: 0 14px 34px rgba(0, 0, 0, 0.22); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--surface-page); + color: var(--text); + font-family: "Avenir Next", "Segoe UI", Arial, sans-serif; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +img { + display: block; + max-width: 100%; +} + +.app-shell { + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 22px; + padding: 22px 18px; + background: var(--surface); + border-right: 1px solid var(--border); +} + +.brand-block { + display: grid; + gap: 12px; + justify-items: center; + text-align: center; +} + +.brand-logo { + width: 142px; + margin: 0 auto; + border-radius: var(--radius); + transition: filter 180ms ease, opacity 180ms ease; +} + +[data-theme="dark"] .brand-logo { + filter: brightness(0) invert(1); + opacity: 0.96; +} + +.brand-block h1, +.topbar h2, +.hero-card h3, +.panel h3 { + margin: 0; +} + +.brand-block h1 { + font-size: 1.15rem; +} + +.brand-note, +.panel-note, +.status-message, +.preview-snippet, +.flow-list span, +.history-main span, +.compact-list span, +.analysis-list span, +.log-list span, +.hero-card p { + color: var(--muted); +} + +.eyebrow { + margin: 0 0 6px; + color: var(--primary-deep); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.side-nav { + display: grid; + gap: 16px; +} + +.nav-section { + display: grid; + gap: 6px; +} + +.nav-section-title { + margin: 0; + padding: 0 8px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.nav-item, +.nav-subitem { + width: 100%; + text-align: left; + border-radius: var(--radius); + background: transparent; + transition: background 160ms ease, color 160ms ease; +} + +.nav-item { + padding: 11px 12px; + color: var(--text); + font-weight: 700; +} + +.nav-subitem { + padding: 8px 12px 8px 22px; + color: var(--muted); + font-size: 0.92rem; + font-weight: 600; +} + +.nav-item:hover, +.nav-item:focus-visible, +.nav-subitem:hover, +.nav-subitem:focus-visible { + background: var(--surface-soft); + outline: none; +} + +.nav-item.active { + background: var(--primary); + color: #ffffff; +} + +.nav-subitem.active { + background: var(--surface-soft); + color: var(--primary-deep); +} + +.workspace { + width: 100%; + max-width: var(--workspace-max); + justify-self: center; + padding: var(--workspace-pad); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + scroll-margin-top: 16px; +} + +.topbar h2 { + font-size: 1.6rem; +} + +.theme-toggle { + position: fixed; + top: var(--workspace-pad); + right: max(var(--workspace-pad), calc((100vw - var(--sidebar-width) - var(--workspace-max)) / 2 + var(--workspace-pad))); + z-index: 50; + display: inline-flex; + align-items: center; + justify-content: space-between; + width: 72px; + min-width: 72px; + min-height: 38px; + padding: 4px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.session-actions { + position: fixed; + top: var(--workspace-pad); + right: max(var(--workspace-pad), calc((100vw - var(--sidebar-width) - var(--workspace-max)) / 2 + var(--workspace-pad))); + z-index: 60; + display: inline-flex; + align-items: flex-start; + gap: 8px; +} + +.session-actions .theme-toggle { + position: relative; + top: auto; + right: auto; +} + +.profile-toggle { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--primary-deep); + box-shadow: var(--shadow); + font-weight: 800; +} + +.profile-toggle:hover, +.profile-toggle:focus-visible { + background: var(--surface-soft); + outline: none; +} + +.profile-menu { + position: absolute; + top: 46px; + right: 0; + display: grid; + gap: 8px; + min-width: 230px; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); +} + +.profile-menu strong, +.profile-menu span { + line-height: 1.35; +} + +.profile-menu span { + color: var(--muted); + font-size: 0.9rem; +} + +.profile-menu form { + margin: 0; +} + +.profile-menu .text-button { + justify-content: flex-start; + width: 100%; +} + +.theme-toggle::before { + content: ""; + position: absolute; + left: 4px; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--primary); + transition: transform 180ms ease, background 180ms ease; +} + +[data-theme="dark"] .theme-toggle::before { + transform: translateX(32px); +} + +.theme-icon { + position: relative; + z-index: 1; + display: grid; + place-items: center; + width: 30px; + height: 30px; + color: var(--muted); + font-size: 1rem; + font-weight: 800; +} + +.theme-icon-sun { + color: #ffffff; +} + +[data-theme="dark"] .theme-icon-sun { + color: var(--muted); +} + +[data-theme="dark"] .theme-icon-moon { + color: #ffffff; +} + +.back-to-top { + position: fixed; + right: max(var(--workspace-pad), calc((100vw - var(--sidebar-width) - var(--workspace-max)) / 2 + var(--workspace-pad))); + bottom: 22px; + z-index: 50; + display: grid; + place-items: center; + width: 44px; + height: 44px; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--surface); + color: var(--primary-deep); + box-shadow: var(--shadow); + font-size: 1.2rem; + font-weight: 900; + opacity: 0; + pointer-events: none; + transform: translateY(10px); + transition: opacity 180ms ease, transform 180ms ease, background 180ms ease; +} + +.back-to-top.visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.back-to-top:hover, +.back-to-top:focus-visible { + background: var(--surface-soft); + outline: none; +} + +.view-stack { + display: grid; +} + +.view { + display: none; + gap: 18px; +} + +.view.active { + display: grid; +} + +.hero-card, +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.hero-card { + display: grid; + gap: 18px; + padding: 24px; +} + +.hero-card h3 { + max-width: 760px; + font-size: 1.7rem; + line-height: 1.18; +} + +.hero-card p { + max-width: 800px; + margin: 10px 0 0; + line-height: 1.6; +} + +.panel { + display: grid; + align-content: start; + gap: 18px; + padding: 22px; +} + +.compact-panel { + gap: 16px; +} + +.flow-step { + position: relative; +} + +.flow-step + .flow-step { + --flow-gap: 44px; + --flow-grid-gap: 18px; + margin-top: var(--flow-gap); +} + +.flow-step + .flow-step::before { + content: ""; + position: absolute; + top: calc(((var(--flow-gap) + var(--flow-grid-gap)) / -2) - 6px); + left: 50%; + z-index: 1; + width: 11px; + height: 11px; + border-right: 2px solid var(--border-strong); + border-bottom: 2px solid var(--border-strong); + transform: translateX(-50%) rotate(45deg); +} + +.flow-step.locked { + box-shadow: none; +} + +.flow-step.locked::after { + content: ""; + position: absolute; + inset: 0; + z-index: 5; + border-radius: inherit; + background: var(--overlay-locked); + backdrop-filter: saturate(0.85); +} + +.flow-step.locked > * { + pointer-events: none; +} + +.panel[id], +.hero-card[id] { + scroll-margin-top: 20px; +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.panel h3 { + font-size: 1.22rem; + line-height: 1.3; +} + +.panel-note, +.status-message { + margin: 0; + line-height: 1.55; +} + +.field-stack, +.editor-stack { + display: grid; + gap: 16px; +} + +.form-grid, +.filter-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.document-filter-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.full-field { + grid-column: 1 / -1; +} + +.compact-field { + max-width: 420px; +} + +.recipient-box { + display: grid; + gap: 14px; + padding: 16px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--surface-muted); +} + +.recipient-box .form-grid { + align-items: start; +} + +.recipient-box .field select, +#recipient-email-input { + height: 48px; + min-height: 48px; +} + +.field { + display: grid; + gap: 8px; +} + +.field span { + font-weight: 700; + line-height: 1.25; +} + +.field input, +.field select, +.field textarea { + width: 100%; + min-height: 46px; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + resize: vertical; +} + +.field input[readonly], +.field textarea[readonly], +.field input:disabled, +.field textarea:disabled { + background: var(--surface-muted); + color: var(--muted); + cursor: default; + opacity: 1; + -webkit-text-fill-color: var(--muted); +} + +.editable-field input:disabled, +.editable-field textarea:disabled { + border-color: #c9e5d3; + background: var(--success-soft); +} + +.document-inspector.confidence-low .editable-field input:disabled, +.document-inspector.confidence-low .editable-field textarea:disabled { + border-color: #f0bf98; + background: var(--accent-soft); +} + +.locked-field input:disabled, +.locked-field textarea:disabled { + border-color: var(--border-soft); + background: var(--surface-soft); +} + +.document-inspector.is-editing .editable-field input, +.document-inspector.is-editing .editable-field textarea { + background: var(--surface); + color: var(--text); + -webkit-text-fill-color: var(--text); +} + +.document-inspector.is-editing.confidence-low .editable-field input, +.document-inspector.is-editing.confidence-low .editable-field textarea, +.document-inspector.is-editing.confidence-high .editable-field input, +.document-inspector.is-editing.confidence-high .editable-field textarea { + border-color: var(--primary); +} + +.field textarea { + min-height: 116px; +} + +#recipient-email-input { + resize: none; +} + +.field.has-manual-correction input, +.field.has-manual-correction textarea { + border-color: #f0bf98; + box-shadow: inset 3px 0 0 #d99a3f; +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + border-color: var(--primary); + outline: 3px solid rgba(111, 166, 207, 0.22); +} + +.button-row, +.meta-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.rating-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; +} + +.primary-button, +.secondary-button, +.rating-button, +.text-button, +.favorite-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + border-radius: var(--radius); + font-weight: 700; + transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.primary-button, +.secondary-button { + padding: 10px 14px; +} + +.primary-button { + background: var(--primary); + color: #ffffff; +} + +.secondary-button, +.rating-button { + background: var(--surface-soft); + color: var(--text); +} + +.text-button { + min-height: 38px; + padding: 8px 10px; + background: transparent; + color: var(--primary-deep); + box-shadow: none; +} + +.favorite-button { + width: 38px; + min-height: 38px; + padding: 0; + background: transparent; + color: var(--muted); + box-shadow: none; + font-size: 1.28rem; + line-height: 1; +} + +.favorite-button.active { + color: var(--accent); +} + +.rating-button { + width: 42px; + padding: 0; + color: var(--muted); + font-size: 1.38rem; +} + +.rating-button.active { + color: #ffffff; +} + +.rating-button.rating-low { + background: #d95e4f; +} + +.rating-button.rating-mid { + background: #d99a3f; +} + +.rating-button.rating-high { + background: #3b9568; +} + +.primary-button:hover, +.secondary-button:hover, +.rating-button:hover, +.primary-button:focus-visible, +.secondary-button:focus-visible, +.rating-button:focus-visible { + box-shadow: 0 8px 18px rgba(47, 103, 143, 0.14); + outline: none; + transform: translateY(-1px); +} + +.text-button:hover, +.text-button:focus-visible, +.favorite-button:hover, +.favorite-button:focus-visible { + background: rgba(47, 103, 143, 0.08); + box-shadow: none; + outline: none; + text-decoration: none; + transform: none; +} + +.primary-button:disabled:hover, +.secondary-button:disabled:hover, +.rating-button:disabled:hover { + box-shadow: none; + transform: none; +} + +.status-list, +.compact-list, +.analysis-list, +.flow-list, +.log-list, +.history-list { + margin: 0; + padding: 0; + list-style: none; +} + +.status-list, +.compact-list, +.analysis-list, +.flow-list, +.log-list, +.history-list { + display: grid; + gap: 10px; +} + +.status-list { + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.status-list li, +.compact-list li, +.analysis-list li, +.flow-list li, +.log-list li, +.history-item { + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--surface-muted); +} + +.status-list li { + display: grid; + gap: 6px; + padding: 14px; +} + +.status-list strong { + font-size: 1.28rem; + color: var(--primary-deep); +} + +.compact-list li, +.analysis-list li, +.log-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 12px 14px; +} + +.compact-list strong, +.analysis-list strong, +.log-list strong { + line-height: 1.35; +} + +.compact-list span, +.analysis-list span, +.log-list span { + text-align: right; +} + +.flow-list li { + display: grid; + gap: 6px; + padding: 14px; +} + +.flow-list strong { + font-size: 1.02rem; +} + +.history-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px; +} + +.history-main { + display: grid; + gap: 4px; +} + +.history-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +.usage-feedback { + display: grid; + gap: 10px; +} + +.metrics-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.metric-block { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--surface-muted); +} + +.metric-block .compact-list li { + padding: 8px 0; + border: 0; + border-bottom: 1px solid var(--border-soft); + border-radius: 0; + background: transparent; +} + +.metric-block .compact-list li:last-child { + border-bottom: 0; +} + +.document-history { + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.document-table { + overflow: hidden; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--surface); +} + +.document-table .document-history { + display: grid; + gap: 0; +} + +.document-row { + position: relative; + display: grid; + grid-template-columns: minmax(150px, 1.35fr) minmax(120px, 1fr) minmax(150px, 1.25fr) minmax(92px, 0.7fr) minmax(72px, 0.55fr) minmax(108px, 0.75fr) minmax(94px, 0.65fr); + align-items: center; + gap: 12px; + padding: 12px 14px 12px 18px; + border-bottom: 1px solid var(--border-soft); +} + +.document-table .document-history .document-row { + min-height: 72px; +} + +.document-row::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: transparent; +} + +.document-row:last-child { + border-bottom: 0; +} + +.document-row-head { + background: var(--surface-soft); + color: var(--muted); + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; +} + +.document-cell-main { + display: grid; + gap: 3px; +} + +.document-cell-main small, +.document-cell-file { + color: var(--muted); + font-size: 0.88rem; +} + +.document-row.needs-review::before { + background: #d99a3f; +} + +.document-row.confirmed::before { + background: #6bb58a; +} + +.document-row.sent::before { + background: var(--border-strong); +} + +.document-row-placeholder { + pointer-events: none; +} + +.document-row-placeholder > * { + visibility: hidden; +} + +.document-row-placeholder::before { + display: none; +} + +.filter-action-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 0.92rem; + font-weight: 700; +} + +.pagination-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-weight: 700; +} + +.compact-kpi-list { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.history-main strong { + line-height: 1.3; +} + +.document-item { + align-items: flex-start; +} + +.document-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin-top: 6px; + color: var(--muted); + font-size: 0.9rem; +} + +.recipient-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + margin-top: 6px; + color: var(--muted); + font-size: 0.9rem; + font-weight: 700; +} + +.document-status { + flex: 0 0 auto; + min-width: 108px; + padding: 7px 10px; + border: 1px solid #f0bf98; + border-radius: var(--radius); + background: var(--warning-soft); + color: var(--warning-text); + font-size: 0.86rem; + font-weight: 800; + text-align: center; +} + +.document-status.confirmed { + border-color: #c9e5d3; + background: var(--success-soft); + color: var(--success-text); +} + +.document-status.sent { + border-color: var(--border); + background: var(--surface-soft); + color: var(--primary-deep); +} + +.panel.is-hidden, +.nav-subitem.is-hidden, +.send-draft.is-hidden { + display: none; +} + +.document-detail-grid { + display: grid; + grid-template-columns: minmax(240px, 0.9fr) minmax(0, 1.45fr); + gap: 14px; +} + +.document-preview, +.extracted-card { + display: grid; + align-content: start; + gap: 10px; + padding: 16px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--surface-muted); +} + +.document-preview { + min-height: 180px; +} + +.document-preview strong { + line-height: 1.35; +} + +.document-preview span { + color: var(--muted); + line-height: 1.45; +} + +.preview-mode-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.preview-mode-row .text-button { + border: 1px solid transparent; +} + +.preview-mode-row .text-button.active { + border-color: var(--border); + background: var(--surface-soft); + color: var(--primary-deep); +} + +.history-item.needs-review { + border-color: #f0bf98; + background: var(--warning-soft); +} + +.history-item.confirmed { + border-color: #c9e5d3; + background: var(--success-soft); +} + +.history-item.sent { + border-color: var(--border); + background: var(--surface-soft); +} + +.history-item.selected { + border-color: var(--primary); + box-shadow: inset 4px 0 0 var(--primary); +} + +.history-actions .text-button.active { + background: var(--surface-soft); + color: var(--primary-deep); +} + +.cover-placeholder, +.upload-box { + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + background: var(--surface); +} + +.cover-placeholder { + display: grid; + place-items: center; + gap: 10px; + min-height: 120px; + padding: 18px; + color: var(--muted); + text-align: center; + background-position: center; + background-size: cover; +} + +.cover-placeholder.has-cover { + min-height: 180px; + color: #ffffff; +} + +.cover-placeholder.has-cover span, +.cover-placeholder.has-cover .text-button { + border-radius: var(--radius); + background: rgba(15, 23, 32, 0.7); + color: #ffffff; +} + +.cover-placeholder.has-cover span { + padding: 8px 10px; +} + +.upload-box { + width: 100%; + display: grid; + gap: 8px; + padding: 22px; + color: var(--text); + text-align: left; +} + +.upload-box span { + color: var(--muted); +} + +.upload-box:hover, +.upload-box:focus-visible { + border-color: var(--primary); + outline: 3px solid rgba(111, 166, 207, 0.18); +} + +.upload-box.processing { + border-style: solid; + background: var(--surface-soft); +} + +.preview-sheet { + display: grid; + gap: 8px; + padding: 16px; +} + +.send-draft { + display: grid; + gap: 16px; + padding: 16px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--surface-muted); +} + +.preview-sheet { + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--surface-muted); +} + +.preview-sheet .eyebrow { + margin-bottom: 0; +} + +.preview-snippet { + margin: 0; + line-height: 1.55; +} + +.document-preview-lines { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.document-preview-lines span { + display: block; + padding: 9px 10px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: var(--surface); + color: var(--muted); + font-size: 0.9rem; +} + +.document-preview-lines.pdf-preview-mode { + margin-top: 10px; +} + +.pdf-placeholder-frame { + min-height: 420px; + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: + linear-gradient(var(--surface), var(--surface)) padding-box, + repeating-linear-gradient(135deg, var(--border-soft) 0 8px, transparent 8px 16px) border-box; +} + +.pdf-placeholder-chrome { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-soft); + background: var(--surface-soft); +} + +.pdf-placeholder-chrome strong { + font-size: 0.92rem; +} + +.pdf-placeholder-chrome span { + font-size: 0.82rem; + font-weight: 800; +} + +.pdf-placeholder-body { + display: grid; + place-items: center; + min-height: 360px; + padding: 22px; + color: var(--muted); + text-align: center; + line-height: 1.55; +} + +.document-inspector { + gap: 14px; +} + +.inspector-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.field-legend { + display: inline-flex; + flex-wrap: wrap; + gap: 8px 12px; + color: var(--muted); + font-size: 0.82rem; + font-weight: 800; +} + +.field-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 999px; + border: 1px solid var(--border); +} + +.editable-dot { + background: var(--success-soft); + border-color: #c9e5d3; +} + +.document-inspector.confidence-low .editable-dot { + background: var(--accent-soft); + border-color: #f0bf98; +} + +.locked-dot { + background: var(--surface-soft); +} + +.inspector-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.inspector-tabs .text-button { + border: 1px solid transparent; +} + +.inspector-tabs .text-button.active { + border-color: var(--border); + background: var(--surface-soft); + color: var(--primary-deep); +} + +.inspector-panel { + display: none; + gap: 12px; +} + +.inspector-panel.active { + display: grid; +} + +.detail-list { + gap: 8px; +} + +.meta-row { + color: var(--muted); + font-size: 0.9rem; +} + +.empty-note, +.inline-note, +.section-label { + margin: 0; + padding: 12px 14px; + border-radius: var(--radius); + font-weight: 700; + line-height: 1.45; +} + +.empty-note { + background: var(--warning-soft); + color: var(--warning-text); +} + +.inline-note { + background: var(--success-soft); + color: var(--success-text); +} + +.section-label { + padding: 0; + color: var(--text); +} + +.hidden { + display: none !important; +} + +@media (max-width: 1100px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + height: auto; + overflow: visible; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .workspace { + max-width: none; + } + + .theme-toggle { + right: var(--workspace-pad); + } + + .session-actions { + right: var(--workspace-pad); + } + + .back-to-top { + right: var(--workspace-pad); + } +} + +@media (max-width: 760px) { + :root { + --workspace-pad: 16px; + } + + .sidebar, + .workspace { + padding: 16px; + } + + .brand-logo { + width: 128px; + } + + .hero-card, + .panel { + padding: 18px; + } + + .hero-card h3 { + font-size: 1.42rem; + } + + .topbar h2 { + font-size: 1.35rem; + } + + .form-grid, + .filter-row, + .status-list, + .document-filter-grid, + .compact-kpi-list, + .metrics-detail-grid, + .document-detail-grid { + grid-template-columns: 1fr; + } + + .document-table { + overflow-x: auto; + } + + .document-row { + min-width: 820px; + } + + .pagination-row, + .filter-action-row, + .inspector-heading { + align-items: flex-start; + flex-direction: column; + } + + .compact-list li, + .analysis-list li, + .log-list li, + .history-item { + align-items: flex-start; + flex-direction: column; + } + + .history-actions { + width: 100%; + justify-content: flex-end; + } + + .compact-list span, + .analysis-list span, + .log-list span { + text-align: left; + } +} diff --git a/resources/views/auth/user-login.blade.php b/resources/views/auth/user-login.blade.php new file mode 100644 index 0000000..5c28406 --- /dev/null +++ b/resources/views/auth/user-login.blade.php @@ -0,0 +1,110 @@ + + + + + + NEXUM | Accesso utenti + + + + +
+ +
+ + diff --git a/resources/views/filament/pages/dashboard.blade.php b/resources/views/filament/pages/dashboard.blade.php index 56dade0..5679949 100644 --- a/resources/views/filament/pages/dashboard.blade.php +++ b/resources/views/filament/pages/dashboard.blade.php @@ -1,140 +1,26 @@ -@php - $assistantMetrics = $this->getAssistantMetrics(); - $copilotMetrics = $this->getCopilotMetrics(); - $recentCommunications = $this->getRecentCommunications(); - $recentDocuments = $this->getRecentDocuments(); -@endphp - -
-
-
-

Console operativa

-

Gestione assistita di comunicazioni interne e documenti del personale.

-

- NEXUM supporta redattori HR e operatori CdL nella preparazione dei contenuti, - classificazione documentale, verifica degli esiti e tracciamento delle consegne. -

-
- - -
- +
-

Moduli

-

Da dove partire

+

Amministrazione

+

Gestione credenziali

-
- -
-
-
-
-

AI Assistant

-

Stato contenuti

-
-
- -
- @foreach ($assistantMetrics as $metric) -
- {{ $metric['value'] }} - {{ $metric['label'] }} -
- @endforeach -
-
- -
-
-
-

Co-Pilot CdL

-

Stato documenti

-
-
- -
- @foreach ($copilotMetrics as $metric) -
- {{ $metric['value'] }} - {{ $metric['label'] }} -
- @endforeach -
-
-
- -
-
-
-
-

Storico prompt

-

Ultime generazioni

-
-
- -
- @forelse ($recentCommunications as $item) -
- {{ $item['title'] }} - {{ $item['meta'] }} -
- @empty -
- Nessuna bozza generata - Usa il modulo AI Assistant per creare il primo contenuto. -
- @endforelse -
-
- -
-
-
-

Storico documenti

-

Ultime analisi

-
-
- -
- @forelse ($recentDocuments as $item) -
- {{ $item['title'] }} - {{ $item['meta'] }} -
- @empty -
- Nessun documento caricato - Carica un PDF dal modulo Co-Pilot per avviare la demo documentale. -
- @endforelse -
-
-
diff --git a/resources/views/poc/app.blade.php b/resources/views/poc/app.blade.php new file mode 100644 index 0000000..2594898 --- /dev/null +++ b/resources/views/poc/app.blade.php @@ -0,0 +1,709 @@ + + + + + + NEXUM | Eggon Console + + + +
+ + +
+
+
+

NEXUM

+

Overview operativa

+
+
+ + + +
+
+ +
+
+
+
+

Console operativa

+

Gestione assistita di comunicazioni interne e documenti del personale.

+

+ NEXUM supporta redattori HR e operatori CdL nelle attività quotidiane: preparazione dei contenuti, + classificazione documentale, verifica degli esiti e tracciamento delle consegne. +

+
+
+ + +
+
+ +
+
+
+

Moduli

+

Da dove partire

+
+
+ +
    +
  • + AI Assistant Generativo + Scrivi il prompt, scegli pubblico, tono e canale. Il sistema propone una bozza da revisionare e inviare. +
  • +
  • + AI Co-Pilot per CdL + Carica un documento o un lotto. OCR e classificazione rilevano metadati, destinatari e split; l'operatore verifica solo le anomalie. +
  • +
  • + Metriche operative + Monitora utilizzo, qualità e performance direttamente nelle pagine dei due strumenti. +
  • +
+
+ +
+
+
+

Oggi

+

Priorità essenziali

+
+
+
    +
  • 0Bozze da rivedere
  • +
  • 0Invii con anomalie
  • +
  • 0Invii pronti
  • +
+
+
+ +
+
+
+
+

AI Assistant

+

Crea una bozza

+
+
+ +

+ Inserisci cosa comunicare e pochi parametri editoriali. La bozza resta modificabile prima di salvarla o inviarla. +

+ +
+ + +
+ + + + + + + +
+
+ +
+ + +
+ +

La bozza comparirà nella revisione.

+
+ +
+
+
+

Revisione

+

Controlla il contenuto

+
+
+ +
+
+ Cover generata per il canale scelto + + +
+ + + + + +
+ 124 caratteri + 1 min lettura + Creato da AI Assistant + Bozza non generata +
+ +
+
+
+

Destinatari

+

Categoria e persone specifiche

+
+
+ +
+ + + +
+ +

+ Puoi inviare a una categoria, a una o più persone, oppure a entrambi. +

+
+ + + +
+ + + + + +
+
+
+ +
+
+
+

Qualità

+

Valuta la bozza

+
+
+ +
+ + + + + +
+ + + +
+ +
+ +
+ +
+
+
+

Storico prompt

+

Trova e riusa un prompt

+
+
+ +
+ + +
+ +
    + +

    Nessun risultato trovato.

    +
    + +
    +
    +
    +

    Metriche

    +

    Metriche AI Assistant

    +
    +
    + +
    + + +
    + +
      +
    • 0Contenuti generati
    • +
    • 0Bozze da rivedere
    • +
    • 0Feedback raccolti
    • +
    • n/dRating medio
    • +
    + +
    +
    + +
      +
    • 0Contenuti disponibili
    • +
    +
    + +
    + +
      +
    • n/dFeedback non disponibili
    • +
    +
    +
    + +
    + +
      +
    • n/dNessun feedback registrato
    • +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    +
    +

    Co-Pilot CdL

    +

    Carica nel repository

    +
    +
    + +

    + Il modello rileva automaticamente i campi del documento dopo il caricamento. L'utente può correggere manualmente i soli campi modificabili nella verifica. +

    + + + + +
      +
    • StatoIn attesa di caricamento
    • +
    • Controlli automaticiFormato e duplicati
    • +
    • OutputLe entry di invio compariranno nello storico sottostante.
    • +
    +
    + +
    +
    +
    +

    Storico invii

    +

    Consulta gli invii

    +
    +
    + +

    + Archivio delle entry di invio generate dal modello. Ogni record rappresenta un destinatario e resta filtrabile senza trasformare la pagina in una lista infinita. +

    + +
      +
    • 0Risultati filtrati
    • +
    • 0Da verificare
    • +
    • 0Pronti per invio
    • +
    • 0Inviati
    • +
    + +
    + + + + + + + +
    + +
    + + Filtri predefiniti: tutti gli invii, soglia 80%. +
    + +
    +
    + Destinatario + Documento + File + Data + Conf. + Stato + Azioni +
    +
      +
      + +
      + Pagina 1 di 1 +
      + + +
      +
      + + +
      + + + +
      +
      +
      +

      Metriche Co-Pilot

      +

      Qualità e performance OCR

      +
      +
      + +
      + + +
      + +
        +
      • 0Documenti analizzati
      • +
      • 0Da verificare
      • +
      • 0Pronti per invio
      • +
      • 0Inviati
      • +
      • n/dTempo medio analisi
      • +
      + +
      +
      + +
        +
      • 0Documenti nel periodo
      • +
      • 0Invii già completati
      • +
      • 0Invii non completati
      • +
      +
      + +
      + +
        +
      • 80%Soglia di revisione umana
      • +
      • 0Documenti sotto soglia
      • +
      • n/dTempo medio per documento
      • +
      +
      +
      + +
      + +
      + +
      +
      +
      +
      +
      + + + + + + diff --git a/routes/web.php b/routes/web.php index 86a06c5..f480270 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,44 @@ check() + ? redirect()->route('poc.app') + : redirect()->route('login'); +})->name('home'); + +Route::get('/app', function () { + return view('poc.app'); +})->middleware('auth')->name('poc.app'); + +Route::get('/login', [UserLoginController::class, 'create'])->name('login'); +Route::post('/login', [UserLoginController::class, 'store']) + ->middleware('throttle:5,1') + ->name('user.login.store'); + +Route::middleware(['auth']) + ->prefix('poc') + ->name('poc.') + ->group(function () { + Route::post('/logout', SessionController::class) + ->middleware('throttle:10,1') + ->name('logout'); + + Route::prefix('api') + ->name('api.') + ->middleware('throttle:60,1') + ->group(function () { + Route::get('/session', [AppApiController::class, 'session'])->name('session'); + Route::get('/state', [AppApiController::class, 'state'])->name('state'); + Route::post('/communications', [AppApiController::class, 'generateCommunication']) + ->middleware('throttle:20,1') + ->name('communications.generate'); + Route::post('/documents/ocr', [AppApiController::class, 'runDocumentOcr']) + ->middleware('throttle:20,1') + ->name('documents.ocr'); + }); + }); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8fdc86b..ce99488 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -3,5 +3,5 @@ test('the application returns a successful response', function () { $response = $this->get('/'); - $response->assertStatus(200); + $response->assertRedirect('/login'); }); diff --git a/tests/Feature/PocAppRoutesTest.php b/tests/Feature/PocAppRoutesTest.php new file mode 100644 index 0000000..92804a8 --- /dev/null +++ b/tests/Feature/PocAppRoutesTest.php @@ -0,0 +1,159 @@ +get('/') + ->assertRedirect('/login'); +}); + +test('root redirects authenticated users to the application', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/') + ->assertRedirect(route('poc.app')); +}); + +test('user login authenticates standard users into the application', function () { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]) + ->assertRedirect(route('poc.app')); + + $this->assertAuthenticatedAs($user); +}); + +test('admin users page is available only to admin users', function () { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->get('/admin/users') + ->assertOk() + ->assertSee('Credenziali utenti'); +}); + +test('admin panel keeps its dedicated login route for guests', function () { + $this->get('/admin/users') + ->assertRedirect('/admin/login'); +}); + +test('standard users cannot access the admin users page', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/admin/users') + ->assertForbidden(); +}); + +test('poc api session requires authentication', function () { + $this->getJson('/poc/api/session')->assertUnauthorized(); +}); + +test('authenticated admin session returns profile csrf token and user management link', function () { + $user = User::factory()->admin()->create(['name' => 'Nexum Admin']); + + $this->actingAs($user) + ->getJson('/poc/api/session') + ->assertOk() + ->assertJsonPath('user.email', $user->email) + ->assertJsonPath('user.initials', 'NA') + ->assertJsonPath('user.isAdmin', true) + ->assertJsonStructure(['csrfToken', 'links' => ['users', 'logout']]); +}); + +test('authenticated standard session does not expose the user management link', function () { + $user = User::factory()->create(['name' => 'Nexum User']); + + $this->actingAs($user) + ->getJson('/poc/api/session') + ->assertOk() + ->assertJsonPath('user.email', $user->email) + ->assertJsonPath('user.isAdmin', false) + ->assertJsonPath('links.users', null); +}); + +test('ai assistant generation uses validated parameters and stores a draft', function () { + $this->mock(BedrockService::class, function ($mock) { + $mock->shouldReceive('generateCommunication') + ->once() + ->andReturn(['title' => 'Titolo reale', 'body' => 'Corpo reale']); + }); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->postJson('/poc/api/communications', [ + 'prompt' => 'Comunicazione interna sulla nuova area documentale.', + 'audience' => 'Tutti i dipendenti', + 'tone' => 'Chiaro e diretto', + 'style' => 'Testo informativo', + 'channel' => 'Email interna', + ]) + ->assertCreated() + ->assertJsonPath('communication.title', 'Titolo reale'); + + expect(Communication::query()->count())->toBe(1); + expect(Communication::query()->first()->generated_body)->toBe('Corpo reale'); +}); + +test('document ocr upload stores null extracted fields until ocr is implemented', function () { + Storage::fake('local'); + Http::fake([ + '*' => Http::response([ + 'document_id' => '1', + 'status' => 'placeholder', + 'message' => 'OCR predisposto.', + 'data' => [], + ]), + ]); + + $user = User::factory()->create(); + $file = UploadedFile::fake()->create('cedolino.pdf', 16, 'application/pdf'); + + $this->actingAs($user) + ->postJson('/poc/api/documents/ocr', ['document' => $file]) + ->assertCreated() + ->assertJsonPath('document.employee', null) + ->assertJsonPath('document.confidence', null); + + expect(OriginalDocument::query()->count())->toBe(1); + expect(ExtractedData::query()->first()->employee_first_name)->toBeNull(); + expect(ExtractedData::query()->first()->confidence_score)->toBeNull(); +}); + +test('filament user creation hashes password', function () { + $admin = User::factory()->admin()->create(); + + Livewire::actingAs($admin) + ->test(CreateUser::class) + ->fillForm([ + 'name' => 'Operatore CdL', + 'email' => 'operatore@nexum.local', + 'password' => 'Password12345', + 'password_confirmation' => 'Password12345', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $created = User::query()->where('email', 'operatore@nexum.local')->first(); + + expect($created)->not->toBeNull(); + expect(Hash::check('Password12345', $created->password))->toBeTrue(); +}); From 7abb16f7c4bec5404579de6282abd8fcda045d10 Mon Sep 17 00:00:00 2001 From: Angelica Date: Mon, 11 May 2026 22:29:01 +0200 Subject: [PATCH 03/22] fix aws e metriche --- app/Enums/CommunicationStatus.php | 3 + app/Filament/Pages/Dashboard.php | 6 +- .../Pages/ViewCommunication.php | 9 +++ .../Resources/OriginalDocumentResource.php | 11 +--- .../Pages/ViewSubDocument.php | 19 +++--- app/Services/BedrockService.php | 29 ++++++++- app/Services/DocumentProcessingService.php | 11 +++- config/services.php | 1 + database/factories/CommunicationFactory.php | 5 ++ ..._add_approved_to_communications_status.php | 23 +++++++ .../views/filament/pages/dashboard.blade.php | 4 -- tests/Feature/CommunicationApprovalTest.php | 65 +++++++++++++++++++ tests/Feature/DashboardModulesTest.php | 25 +++++++ 13 files changed, 181 insertions(+), 30 deletions(-) create mode 100644 database/migrations/2026_05_11_201751_add_approved_to_communications_status.php create mode 100644 tests/Feature/CommunicationApprovalTest.php create mode 100644 tests/Feature/DashboardModulesTest.php diff --git a/app/Enums/CommunicationStatus.php b/app/Enums/CommunicationStatus.php index 73b2362..020d2a4 100644 --- a/app/Enums/CommunicationStatus.php +++ b/app/Enums/CommunicationStatus.php @@ -5,12 +5,14 @@ enum CommunicationStatus: string { case Draft = 'draft'; + case Approved = 'approved'; case Discarded = 'discarded'; public function label(): string { return match ($this) { self::Draft => 'Bozza', + self::Approved => 'Approvata', self::Discarded => 'Scartata', }; } @@ -19,6 +21,7 @@ public function color(): string { return match ($this) { self::Draft => 'warning', + self::Approved => 'success', self::Discarded => 'gray', }; } diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php index bfb7319..3c260ba 100644 --- a/app/Filament/Pages/Dashboard.php +++ b/app/Filament/Pages/Dashboard.php @@ -31,10 +31,14 @@ public function getAssistantMetrics(): array ->where('status', CommunicationStatus::Draft) ->count(); + $approved = Communication::query() + ->where('status', CommunicationStatus::Approved) + ->count(); + return [ ['value' => $total, 'label' => 'Contenuti generati'], ['value' => $drafts, 'label' => 'Bozze da rivedere'], - ['value' => max(0, $total - $drafts), 'label' => 'Contenuti finalizzati'], + ['value' => $approved, 'label' => 'Contenuti finalizzati'], ]; } diff --git a/app/Filament/Resources/CommunicationResource/Pages/ViewCommunication.php b/app/Filament/Resources/CommunicationResource/Pages/ViewCommunication.php index 72b9ae2..10dda39 100644 --- a/app/Filament/Resources/CommunicationResource/Pages/ViewCommunication.php +++ b/app/Filament/Resources/CommunicationResource/Pages/ViewCommunication.php @@ -14,6 +14,15 @@ class ViewCommunication extends ViewRecord protected function getHeaderActions(): array { return [ + Actions\Action::make('approve') + ->label('Approva') + ->color('success') + ->icon('heroicon-o-check-circle') + ->visible(fn () => $this->record->status === CommunicationStatus::Draft) + ->action(function () { + $this->record->update(['status' => CommunicationStatus::Approved]); + $this->refreshFormData(['status']); + }), Actions\Action::make('discard') ->label('Scarta') ->color('danger') diff --git a/app/Filament/Resources/OriginalDocumentResource.php b/app/Filament/Resources/OriginalDocumentResource.php index af7e910..fd6f1af 100644 --- a/app/Filament/Resources/OriginalDocumentResource.php +++ b/app/Filament/Resources/OriginalDocumentResource.php @@ -14,8 +14,6 @@ use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Storage; class OriginalDocumentResource extends Resource { @@ -93,14 +91,7 @@ public static function table(Table $table): Table ]) ->action(function (array $data) { $service = app(DocumentProcessingService::class); - $file = new UploadedFile( - Storage::disk('local')->path($data['pdf_file']), - basename($data['pdf_file']), - 'application/pdf', - null, - true, - ); - $service->handleUpload($file); + $service->handleStoredFile($data['pdf_file'], basename($data['pdf_file'])); }), ]) ->filters([ diff --git a/app/Filament/Resources/OriginalDocumentResource/Pages/ViewSubDocument.php b/app/Filament/Resources/OriginalDocumentResource/Pages/ViewSubDocument.php index d01f929..b449ff9 100644 --- a/app/Filament/Resources/OriginalDocumentResource/Pages/ViewSubDocument.php +++ b/app/Filament/Resources/OriginalDocumentResource/Pages/ViewSubDocument.php @@ -48,36 +48,35 @@ protected function getHeaderActions(): array public function infolist(Infolist $infolist): Infolist { return $infolist - ->record($this->subDocument->extractedData) + ->record($this->subDocument) ->schema([ Infolists\Components\Grid::make(2) ->schema([ Infolists\Components\Section::make('Dati estratti') ->schema([ - Infolists\Components\TextEntry::make('employee_first_name') + Infolists\Components\TextEntry::make('extractedData.employee_first_name') ->label('Nome'), - Infolists\Components\TextEntry::make('employee_last_name') + Infolists\Components\TextEntry::make('extractedData.employee_last_name') ->label('Cognome'), - Infolists\Components\TextEntry::make('company_name') + Infolists\Components\TextEntry::make('extractedData.company_name') ->label('Azienda'), - Infolists\Components\TextEntry::make('document_date') + Infolists\Components\TextEntry::make('extractedData.document_date') ->label('Data documento') ->date('d/m/Y'), - Infolists\Components\TextEntry::make('document_type') + Infolists\Components\TextEntry::make('extractedData.document_type') ->label('Tipo documento'), - Infolists\Components\TextEntry::make('confidence_score') + Infolists\Components\TextEntry::make('extractedData.confidence_score') ->label('Confidenza') ->suffix('%'), - Infolists\Components\TextEntry::make('description') + Infolists\Components\TextEntry::make('extractedData.description') ->label('Descrizione') ->columnSpanFull(), ]) ->columns(2), Infolists\Components\Section::make('Documento') ->schema([ - Infolists\Components\TextEntry::make('subDocument.send_status') + Infolists\Components\TextEntry::make('send_status') ->label('Stato invio') - ->state(fn () => $this->subDocument->send_status) ->badge() ->color(fn (SendStatus $state): string => $state->color()), Infolists\Components\TextEntry::make('pages') diff --git a/app/Services/BedrockService.php b/app/Services/BedrockService.php index c1ce9b0..9cdca7b 100644 --- a/app/Services/BedrockService.php +++ b/app/Services/BedrockService.php @@ -5,6 +5,7 @@ use Aws\BedrockRuntime\BedrockRuntimeClient; use Aws\Exception\AwsException; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; class BedrockService { @@ -81,7 +82,11 @@ public function splitDocument(string $pdfPath): array $this->ensureConfigured(); - $pdfContent = base64_encode(file_get_contents(storage_path('app/'.$pdfPath))); + $pdfContent = Storage::disk('local')->get($pdfPath); + + if ($pdfContent === null) { + throw new \RuntimeException("File non trovato sul disco: {$pdfPath}"); + } $prompt = "Analizza questo PDF di cedolini aziendali. Identifica tutti i dipendenti presenti.\nPer ogni dipendente restituisci un array JSON con: employee_name (stringa), start_page (intero, 1-indexed), end_page (intero, 1-indexed).\nRispondi SOLO con JSON valido (array). Se non ci sono dipendenti distinti, restituisci un array vuoto."; @@ -107,6 +112,13 @@ public function splitDocument(string $pdfPath): array ]); $text = $result['output']['message']['content'][0]['text'] ?? '[]'; + + if (preg_match('/```(?:json)?\s*(\[.*?\])\s*```/s', $text, $matches)) { + $text = $matches[1]; + } elseif (preg_match('/(\[.*\])/s', $text, $matches)) { + $text = $matches[1]; + } + $decoded = json_decode($text, true); if (! is_array($decoded)) { @@ -137,13 +149,17 @@ public function extractFields(string $subPdfPath): array 'document_date' => now()->toDateString(), 'document_type' => 'Cedolino', 'description' => 'Dati estratti in modalita PoC.', - 'confidence_score' => (int) env('POC_CONFIDENCE_THRESHOLD', 80), + 'confidence_score' => (int) config('services.bedrock.poc_confidence_threshold', 80), ]; } $this->ensureConfigured(); - $pdfContent = base64_encode(file_get_contents(storage_path('app/'.$subPdfPath))); + $pdfContent = Storage::disk('local')->get($subPdfPath); + + if ($pdfContent === null) { + throw new \RuntimeException("File non trovato sul disco: {$subPdfPath}"); + } $prompt = "Estrai i seguenti campi da questo cedolino PDF.\nRispondi SOLO con JSON valido con le chiavi: employee_first_name, employee_last_name, company_name, document_date (formato YYYY-MM-DD), document_type, description (max 200 caratteri), confidence_score (intero 0-100 che indica la tua confidenza nell'estrazione).\nUsa null per i campi non trovati."; @@ -169,6 +185,13 @@ public function extractFields(string $subPdfPath): array ]); $text = $result['output']['message']['content'][0]['text'] ?? '{}'; + + if (preg_match('/```(?:json)?\s*(\{.*?\})\s*```/s', $text, $matches)) { + $text = $matches[1]; + } elseif (preg_match('/(\{.*\})/s', $text, $matches)) { + $text = $matches[1]; + } + $decoded = json_decode($text, true); if (! is_array($decoded)) { diff --git a/app/Services/DocumentProcessingService.php b/app/Services/DocumentProcessingService.php index bf0ef32..2edf3fe 100644 --- a/app/Services/DocumentProcessingService.php +++ b/app/Services/DocumentProcessingService.php @@ -20,9 +20,16 @@ public function __construct(private readonly BedrockService $bedrock) {} */ public function handleUpload(UploadedFile $file): OriginalDocument { - $filename = $file->getClientOriginalName(); $path = $file->store('documents/originals', 'local'); + return $this->handleStoredFile($path, $file->getClientOriginalName()); + } + + /** + * Create an OriginalDocument from a file that is already stored on the local disk. + */ + public function handleStoredFile(string $path, string $filename): OriginalDocument + { $original = OriginalDocument::create([ 'file_path' => $path, 'original_filename' => $filename, @@ -105,7 +112,7 @@ private function extractPages(string $sourcePath, int $originalId, string $emplo } $slug = preg_replace('/[^a-z0-9_]/i', '_', $employeeName); - $relativePath = "documents/sub/{$originalId}_{$slug}.pdf"; + $relativePath = "documents/sub/{$originalId}_{$slug}_{$startPage}-{$endPage}.pdf"; $absoluteDest = Storage::disk('local')->path($relativePath); Storage::disk('local')->makeDirectory('documents/sub'); diff --git a/config/services.php b/config/services.php index e6eed8b..b4fb73c 100644 --- a/config/services.php +++ b/config/services.php @@ -41,6 +41,7 @@ 'classifier_model_id' => env('BEDROCK_CLASSIFIER_MODEL_ID'), 'guardrail_id' => env('BEDROCK_GUARDRAIL_ID'), 'region' => env('BEDROCK_AWS_REGION', env('AWS_DEFAULT_REGION', 'eu-central-1')), + 'poc_confidence_threshold' => (int) env('POC_CONFIDENCE_THRESHOLD', 80), ], 'textract' => [ diff --git a/database/factories/CommunicationFactory.php b/database/factories/CommunicationFactory.php index 4451882..de65436 100644 --- a/database/factories/CommunicationFactory.php +++ b/database/factories/CommunicationFactory.php @@ -28,6 +28,11 @@ public function draft(): static return $this->state(['status' => CommunicationStatus::Draft]); } + public function approved(): static + { + return $this->state(['status' => CommunicationStatus::Approved]); + } + public function discarded(): static { return $this->state(['status' => CommunicationStatus::Discarded]); diff --git a/database/migrations/2026_05_11_201751_add_approved_to_communications_status.php b/database/migrations/2026_05_11_201751_add_approved_to_communications_status.php new file mode 100644 index 0000000..e89cd5e --- /dev/null +++ b/database/migrations/2026_05_11_201751_add_approved_to_communications_status.php @@ -0,0 +1,23 @@ +AI Co-Pilot per CdL Carica un PDF. La PoC prepara estrazione, split e stato di lavorazione. - - Metriche operative - Consulta storico, qualita percepita e stato dei flussi direttamente dalle liste Filament. -
      diff --git a/tests/Feature/CommunicationApprovalTest.php b/tests/Feature/CommunicationApprovalTest.php new file mode 100644 index 0000000..b1a477f --- /dev/null +++ b/tests/Feature/CommunicationApprovalTest.php @@ -0,0 +1,65 @@ +actingAs(User::factory()->create()); +}); + +test('approved is a valid communication status', function () { + $communication = Communication::factory()->draft()->create(); + + $communication->update(['status' => CommunicationStatus::Approved]); + + expect($communication->fresh()->status)->toBe(CommunicationStatus::Approved); +}); + +test('approved status has correct label and color', function () { + expect(CommunicationStatus::Approved->label())->toBe('Approvata') + ->and(CommunicationStatus::Approved->color())->toBe('success'); +}); + +test('approve action transitions communication from draft to approved', function () { + $communication = Communication::factory()->draft()->create(); + + Livewire::test(ViewCommunication::class, ['record' => $communication->getRouteKey()]) + ->callAction('approve') + ->assertHasNoActionErrors(); + + expect($communication->fresh()->status)->toBe(CommunicationStatus::Approved); +}); + +test('approve action is not visible for approved communication', function () { + $communication = Communication::factory()->approved()->create(); + + Livewire::test(ViewCommunication::class, ['record' => $communication->getRouteKey()]) + ->assertActionHidden('approve'); +}); + +test('approve action is not visible for discarded communication', function () { + $communication = Communication::factory()->discarded()->create(); + + Livewire::test(ViewCommunication::class, ['record' => $communication->getRouteKey()]) + ->assertActionHidden('approve'); +}); + +test('dashboard counts only approved communications as finalizzati', function () { + Communication::factory()->draft()->count(3)->create(); + Communication::factory()->approved()->count(2)->create(); + Communication::factory()->discarded()->create(); + + $dashboard = app(Dashboard::class); + $metrics = $dashboard->getAssistantMetrics(); + + $finalizzati = collect($metrics)->firstWhere('label', 'Contenuti finalizzati'); + + expect($finalizzati['value'])->toBe(2); +}); diff --git a/tests/Feature/DashboardModulesTest.php b/tests/Feature/DashboardModulesTest.php new file mode 100644 index 0000000..43addd9 --- /dev/null +++ b/tests/Feature/DashboardModulesTest.php @@ -0,0 +1,25 @@ +user = User::factory()->create(); + $this->actingAs($this->user); +}); + +test('dashboard modules section shows exactly two modules', function () { + Livewire::test(Dashboard::class) + ->assertSee('AI Assistant Generativo') + ->assertSee('AI Co-Pilot per CdL') + ->assertDontSee('Metriche operative'); +}); + +test('dashboard modules section does not contain metriche operative', function () { + Livewire::test(Dashboard::class) + ->assertDontSee('Metriche operative'); +}); From d687501e73083e5e1a2b71c69fd8b8ab7cd46b43 Mon Sep 17 00:00:00 2001 From: subnetMusk Date: Tue, 12 May 2026 12:04:24 +0200 Subject: [PATCH 04/22] Allinea la PoC pubblica ai flussi principali e aggiorna la documentazione --- .env.example | 4 - README.md | 157 +- app/Filament/Pages/Dashboard.php | 87 - .../Resources/CommunicationResource.php | 152 -- .../Pages/CreateCommunication.php | 30 - .../Pages/ListCommunications.php | 19 - .../Pages/ViewCommunication.php | 40 - .../Resources/OriginalDocumentResource.php | 127 - .../Pages/ListOriginalDocuments.php | 16 - .../Pages/ViewOriginalDocument.php | 16 - .../Pages/ViewSubDocument.php | 89 - .../SubDocumentsRelationManager.php | 51 - app/Filament/Resources/UserResource.php | 113 - .../UserResource/Pages/CreateUser.php | 11 - .../Resources/UserResource/Pages/EditUser.php | 20 - .../UserResource/Pages/ListUsers.php | 20 - .../Controllers/Auth/UserLoginController.php | 43 - app/Http/Controllers/Poc/AppApiController.php | 161 +- .../Controllers/Poc/SessionController.php | 21 - app/Models/User.php | 12 +- app/Providers/Filament/AdminPanelProvider.php | 91 - app/Services/DocumentProcessingService.php | 59 +- bootstrap/app.php | 23 +- bootstrap/providers.php | 1 - database/factories/UserFactory.php | 7 - ..._11_200000_add_is_admin_to_users_table.php | 10 +- database/seeders/DatabaseSeeder.php | 9 +- docker-compose.yml | 4 - docker/php/entrypoint.sh | 45 - public/css/nexum-filament.css | 439 ---- public/nexum-app/app.js | 2142 ++--------------- public/nexum-app/styles.css | 20 + resources/views/auth/user-login.blade.php | 110 - .../views/filament/pages/dashboard.blade.php | 26 - .../pages/view-sub-document.blade.php | 3 - resources/views/poc/app.blade.php | 558 +---- routes/web.php | 28 +- tests/Feature/CommunicationApprovalTest.php | 45 - tests/Feature/DashboardModulesTest.php | 25 - tests/Feature/DocumentExtractionTest.php | 14 - tests/Feature/ExampleTest.php | 2 +- tests/Feature/PocAppRoutesTest.php | 182 +- 42 files changed, 595 insertions(+), 4437 deletions(-) delete mode 100644 app/Filament/Pages/Dashboard.php delete mode 100644 app/Filament/Resources/CommunicationResource.php delete mode 100644 app/Filament/Resources/CommunicationResource/Pages/CreateCommunication.php delete mode 100644 app/Filament/Resources/CommunicationResource/Pages/ListCommunications.php delete mode 100644 app/Filament/Resources/CommunicationResource/Pages/ViewCommunication.php delete mode 100644 app/Filament/Resources/OriginalDocumentResource.php delete mode 100644 app/Filament/Resources/OriginalDocumentResource/Pages/ListOriginalDocuments.php delete mode 100644 app/Filament/Resources/OriginalDocumentResource/Pages/ViewOriginalDocument.php delete mode 100644 app/Filament/Resources/OriginalDocumentResource/Pages/ViewSubDocument.php delete mode 100644 app/Filament/Resources/OriginalDocumentResource/RelationManagers/SubDocumentsRelationManager.php delete mode 100644 app/Filament/Resources/UserResource.php delete mode 100644 app/Filament/Resources/UserResource/Pages/CreateUser.php delete mode 100644 app/Filament/Resources/UserResource/Pages/EditUser.php delete mode 100644 app/Filament/Resources/UserResource/Pages/ListUsers.php delete mode 100644 app/Http/Controllers/Auth/UserLoginController.php delete mode 100644 app/Http/Controllers/Poc/SessionController.php delete mode 100644 app/Providers/Filament/AdminPanelProvider.php delete mode 100644 public/css/nexum-filament.css delete mode 100644 resources/views/auth/user-login.blade.php delete mode 100644 resources/views/filament/pages/dashboard.blade.php delete mode 100644 resources/views/filament/resources/original-document-resource/pages/view-sub-document.blade.php delete mode 100644 tests/Feature/DashboardModulesTest.php diff --git a/.env.example b/.env.example index 04b551d..c750937 100644 --- a/.env.example +++ b/.env.example @@ -61,10 +61,6 @@ LARAVEL_AUTOMATED_SETUP=true LARAVEL_WAIT_FOR_SETUP=true LARAVEL_SETUP_RETRIES=30 LARAVEL_SETUP_WAIT_SECONDS=120 -FILAMENT_ADMIN_CREATE=true -FILAMENT_ADMIN_NAME="NEXUM Admin" -FILAMENT_ADMIN_EMAIL=admin@nexum.local -FILAMENT_ADMIN_PASSWORD=Password123! MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin diff --git a/README.md b/README.md index d7dbcda..a4f5f10 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,121 @@ # NEXUM / aLittleByte PoC -Proof of Concept universitaria per i moduli: +Proof of Concept universitaria per sperimentare due funzioni centrali della piattaforma NEXUM/aLittleByte: -- AI Assistant Generativo; -- AI Co-Pilot documentale per Consulenti del Lavoro. +- generazione assistita di contenuti tramite AI Assistant; +- caricamento e analisi iniziale di documenti PDF per il Co-Pilot CdL. -La base applicativa e' Laravel con interfaccia amministrativa Filament. Il progetto resta volutamente leggero: dimostra i flussi principali dell'Analisi dei Requisiti, mentre la rifinitura architetturale e funzionale viene rimandata al PB. +L'applicazione e' pensata per una demo locale semplice: una singola interfaccia web pubblica, senza login, raggiungibile dalla root del progetto avviato in Docker. ## Stack locale -- Laravel 12 -- Filament 3 -- PostgreSQL -- Redis e Laravel Queue -- MinIO come storage S3-compatible locale -- Mailpit per test email -- AI worker FastAPI per OCR/parsing/split placeholder -- Bedrock e Textract configurabili, disabilitati di default +- Laravel 12 come backend applicativo. +- PostgreSQL come database. +- Redis e Laravel Queue per lavorazioni asincrone. +- Nginx e PHP-FPM per servire l'applicativo. +- MinIO come storage S3-compatible locale. +- Mailpit per eventuali test email locali. +- AI worker FastAPI opzionale per OCR/parsing/split documentale. +- Amazon Bedrock e Textract configurabili tramite variabili d'ambiente. -## Perimetro PoC +## Funzioni disponibili -Il perimetro funzionale e le esclusioni sono descritti in [docs/poc-scope.md](docs/poc-scope.md). +### AI Assistant -## Avvio ambiente +La sezione `AI Assistant > Generazione` permette di: -Prima copia l'esempio di configurazione: +- inserire un prompt testuale; +- selezionare tono e stile; +- generare una bozza composta da titolo e testo; +- visualizzare e revisionare localmente il risultato. + +Di default la generazione usa un driver fake deterministico, utile per provare il flusso senza credenziali AWS. Se Bedrock viene configurato, la stessa interfaccia puo' inviare la richiesta al servizio reale. + +### Co-Pilot CdL + +La sezione `Co-Pilot CdL > Caricamento` permette di: + +- caricare un PDF; +- salvare il documento nel sistema; +- avviare una prima elaborazione di split; +- rilevare i campi principali del documento; +- consultare lo storico dei documenti rilevati; +- aprire il dettaglio con campi OCR, anteprima dello split e preview PDF. + +La PoC non implementa ancora una pipeline documentale completa: split robusto, OCR avanzato, matching destinatario e revisione human-in-the-loop sono predisposti come punti di evoluzione. + +## Avvio rapido + +Copia la configurazione di esempio: ```bash cp .env.example .env ``` -Poi avvia i servizi e il bootstrap applicativo: +Avvia lo stack locale: ```bash docker compose up -d --build ``` -Durante l'avvio il container `app` crea `.env` da `.env.example` se manca, installa automaticamente le dipendenze Composer se `vendor/` manca o se cambia `composer.lock`, genera `APP_KEY`, esegue le migrazioni e crea l'utente Filament locale se non esiste. +Al primo avvio il container `app`: + +- installa le dipendenze Composer se necessario; +- genera `APP_KEY` se manca; +- esegue le migrazioni; +- prepara l'applicazione per l'uso locale. -Credenziali Filament locali di default: +Quando i container sono attivi, apri: ```text -Email: admin@nexum.local -Password: Password123! +http://localhost:8080/ ``` -Se vuoi disabilitare o personalizzare il bootstrap automatico: +Servizi di supporto: -```env -COMPOSER_INSTALL_ON_STARTUP=false -LARAVEL_AUTOMATED_SETUP=false -FILAMENT_ADMIN_CREATE=false -FILAMENT_ADMIN_EMAIL=admin@nexum.local -FILAMENT_ADMIN_PASSWORD=Password123! +- MinIO console: `http://localhost:9001` +- Mailpit: `http://localhost:8025` +- AI worker healthcheck: `http://localhost:8001/health` + +## Comandi utili + +Eseguire le migrazioni manualmente: + +```bash +docker compose exec app php artisan migrate ``` -Servizi locali principali: +Lanciare i test: -- Laravel/Nginx: `http://localhost:8080` -- Filament: `http://localhost:8080/admin` -- MinIO console: `http://localhost:9001` -- Mailpit: `http://localhost:8025` -- AI worker: `http://localhost:8001/health` +```bash +docker compose exec app php artisan test +``` -## Modalita AI +Pulire cache Laravel: -Per default `BEDROCK_ENABLED=false`, quindi l'applicazione usa risposte fake deterministiche utili alla demo locale. +```bash +docker compose exec app php artisan optimize:clear +``` + +Rigenerare autoload Composer: + +```bash +docker compose exec app composer dump-autoload +``` + +## Configurazione AI + +La configurazione locale usa valori sicuri per la demo: + +```env +AI_GENERATOR_DRIVER=fake +DOCUMENT_OCR_DRIVER=local +DOCUMENT_CLASSIFIER_DRIVER=fake +BEDROCK_ENABLED=false +TEXTRACT_ENABLED=false +``` -Per provare Bedrock reale: +Per provare Amazon Bedrock reale, abilita il servizio e imposta modello e credenziali tramite `.env` locale o variabili d'ambiente non versionate: ```env BEDROCK_ENABLED=true @@ -77,8 +125,37 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= ``` -Le credenziali reali non devono essere versionate. +Per Textract valgono le stesse cautele: le credenziali AWS e gli ARN reali non devono essere versionati. + +## Storage locale + +Per default lo storage usa MinIO con API S3-compatible: + +```env +FILESYSTEM_DISK=s3 +AWS_ENDPOINT=http://minio:9000 +AWS_BUCKET=nexum-local +AWS_USE_PATH_STYLE_ENDPOINT=true +``` + +Il servizio `minio-init` crea automaticamente il bucket locale configurato da `MINIO_BUCKET`. + +## Note operative + +- L'applicazione corrente funziona senza autenticazione. +- L'interfaccia principale e' servita solo da `/`. +- Le API della PoC sono sotto `/poc/api/*`. +- I task asincroni usano Redis tramite il servizio `queue`. +- Se il browser mostra asset vecchi, ricarica forzatamente la pagina: CSS e JS sono comunque versionati automaticamente in base alla modifica dei file. + +## Limiti intenzionali -## Placeholder intenzionali +Questa PoC dimostra i flussi principali, non una soluzione enterprise completa. Sono volutamente lasciati come evoluzioni successive: -OCR reale, Textract, split documentale robusto, matching destinatario, metriche avanzate e consegna email reale sono predisposti ma non implementati in modo completo. Nella PoC vengono simulati o lasciati come punti di integrazione. +- autenticazione e gestione ruoli; +- workflow completo di approvazione; +- invio email reale; +- OCR/Textract completo; +- split documentale avanzato; +- matching destinatario; +- metriche avanzate e audit trail esteso. diff --git a/app/Filament/Pages/Dashboard.php b/app/Filament/Pages/Dashboard.php deleted file mode 100644 index e460352..0000000 --- a/app/Filament/Pages/Dashboard.php +++ /dev/null @@ -1,87 +0,0 @@ -count(); - $drafts = Communication::query() - ->where('status', CommunicationStatus::Draft) - ->count(); - - $approved = Communication::query() - ->where('status', CommunicationStatus::Approved) - ->count(); - - return [ - ['value' => $total, 'label' => 'Contenuti generati'], - ['value' => $drafts, 'label' => 'Bozze da rivedere'], - ['value' => $approved, 'label' => 'Contenuti finalizzati'], - ]; - } - - public function getCopilotMetrics(): array - { - $documents = OriginalDocument::query()->count(); - $failed = OriginalDocument::query() - ->where('processing_status', ProcessingStatus::Failed) - ->count(); - $pendingDispatch = SubDocument::query() - ->where('send_status', SendStatus::Pending) - ->count(); - - return [ - ['value' => $documents, 'label' => 'Documenti caricati'], - ['value' => $pendingDispatch, 'label' => 'Invii da completare'], - ['value' => $failed, 'label' => 'Anomalie da verificare'], - ]; - } - - public function getRecentCommunications(): array - { - return Communication::query() - ->latest() - ->limit(3) - ->get(['generated_title', 'tone', 'created_at']) - ->map(fn (Communication $communication): array => [ - 'title' => $communication->generated_title ?: 'Bozza senza titolo', - 'meta' => trim(($communication->tone ?: 'Tono non indicato').' - '.$communication->created_at?->format('d/m/Y H:i')), - ]) - ->all(); - } - - public function getRecentDocuments(): array - { - return OriginalDocument::query() - ->latest() - ->limit(3) - ->get(['original_filename', 'processing_status', 'created_at']) - ->map(fn (OriginalDocument $document): array => [ - 'title' => $document->original_filename, - 'meta' => $document->processing_status->label().' - '.$document->created_at?->format('d/m/Y H:i'), - ]) - ->all(); - } -} diff --git a/app/Filament/Resources/CommunicationResource.php b/app/Filament/Resources/CommunicationResource.php deleted file mode 100644 index f8a328e..0000000 --- a/app/Filament/Resources/CommunicationResource.php +++ /dev/null @@ -1,152 +0,0 @@ -schema([ - Forms\Components\Textarea::make('prompt') - ->label('Prompt') - ->helperText('Descrivi il contenuto da generare. La PoC produce una bozza revisionabile.') - ->required() - ->rows(5) - ->columnSpanFull(), - Forms\Components\Select::make('tone') - ->label('Tono') - ->options([ - 'Chiaro e diretto' => 'Chiaro e diretto', - 'Più istituzionale' => 'Più istituzionale', - 'Più sintetico' => 'Più sintetico', - 'Empatico' => 'Empatico', - 'Tecnico' => 'Tecnico', - ]) - ->default('Chiaro e diretto') - ->native(false) - ->required(), - Forms\Components\Select::make('style') - ->label('Stile') - ->options([ - 'Testo informativo' => 'Testo informativo', - 'Avviso operativo' => 'Avviso operativo', - 'Aggiornamento breve' => 'Aggiornamento breve', - 'News portale' => 'News portale', - 'Email interna' => 'Email interna', - ]) - ->default('Testo informativo') - ->native(false) - ->required(), - ]); - } - - public static function infolist(Infolist $infolist): Infolist - { - return $infolist - ->schema([ - Infolists\Components\Section::make('Parametri') - ->schema([ - Infolists\Components\TextEntry::make('prompt') - ->label('Contenuto richiesto') - ->columnSpanFull(), - Infolists\Components\TextEntry::make('tone') - ->label('Tono'), - Infolists\Components\TextEntry::make('style') - ->label('Stile'), - Infolists\Components\TextEntry::make('status') - ->label('Stato') - ->badge() - ->color(fn (CommunicationStatus $state): string => $state->color()), - ]) - ->columns(2), - Infolists\Components\Section::make('Testo generato') - ->schema([ - Infolists\Components\TextEntry::make('generated_title') - ->label('Titolo') - ->columnSpanFull(), - Infolists\Components\TextEntry::make('generated_body') - ->label('Corpo') - ->columnSpanFull(), - ]), - ]); - } - - public static function table(Table $table): Table - { - return $table - ->columns([ - Tables\Columns\TextColumn::make('generated_title') - ->label('Titolo') - ->limit(60) - ->searchable(), - Tables\Columns\TextColumn::make('tone') - ->label('Tono') - ->searchable(), - Tables\Columns\TextColumn::make('style') - ->label('Stile') - ->searchable(), - Tables\Columns\TextColumn::make('status') - ->label('Stato') - ->badge() - ->color(fn (CommunicationStatus $state): string => $state->color()), - Tables\Columns\TextColumn::make('created_at') - ->label('Creata il') - ->dateTime('d/m/Y H:i') - ->sortable(), - ]) - ->defaultSort('created_at', 'desc') - ->filters([ - Tables\Filters\SelectFilter::make('status') - ->label('Stato') - ->options(CommunicationStatus::class), - ]) - ->actions([ - Tables\Actions\ViewAction::make(), - ]) - ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), - ]), - ]); - } - - public static function getRelations(): array - { - return []; - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListCommunications::route('/'), - 'create' => Pages\CreateCommunication::route('/create'), - 'view' => Pages\ViewCommunication::route('/{record}'), - ]; - } -} diff --git a/app/Filament/Resources/CommunicationResource/Pages/CreateCommunication.php b/app/Filament/Resources/CommunicationResource/Pages/CreateCommunication.php deleted file mode 100644 index d5852f6..0000000 --- a/app/Filament/Resources/CommunicationResource/Pages/CreateCommunication.php +++ /dev/null @@ -1,30 +0,0 @@ -generateCommunication($data['prompt'], $data['tone'], $data['style']); - - return static::getModel()::create([ - 'prompt' => $data['prompt'], - 'tone' => $data['tone'], - 'style' => $data['style'], - 'generated_title' => $generated['title'], - 'generated_body' => $generated['body'], - 'status' => CommunicationStatus::Draft, - ]); - } -} diff --git a/app/Filament/Resources/CommunicationResource/Pages/ListCommunications.php b/app/Filament/Resources/CommunicationResource/Pages/ListCommunications.php deleted file mode 100644 index 9f7ae7a..0000000 --- a/app/Filament/Resources/CommunicationResource/Pages/ListCommunications.php +++ /dev/null @@ -1,19 +0,0 @@ -label('Approva') - ->color('success') - ->icon('heroicon-o-check-circle') - ->visible(fn () => $this->record->status === CommunicationStatus::Draft) - ->action(function () { - $this->record->update(['status' => CommunicationStatus::Approved]); - $this->refreshFormData(['status']); - }), - Actions\Action::make('discard') - ->label('Scarta') - ->color('danger') - ->icon('heroicon-o-trash') - ->requiresConfirmation() - ->modalHeading('Scarta comunicazione') - ->modalDescription('Sei sicuro di voler scartare questa comunicazione? L\'azione non può essere annullata.') - ->visible(fn () => $this->record->status === CommunicationStatus::Draft) - ->action(function () { - $this->record->update(['status' => CommunicationStatus::Discarded]); - $this->refreshFormData(['status']); - }), - ]; - } -} diff --git a/app/Filament/Resources/OriginalDocumentResource.php b/app/Filament/Resources/OriginalDocumentResource.php deleted file mode 100644 index fd6f1af..0000000 --- a/app/Filament/Resources/OriginalDocumentResource.php +++ /dev/null @@ -1,127 +0,0 @@ -schema([]); - } - - public static function infolist(Infolist $infolist): Infolist - { - return $infolist - ->schema([ - Infolists\Components\Section::make('Documento originale') - ->schema([ - Infolists\Components\TextEntry::make('original_filename') - ->label('Nome file'), - Infolists\Components\TextEntry::make('processing_status') - ->label('Stato elaborazione') - ->badge() - ->color(fn (ProcessingStatus $state): string => $state->color()), - Infolists\Components\TextEntry::make('created_at') - ->label('Caricato il') - ->dateTime('d/m/Y H:i'), - ]) - ->columns(3), - ]); - } - - public static function table(Table $table): Table - { - return $table - ->columns([ - Tables\Columns\TextColumn::make('original_filename') - ->label('Nome file') - ->searchable(), - Tables\Columns\TextColumn::make('processing_status') - ->label('Stato') - ->badge() - ->color(fn (ProcessingStatus $state): string => $state->color()), - Tables\Columns\TextColumn::make('sub_documents_count') - ->label('Sotto-documenti') - ->counts('subDocuments'), - Tables\Columns\TextColumn::make('created_at') - ->label('Caricato il') - ->dateTime('d/m/Y H:i') - ->sortable(), - ]) - ->defaultSort('created_at', 'desc') - ->headerActions([ - Tables\Actions\Action::make('upload') - ->label('Carica PDF') - ->icon('heroicon-o-arrow-up-tray') - ->modalHeading('Carica documento PDF') - ->form([ - Forms\Components\FileUpload::make('pdf_file') - ->label('File PDF') - ->acceptedFileTypes(['application/pdf']) - ->required() - ->disk('local') - ->directory('documents/originals'), - ]) - ->action(function (array $data) { - $service = app(DocumentProcessingService::class); - $service->handleStoredFile($data['pdf_file'], basename($data['pdf_file'])); - }), - ]) - ->filters([ - Tables\Filters\SelectFilter::make('processing_status') - ->label('Stato') - ->options(ProcessingStatus::class), - ]) - ->actions([ - Tables\Actions\ViewAction::make(), - ]) - ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), - ]), - ]); - } - - public static function getRelations(): array - { - return [ - SubDocumentsRelationManager::class, - ]; - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListOriginalDocuments::route('/'), - 'view' => Pages\ViewOriginalDocument::route('/{record}'), - 'view-sub-document' => Pages\ViewSubDocument::route('/sub-documents/{record}'), - ]; - } -} diff --git a/app/Filament/Resources/OriginalDocumentResource/Pages/ListOriginalDocuments.php b/app/Filament/Resources/OriginalDocumentResource/Pages/ListOriginalDocuments.php deleted file mode 100644 index d6dee07..0000000 --- a/app/Filament/Resources/OriginalDocumentResource/Pages/ListOriginalDocuments.php +++ /dev/null @@ -1,16 +0,0 @@ -subDocument = SubDocument::with(['extractedData', 'originalDocument'])->findOrFail($record); - } - - protected function getHeaderActions(): array - { - return [ - Actions\Action::make('back') - ->label('Torna al documento') - ->icon('heroicon-o-arrow-left') - ->url(fn () => OriginalDocumentResource::getUrl('view', ['record' => $this->subDocument->original_document_id])), - Actions\Action::make('send') - ->label('Invia') - ->icon('heroicon-o-paper-airplane') - ->color('success') - ->requiresConfirmation() - ->modalHeading('Invia documento') - ->modalDescription('Confermi l\'invio di questo sotto-documento al dipendente?') - ->visible(fn () => $this->subDocument->send_status === SendStatus::Pending) - ->action(function () { - $this->subDocument->update(['send_status' => SendStatus::Sent]); - $this->subDocument->refresh(); - }), - ]; - } - - public function infolist(Infolist $infolist): Infolist - { - return $infolist - ->record($this->subDocument) - ->schema([ - Infolists\Components\Grid::make(2) - ->schema([ - Infolists\Components\Section::make('Dati estratti') - ->schema([ - Infolists\Components\TextEntry::make('extractedData.employee_first_name') - ->label('Nome'), - Infolists\Components\TextEntry::make('extractedData.employee_last_name') - ->label('Cognome'), - Infolists\Components\TextEntry::make('extractedData.company_name') - ->label('Azienda'), - Infolists\Components\TextEntry::make('extractedData.document_date') - ->label('Data documento') - ->date('d/m/Y'), - Infolists\Components\TextEntry::make('extractedData.document_type') - ->label('Tipo documento'), - Infolists\Components\TextEntry::make('extractedData.confidence_score') - ->label('Confidenza') - ->suffix('%'), - Infolists\Components\TextEntry::make('extractedData.description') - ->label('Descrizione') - ->columnSpanFull(), - ]) - ->columns(2), - Infolists\Components\Section::make('Documento') - ->schema([ - Infolists\Components\TextEntry::make('send_status') - ->label('Stato invio') - ->badge() - ->color(fn (SendStatus $state): string => $state->color()), - Infolists\Components\TextEntry::make('pages') - ->label('Pagine') - ->state(fn () => "{$this->subDocument->start_page}–{$this->subDocument->end_page}"), - ]), - ]), - ]); - } -} diff --git a/app/Filament/Resources/OriginalDocumentResource/RelationManagers/SubDocumentsRelationManager.php b/app/Filament/Resources/OriginalDocumentResource/RelationManagers/SubDocumentsRelationManager.php deleted file mode 100644 index 1e85ca0..0000000 --- a/app/Filament/Resources/OriginalDocumentResource/RelationManagers/SubDocumentsRelationManager.php +++ /dev/null @@ -1,51 +0,0 @@ -schema([]); - } - - public function table(Table $table): Table - { - return $table - ->columns([ - Tables\Columns\TextColumn::make('id') - ->label('#') - ->sortable(), - Tables\Columns\TextColumn::make('extractedData.employee_first_name') - ->label('Nome') - ->default('—'), - Tables\Columns\TextColumn::make('extractedData.employee_last_name') - ->label('Cognome') - ->default('—'), - Tables\Columns\TextColumn::make('start_page') - ->label('Pagine') - ->formatStateUsing(fn ($record) => "{$record->start_page}–{$record->end_page}"), - Tables\Columns\TextColumn::make('send_status') - ->label('Invio') - ->badge() - ->color(fn (SendStatus $state): string => $state->color()), - ]) - ->actions([ - Tables\Actions\Action::make('view') - ->label('Visualizza') - ->icon('heroicon-o-eye') - ->url(fn ($record) => OriginalDocumentResource::getUrl('view-sub-document', ['record' => $record->id])), - ]); - } -} diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php deleted file mode 100644 index a165b57..0000000 --- a/app/Filament/Resources/UserResource.php +++ /dev/null @@ -1,113 +0,0 @@ -schema([ - Forms\Components\Section::make('Dati account') - ->schema([ - Forms\Components\TextInput::make('name') - ->label('Nome') - ->required() - ->maxLength(255), - Forms\Components\TextInput::make('email') - ->label('Email') - ->email() - ->required() - ->maxLength(255) - ->unique(ignoreRecord: true), - Forms\Components\Toggle::make('is_admin') - ->label('Accesso amministrativo') - ->helperText('Abilita l’accesso al pannello /admin. Gli utenti non admin accedono solo all’applicativo.') - ->default(false) - ->disabled(fn (?User $record): bool => $record?->id === auth()->id()) - ->dehydrated(fn (?User $record): bool => $record?->id !== auth()->id()), - ]) - ->columns(2), - Forms\Components\Section::make('Credenziali') - ->description('La password viene salvata con hashing Laravel. In modifica lascia vuoto il campo per conservarla.') - ->schema([ - Forms\Components\TextInput::make('password') - ->label('Password') - ->password() - ->revealable() - ->required(fn (string $operation): bool => $operation === 'create') - ->rule(Password::min(12)->letters()->mixedCase()->numbers()) - ->same('password_confirmation') - ->dehydrated(fn (?string $state): bool => filled($state)), - Forms\Components\TextInput::make('password_confirmation') - ->label('Conferma password') - ->password() - ->revealable() - ->required(fn (string $operation): bool => $operation === 'create') - ->dehydrated(false), - ]) - ->columns(2), - ]); - } - - public static function table(Table $table): Table - { - return $table - ->columns([ - Tables\Columns\TextColumn::make('name') - ->label('Nome') - ->searchable() - ->sortable(), - Tables\Columns\TextColumn::make('email') - ->label('Email') - ->searchable() - ->sortable(), - Tables\Columns\IconColumn::make('is_admin') - ->label('Admin') - ->boolean() - ->sortable(), - Tables\Columns\TextColumn::make('created_at') - ->label('Creato il') - ->dateTime('d/m/Y H:i') - ->sortable(), - ]) - ->defaultSort('created_at', 'desc') - ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\DeleteAction::make() - ->visible(fn (User $record): bool => $record->id !== auth()->id()), - ]); - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListUsers::route('/'), - 'create' => Pages\CreateUser::route('/create'), - 'edit' => Pages\EditUser::route('/{record}/edit'), - ]; - } -} diff --git a/app/Filament/Resources/UserResource/Pages/CreateUser.php b/app/Filament/Resources/UserResource/Pages/CreateUser.php deleted file mode 100644 index 78a3894..0000000 --- a/app/Filament/Resources/UserResource/Pages/CreateUser.php +++ /dev/null @@ -1,11 +0,0 @@ -visible(fn (): bool => $this->record->id !== auth()->id()), - ]; - } -} diff --git a/app/Filament/Resources/UserResource/Pages/ListUsers.php b/app/Filament/Resources/UserResource/Pages/ListUsers.php deleted file mode 100644 index ce9770e..0000000 --- a/app/Filament/Resources/UserResource/Pages/ListUsers.php +++ /dev/null @@ -1,20 +0,0 @@ -label('Crea utente'), - ]; - } -} diff --git a/app/Http/Controllers/Auth/UserLoginController.php b/app/Http/Controllers/Auth/UserLoginController.php deleted file mode 100644 index 96efa24..0000000 --- a/app/Http/Controllers/Auth/UserLoginController.php +++ /dev/null @@ -1,43 +0,0 @@ -user()) { - return redirect()->route('poc.app'); - } - - return view('auth.user-login'); - } - - /** - * @throws ValidationException - */ - public function store(Request $request): RedirectResponse - { - $credentials = $request->validate([ - 'email' => ['required', 'email:rfc'], - 'password' => ['required', 'string'], - ]); - - if (! Auth::attempt($credentials, $request->boolean('remember'))) { - throw ValidationException::withMessages([ - 'email' => 'Credenziali non valide.', - ]); - } - - $request->session()->regenerate(); - - return redirect()->intended(route('poc.app')); - } -} diff --git a/app/Http/Controllers/Poc/AppApiController.php b/app/Http/Controllers/Poc/AppApiController.php index ffdb9e2..4aa775a 100644 --- a/app/Http/Controllers/Poc/AppApiController.php +++ b/app/Http/Controllers/Poc/AppApiController.php @@ -3,18 +3,17 @@ namespace App\Http\Controllers\Poc; use App\Enums\CommunicationStatus; -use App\Enums\ProcessingStatus; -use App\Filament\Resources\UserResource; use App\Http\Controllers\Controller; use App\Models\Communication; use App\Models\ExtractedData; use App\Models\OriginalDocument; use App\Models\SubDocument; use App\Services\BedrockService; +use App\Services\DocumentProcessingService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; class AppApiController extends Controller @@ -33,37 +32,6 @@ class AppApiController extends Controller 'Aggiornamento breve', ]; - private const CHANNELS = [ - 'Email interna', - 'News portale', - 'Notifica rapida', - ]; - - private const AUDIENCES = [ - 'Tutti i dipendenti', - 'Manager e responsabili', - 'Team HR', - ]; - - public function session(Request $request): JsonResponse - { - $user = $request->user(); - - return response()->json([ - 'csrfToken' => csrf_token(), - 'user' => [ - 'name' => $user->name, - 'email' => $user->email, - 'initials' => $this->initials($user->name), - 'isAdmin' => $user->is_admin, - ], - 'links' => [ - 'users' => $user->is_admin ? UserResource::getUrl('index') : null, - 'logout' => route('poc.logout'), - ], - ]); - } - public function state(): JsonResponse { return response()->json($this->stateData()); @@ -73,21 +41,16 @@ public function generateCommunication(Request $request, BedrockService $bedrock) { $validated = $request->validate([ 'prompt' => ['required', 'string', 'min:12', 'max:5000'], - 'audience' => ['required', 'string', Rule::in(self::AUDIENCES)], 'tone' => ['required', 'string', Rule::in(self::TONES)], 'style' => ['required', 'string', Rule::in(self::STYLES)], - 'channel' => ['required', 'string', Rule::in(self::CHANNELS)], - ]); - - $style = $validated['style'].' / '.$validated['channel']; - $prompt = implode("\n", [ - 'Pubblico: '.$validated['audience'], - 'Canale: '.$validated['channel'], - 'Richiesta: '.$validated['prompt'], ]); try { - $generated = $bedrock->generateCommunication($prompt, $validated['tone'], $style); + $generated = $bedrock->generateCommunication( + $validated['prompt'], + $validated['tone'], + $validated['style'], + ); } catch (\Throwable $e) { Log::warning('PoC communication generation failed', ['message' => $e->getMessage()]); @@ -99,7 +62,7 @@ public function generateCommunication(Request $request, BedrockService $bedrock) $communication = Communication::create([ 'prompt' => $validated['prompt'], 'tone' => $validated['tone'], - 'style' => $style, + 'style' => $validated['style'], 'generated_title' => $generated['title'], 'generated_body' => $generated['body'], 'status' => CommunicationStatus::Draft, @@ -112,70 +75,52 @@ public function generateCommunication(Request $request, BedrockService $bedrock) ], 201); } - public function runDocumentOcr(Request $request): JsonResponse + public function runDocumentOcr(Request $request, DocumentProcessingService $documents): JsonResponse { $validated = $request->validate([ 'document' => ['required', 'file', 'mimetypes:application/pdf', 'max:10240'], ]); - $file = $validated['document']; - $path = $file->store('documents/originals', 'local'); - - $original = OriginalDocument::create([ - 'file_path' => $path, - 'original_filename' => $file->getClientOriginalName(), - 'processing_status' => ProcessingStatus::Processing, - ]); - - $workerMessage = 'OCR locale/Textract predisposto; campi non ancora disponibili.'; - try { - $response = Http::timeout(8) - ->acceptJson() - ->post(rtrim((string) config('services.ai_worker.url'), '/').'/ocr', [ - 'document_id' => (string) $original->id, - 'storage_path' => $path, - 'driver' => config('services.textract.enabled') ? 'textract' : 'local', - 'language' => 'ita+eng', - ]); - - if ($response->successful()) { - $workerMessage = $response->json('message') ?: $workerMessage; - } + $original = $documents->handleUpload($validated['document']); } catch (\Throwable $e) { - Log::info('PoC OCR worker unavailable; keeping null extracted fields', [ - 'original_document_id' => $original->id, + Log::warning('PoC document processing failed', [ 'message' => $e->getMessage(), ]); - } - $subDocument = SubDocument::create([ - 'original_document_id' => $original->id, - 'file_path' => $path, - 'start_page' => 1, - 'end_page' => 1, - ]); - - ExtractedData::create([ - 'sub_document_id' => $subDocument->id, - 'employee_first_name' => null, - 'employee_last_name' => null, - 'company_name' => null, - 'document_date' => null, - 'document_type' => null, - 'description' => null, - 'confidence_score' => null, - ]); + return response()->json([ + 'message' => 'Analisi documento non disponibile. Verifica il PDF o la configurazione AI e riprova.', + ], 502); + } - $original->update(['processing_status' => ProcessingStatus::Completed]); + $subDocuments = $original->subDocuments() + ->with(['originalDocument', 'extractedData']) + ->latest() + ->get() + ->map(fn (SubDocument $document): array => $this->serializeDocument($document)) + ->values() + ->all(); return response()->json([ - 'message' => $workerMessage, - 'document' => $this->serializeDocument($subDocument->fresh(['originalDocument', 'extractedData'])), + 'message' => 'Documento analizzato: split iniziale e campi OCR disponibili nella sezione risultati.', + 'document' => $subDocuments[0] ?? null, + 'documents' => $subDocuments, 'state' => $this->stateData(), ], 201); } + public function previewSubDocument(SubDocument $subDocument) + { + abort_unless(Storage::disk('local')->exists($subDocument->file_path), 404); + + $filename = $subDocument->originalDocument?->original_filename ?: 'documento.pdf'; + + return response()->file(Storage::disk('local')->path($subDocument->file_path), [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.str_replace('"', '', $filename).'"', + ]); + } + private function stateData(): array { $communications = Communication::query()->latest()->limit(6)->get(); @@ -186,7 +131,9 @@ private function stateData(): array ->get(); $documentCount = OriginalDocument::query()->count(); - $sentCount = SubDocument::query()->where('send_status', 'sent')->count(); + $withConfidenceCount = ExtractedData::query() + ->whereNotNull('confidence_score') + ->count(); $reviewCount = ExtractedData::query() ->where(function ($query) { $query->whereNull('confidence_score') @@ -198,9 +145,7 @@ private function stateData(): array 'assistant' => [ 'metrics' => [ ['value' => Communication::query()->count(), 'label' => 'Contenuti generati'], - ['value' => Communication::query()->where('status', CommunicationStatus::Draft)->count(), 'label' => 'Bozze da rivedere'], - ['value' => 0, 'label' => 'Feedback raccolti'], - ['value' => 'n/d', 'label' => 'Rating medio'], + ['value' => Communication::query()->where('status', CommunicationStatus::Draft)->count(), 'label' => 'Bozze generate'], ], 'history' => $communications ->map(fn (Communication $communication): array => $this->serializeCommunication($communication)) @@ -210,10 +155,9 @@ private function stateData(): array 'copilot' => [ 'metrics' => [ ['value' => $documentCount, 'label' => 'Documenti analizzati'], + ['value' => $documents->count(), 'label' => 'Sotto-documenti rilevati'], + ['value' => $withConfidenceCount, 'label' => 'Campi con confidenza'], ['value' => $reviewCount, 'label' => 'Da verificare'], - ['value' => max(0, $documents->count() - $reviewCount - $sentCount), 'label' => 'Pronti per invio'], - ['value' => $sentCount, 'label' => 'Inviati'], - ['value' => 'n/d', 'label' => 'Tempo medio analisi'], ], 'documents' => $documents ->map(fn (SubDocument $document): array => $this->serializeDocument($document)) @@ -259,23 +203,12 @@ private function serializeDocument(SubDocument $subDocument): array 'type' => $data?->document_type, 'description' => $data?->description, 'confidence' => $confidence, - 'deliveryStatus' => $subDocument->send_status->label(), + 'previewUrl' => route('poc.documents.preview', ['subDocument' => $subDocument->id]), 'previewLines' => [ - 'Anteprima PDF non disponibile nella PoC.', - 'OCR locale/Textract predisposto.', - 'Campi mostrati come non disponibili finché non vengono estratti.', + 'Split iniziale: pagine '.$subDocument->start_page.'-'.$subDocument->end_page.'.', + 'File originale: '.($original?->original_filename ?: 'Non disponibile').'.', + 'Campi OCR rilevati dal servizio AI configurato o dal fallback PoC.', ], ]; } - - private function initials(string $name): string - { - $parts = array_values(array_filter(preg_split('/\s+/', trim($name)) ?: [])); - - if ($parts === []) { - return 'U'; - } - - return strtoupper(substr($parts[0], 0, 1).substr($parts[1] ?? '', 0, 1)); - } } diff --git a/app/Http/Controllers/Poc/SessionController.php b/app/Http/Controllers/Poc/SessionController.php deleted file mode 100644 index a884320..0000000 --- a/app/Http/Controllers/Poc/SessionController.php +++ /dev/null @@ -1,21 +0,0 @@ -logout(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return redirect()->route('login'); - } -} diff --git a/app/Models/User.php b/app/Models/User.php index e17452c..c76791a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,15 +4,13 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; -use Filament\Models\Contracts\FilamentUser; -use Filament\Panel; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; #[Hidden(['password', 'remember_token'])] -class User extends Authenticatable implements FilamentUser +class User extends Authenticatable { /** @use HasFactory */ use HasFactory, Notifiable; @@ -26,9 +24,7 @@ class User extends Authenticatable implements FilamentUser 'name', 'email', 'password', - 'is_admin', ]; - /** * Get the attributes that should be cast. @@ -39,13 +35,7 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', - 'is_admin' => 'boolean', 'password' => 'hashed', ]; } - - public function canAccessPanel(Panel $panel): bool - { - return $panel->getId() === 'admin' && $this->is_admin; - } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php deleted file mode 100644 index 47d9438..0000000 --- a/app/Providers/Filament/AdminPanelProvider.php +++ /dev/null @@ -1,91 +0,0 @@ -default() - ->id('admin') - ->path('admin') - ->brandName('NEXUM') - ->brandLogo(asset('images/eggon_logo_43542.png')) - ->brandLogoHeight('3.25rem') - ->login() - ->colors([ - 'primary' => [ - 50 => 'f5f8fb', - 100 => 'edf5fb', - 200 => 'd7e4ef', - 300 => 'bfd3e4', - 400 => '8eb8d7', - 500 => '6fa6cf', - 600 => '4f8bb9', - 700 => '2f678f', - 800 => '285675', - 900 => '18324a', - 950 => '0f1720', - ], - ]) - ->renderHook( - PanelsRenderHook::STYLES_AFTER, - fn (): Htmlable => new HtmlString(''), - ) - ->renderHook( - PanelsRenderHook::SCRIPTS_AFTER, - fn (): Htmlable => new HtmlString(<<<'HTML' - - HTML), - ) - ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') - ->pages([ - Dashboard::class, - ]) - ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') - ->widgets([]) - ->middleware([ - EncryptCookies::class, - AddQueuedCookiesToResponse::class, - StartSession::class, - AuthenticateSession::class, - ShareErrorsFromSession::class, - VerifyCsrfToken::class, - SubstituteBindings::class, - DisableBladeIconComponents::class, - DispatchServingFilamentEvent::class, - ]) - ->authMiddleware([ - Authenticate::class, - ]); - } -} diff --git a/app/Services/DocumentProcessingService.php b/app/Services/DocumentProcessingService.php index 2edf3fe..a33bfd2 100644 --- a/app/Services/DocumentProcessingService.php +++ b/app/Services/DocumentProcessingService.php @@ -49,7 +49,10 @@ public function process(OriginalDocument $original): void $original->update(['processing_status' => ProcessingStatus::Processing]); try { - $segments = $this->bedrock->splitDocument($original->file_path); + $segments = $this->normalizeSegments( + $this->bedrock->splitDocument($original->file_path), + $original->file_path, + ); foreach ($segments as $segment) { $subPath = $this->extractPages( @@ -78,6 +81,8 @@ public function process(OriginalDocument $original): void 'sub_document_id' => $subDocument->id, 'message' => $e->getMessage(), ]); + + $this->createEmptyExtractedData($subDocument); } } @@ -92,6 +97,51 @@ public function process(OriginalDocument $original): void } } + /** + * Keep the PoC useful even when the split model cannot identify multiple recipients: + * one fallback segment still allows field extraction on the uploaded PDF. + * + * @param array $segments + * @return array + */ + private function normalizeSegments(array $segments, string $sourcePath): array + { + $pageCount = $this->pageCount($sourcePath); + + if ($segments === []) { + return [[ + 'employee_name' => 'documento', + 'start_page' => 1, + 'end_page' => $pageCount, + ]]; + } + + return array_values(array_map(function (array $segment) use ($pageCount): array { + $startPage = max(1, (int) ($segment['start_page'] ?? 1)); + $endPage = min($pageCount, max($startPage, (int) ($segment['end_page'] ?? $startPage))); + + return [ + 'employee_name' => trim((string) ($segment['employee_name'] ?? 'documento')) ?: 'documento', + 'start_page' => $startPage, + 'end_page' => $endPage, + ]; + }, $segments)); + } + + private function createEmptyExtractedData(SubDocument $subDocument): void + { + ExtractedData::create([ + 'sub_document_id' => $subDocument->id, + 'employee_first_name' => null, + 'employee_last_name' => null, + 'company_name' => null, + 'document_date' => null, + 'document_type' => null, + 'description' => null, + 'confidence_score' => null, + ]); + } + /** * Extract a page range from a PDF and write it to storage. * @@ -120,4 +170,11 @@ private function extractPages(string $sourcePath, int $originalId, string $emplo return $relativePath; } + + private function pageCount(string $sourcePath): int + { + $pdf = new Fpdi; + + return max(1, $pdf->setSourceFile(Storage::disk('local')->path($sourcePath))); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..fb0f552 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,9 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; +use Illuminate\Session\TokenMismatchException; +use Symfony\Component\HttpKernel\Exception\HttpException; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -14,5 +17,23 @@ // }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->render(function (TokenMismatchException $exception, Request $request) { + if ($request->is('poc/*') || $request->expectsJson()) { + return response()->json([ + 'message' => "La pagina è rimasta aperta troppo a lungo. Ricaricala e riprova l'operazione.", + ], 419); + } + + return null; + }); + + $exceptions->render(function (HttpException $exception, Request $request) { + if ($exception->getStatusCode() === 419 && ($request->is('poc/*') || $request->expectsJson())) { + return response()->json([ + 'message' => "La pagina è rimasta aperta troppo a lungo. Ricaricala e riprova l'operazione.", + ], 419); + } + + return null; + }); })->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 22744d1..38b258d 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,5 +2,4 @@ return [ App\Providers\AppServiceProvider::class, - App\Providers\Filament\AdminPanelProvider::class, ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 4abcee0..2db68b0 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -29,7 +29,6 @@ public function definition(): array 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), - 'is_admin' => false, 'remember_token' => Str::random(10), ]; } @@ -44,10 +43,4 @@ public function unverified(): static ]); } - public function admin(): static - { - return $this->state(fn (array $attributes) => [ - 'is_admin' => true, - ]); - } } diff --git a/database/migrations/2026_05_11_200000_add_is_admin_to_users_table.php b/database/migrations/2026_05_11_200000_add_is_admin_to_users_table.php index 81a7481..f4bc8d1 100644 --- a/database/migrations/2026_05_11_200000_add_is_admin_to_users_table.php +++ b/database/migrations/2026_05_11_200000_add_is_admin_to_users_table.php @@ -9,15 +9,19 @@ public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->boolean('is_admin')->default(false)->after('password')->index(); + if (! Schema::hasColumn('users', 'is_admin')) { + $table->boolean('is_admin')->default(false)->after('password')->index(); + } }); } public function down(): void { Schema::table('users', function (Blueprint $table) { - $table->dropIndex(['is_admin']); - $table->dropColumn('is_admin'); + if (Schema::hasColumn('users', 'is_admin')) { + $table->dropIndex(['is_admin']); + $table->dropColumn('is_admin'); + } }); } }; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index a6535b0..cccc5c4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -15,12 +14,6 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - 'is_admin' => false, - ]); + // } } diff --git a/docker-compose.yml b/docker-compose.yml index 612a738..84c5ad6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,10 +19,6 @@ services: COMPOSER_INSTALL_ON_STARTUP: ${COMPOSER_INSTALL_ON_STARTUP:-true} LARAVEL_AUTOMATED_SETUP: ${LARAVEL_AUTOMATED_SETUP:-true} LARAVEL_SETUP_RETRIES: ${LARAVEL_SETUP_RETRIES:-30} - FILAMENT_ADMIN_CREATE: ${FILAMENT_ADMIN_CREATE:-true} - FILAMENT_ADMIN_NAME: ${FILAMENT_ADMIN_NAME:-NEXUM Admin} - FILAMENT_ADMIN_EMAIL: ${FILAMENT_ADMIN_EMAIL:-admin@nexum.local} - FILAMENT_ADMIN_PASSWORD: ${FILAMENT_ADMIN_PASSWORD:-Password123!} volumes: - ./:/var/www/html depends_on: diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index e26655e..53d09c2 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -24,32 +24,6 @@ run_with_retries() { done } -filament_admin_exists() { - FILAMENT_ADMIN_EMAIL="$1" php <<'PHP' -make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); - -exit(App\Models\User::where('email', getenv('FILAMENT_ADMIN_EMAIL'))->exists() ? 0 : 1); -PHP -} - -mark_filament_admin() { - FILAMENT_ADMIN_EMAIL="$1" php <<'PHP' -make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); - -App\Models\User::where('email', getenv('FILAMENT_ADMIN_EMAIL'))->update(['is_admin' => true]); -PHP -} - if [ "${COMPOSER_INSTALL_ON_STARTUP:-true}" = "true" ] && [ -f composer.json ]; then if [ -f composer.lock ]; then composer_hash="$(sha256sum composer.lock | awk '{ print $1 }')" @@ -118,25 +92,6 @@ if [ "${LARAVEL_AUTOMATED_SETUP:-false}" = "true" ]; then run_with_retries "Database migration" php artisan migrate --force --no-interaction - if [ "${FILAMENT_ADMIN_CREATE:-true}" = "true" ]; then - admin_name="${FILAMENT_ADMIN_NAME:-NEXUM Admin}" - admin_email="${FILAMENT_ADMIN_EMAIL:-admin@nexum.local}" - admin_password="${FILAMENT_ADMIN_PASSWORD:-Password123!}" - - if filament_admin_exists "$admin_email"; then - echo "Filament admin user already exists: $admin_email" - else - echo "Creating Filament admin user: $admin_email" - php artisan make:filament-user \ - --name="$admin_name" \ - --email="$admin_email" \ - --password="$admin_password" \ - --no-interaction - fi - - mark_filament_admin "$admin_email" - fi - touch "$setup_marker" fi diff --git a/public/css/nexum-filament.css b/public/css/nexum-filament.css deleted file mode 100644 index 785d6e8..0000000 --- a/public/css/nexum-filament.css +++ /dev/null @@ -1,439 +0,0 @@ -:root { - --nexum-surface-page: #f5f8fb; - --nexum-surface: #ffffff; - --nexum-surface-soft: #edf5fb; - --nexum-surface-muted: #f8fbfd; - --nexum-border: #d7e4ef; - --nexum-border-strong: #bfd3e4; - --nexum-text: #18324a; - --nexum-muted: #63798d; - --nexum-primary: #6fa6cf; - --nexum-primary-deep: #2f678f; - --nexum-accent: #f28a52; - --nexum-accent-soft: #fff1e8; - --nexum-shadow: 0 10px 28px rgba(38, 82, 120, 0.08); - --nexum-radius: 8px; -} - -.dark { - --nexum-surface-page: #0f1720; - --nexum-surface: #16212c; - --nexum-surface-soft: #223343; - --nexum-surface-muted: #1c2a36; - --nexum-border: #2c4052; - --nexum-border-strong: #3c566c; - --nexum-text: #e8f0f7; - --nexum-muted: #a8b8c6; - --nexum-primary: #79add3; - --nexum-primary-deep: #9dcced; - --nexum-accent: #f09a62; - --nexum-accent-soft: #3a2c25; - --nexum-shadow: 0 14px 34px rgba(0, 0, 0, 0.22); -} - -body.fi-body, -.fi-layout, -.fi-main-ctn { - background: var(--nexum-surface-page); - color: var(--nexum-text); - font-family: "Avenir Next", "Segoe UI", Arial, sans-serif; -} - -.fi-sidebar { - background: var(--nexum-surface); - border-right: 1px solid var(--nexum-border); - box-shadow: none; -} - -.fi-sidebar-header { - background: var(--nexum-surface); - border-bottom: 1px solid var(--nexum-border); -} - -.fi-logo img, -.fi-sidebar-header img { - border-radius: var(--nexum-radius); - object-fit: contain; -} - -.dark .fi-logo img, -.dark .fi-sidebar-header img { - filter: brightness(0) invert(1); - opacity: 0.96; -} - -.fi-sidebar-group-label { - color: var(--nexum-muted); - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0; - text-transform: uppercase; -} - -.fi-sidebar-item-button { - border-radius: var(--nexum-radius); - color: var(--nexum-text); - transition: background 160ms ease, color 160ms ease; -} - -.fi-sidebar-item-button:hover, -.fi-sidebar-item-button:focus-visible { - background: var(--nexum-surface-soft); -} - -.fi-sidebar-item.fi-active .fi-sidebar-item-button { - background: var(--nexum-primary); - color: #ffffff; -} - -.fi-sidebar-item.fi-active .fi-sidebar-item-icon, -.fi-sidebar-item.fi-active .fi-sidebar-item-label { - color: #ffffff; -} - -.fi-topbar, -.fi-topbar nav { - background: color-mix(in srgb, var(--nexum-surface) 92%, transparent); - border-bottom-color: var(--nexum-border); - box-shadow: none; -} - -.fi-main { - max-width: 1120px; -} - -.fi-header-heading { - color: var(--nexum-text); - font-size: 1.55rem; - letter-spacing: 0; -} - -.fi-header-subheading, -.fi-breadcrumbs, -.fi-breadcrumbs a, -.fi-ta-text, -.fi-fo-field-wrp-helper-text, -.fi-in-text, -.fi-section-description { - color: var(--nexum-muted); -} - -.fi-section, -.fi-ta-ctn, -.fi-modal-window, -.fi-dropdown-panel, -.fi-simple-main { - background: var(--nexum-surface); - border: 1px solid var(--nexum-border); - border-radius: var(--nexum-radius); - box-shadow: var(--nexum-shadow); -} - -.fi-section-header { - border-bottom-color: var(--nexum-border); -} - -.fi-section-header-heading, -.fi-ta-header-heading, -.fi-modal-heading, -.fi-fo-field-wrp-label span { - color: var(--nexum-text); - font-weight: 700; -} - -.fi-input-wrp, -.fi-select-input, -.fi-textarea, -.fi-input { - border-radius: var(--nexum-radius); -} - -.fi-input-wrp { - background: var(--nexum-surface); - border-color: var(--nexum-border); -} - -.fi-input, -.fi-select-input, -.fi-textarea { - color: var(--nexum-text); -} - -.fi-input-wrp:focus-within { - border-color: var(--nexum-primary); - box-shadow: 0 0 0 3px rgba(111, 166, 207, 0.22); -} - -.fi-btn { - border-radius: var(--nexum-radius); - font-weight: 700; -} - -.fi-btn-color-primary { - background: var(--nexum-primary); -} - -.fi-btn-color-primary:hover, -.fi-btn-color-primary:focus-visible { - background: var(--nexum-primary-deep); -} - -.fi-badge { - border-radius: 999px; -} - -.fi-ta-row:hover { - background: var(--nexum-surface-muted); -} - -.fi-ta-header, -.fi-ta-content, -.fi-pagination { - background: transparent; -} - -.fi-simple-layout { - background: - radial-gradient(circle at 18% 18%, rgba(111, 166, 207, 0.14), transparent 26rem), - var(--nexum-surface-page); -} - -.fi-simple-main { - max-width: 440px; -} - -.fi-simple-header img { - max-height: 4.5rem; - border-radius: var(--nexum-radius); -} - -.nexum-dashboard { - display: grid; - gap: 18px; -} - -.nexum-hero, -.nexum-panel { - background: var(--nexum-surface); - border: 1px solid var(--nexum-border); - border-radius: var(--nexum-radius); - box-shadow: var(--nexum-shadow); -} - -.nexum-hero { - display: grid; - gap: 18px; - padding: 24px; -} - -.nexum-hero h2 { - max-width: 760px; - margin: 0; - color: var(--nexum-text); - font-size: 1.7rem; - line-height: 1.18; -} - -.nexum-hero p:not(.nexum-eyebrow) { - max-width: 800px; - margin: 10px 0 0; - color: var(--nexum-muted); - line-height: 1.6; -} - -.nexum-panel { - display: grid; - gap: 18px; - padding: 22px; -} - -.nexum-panel-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; -} - -.nexum-panel h3 { - margin: 0; - color: var(--nexum-text); - font-size: 1.22rem; - line-height: 1.3; -} - -.nexum-admin-dashboard { - display: grid; - gap: 18px; -} - -.nexum-admin-copy { - max-width: 760px; - margin: 0; - color: var(--nexum-muted); - line-height: 1.6; -} - -.nexum-eyebrow { - margin: 0 0 6px; - color: var(--nexum-primary-deep); - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0; - text-transform: uppercase; -} - -.nexum-actions { - display: flex; - flex-wrap: wrap; - gap: 10px; -} - -.nexum-action { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 44px; - padding: 10px 14px; - border-radius: var(--nexum-radius); - font-weight: 700; - text-decoration: none; - transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease; -} - -.nexum-action:hover, -.nexum-action:focus-visible { - box-shadow: 0 8px 18px rgba(47, 103, 143, 0.14); - outline: none; - transform: translateY(-1px); -} - -.nexum-action-primary { - background: var(--nexum-primary); - color: #ffffff; -} - -.nexum-action-secondary { - background: var(--nexum-surface-soft); - color: var(--nexum-text); -} - -.nexum-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px; -} - -.nexum-flow-list, -.nexum-metric-list, -.nexum-history-list { - display: grid; - gap: 10px; -} - -.nexum-flow-list a, -.nexum-metric-list div, -.nexum-history-list div { - display: grid; - gap: 6px; - padding: 14px; - border: 1px solid var(--nexum-border); - border-radius: var(--nexum-radius); - background: var(--nexum-surface-muted); - text-decoration: none; -} - -.nexum-flow-list a:hover, -.nexum-flow-list a:focus-visible { - border-color: var(--nexum-border-strong); - background: var(--nexum-surface-soft); - outline: none; -} - -.nexum-flow-list strong, -.nexum-history-list strong { - color: var(--nexum-text); - line-height: 1.35; -} - -.nexum-flow-list span, -.nexum-history-list span, -.nexum-metric-list span { - color: var(--nexum-muted); - line-height: 1.45; -} - -.nexum-metric-list { - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); -} - -.nexum-metric-list strong { - color: var(--nexum-primary-deep); - font-size: 1.35rem; -} - -@media (max-width: 900px) { - .nexum-grid { - grid-template-columns: 1fr; - } - - .nexum-hero, - .nexum-panel { - padding: 18px; - } -} - -@media (max-width: 640px) { - .fi-main { - padding-inline: 1rem; - } - - .nexum-hero h2 { - font-size: 1.35rem; - } - - .nexum-action { - width: 100%; - } -} - -body:has(.nexum-app-host) { - overflow: hidden; -} - -body:has(.nexum-app-host) .fi-sidebar, -body:has(.nexum-app-host) .fi-topbar, -body:has(.nexum-app-host) .fi-breadcrumbs, -body:has(.nexum-app-host) .fi-header { - display: none !important; -} - -body:has(.nexum-app-host) .fi-layout, -body:has(.nexum-app-host) .fi-main-ctn, -body:has(.nexum-app-host) .fi-main, -body:has(.nexum-app-host) .fi-page { - width: 100vw !important; - max-width: none !important; - min-height: 100vh !important; - margin: 0 !important; - padding: 0 !important; - background: #f5f8fb !important; -} - -body:has(.nexum-app-host) .fi-main { - display: block !important; -} - -.nexum-app-host { - width: 100vw; - height: 100vh; - min-height: 100vh; - background: #f5f8fb; -} - -.nexum-app-frame { - display: block; - width: 100vw; - height: 100vh; - border: 0; - background: #f5f8fb; -} diff --git a/public/nexum-app/app.js b/public/nexum-app/app.js index 40f4a2a..a5df4f0 100644 --- a/public/nexum-app/app.js +++ b/public/nexum-app/app.js @@ -5,37 +5,24 @@ const views = { }; const apiRoutes = { - session: "/poc/api/session", state: "/poc/api/state", communications: "/poc/api/communications", documentOcr: "/poc/api/documents/ocr" }; -let csrfToken = ""; -let appState = null; - +const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ""; const mainNavItems = document.querySelectorAll(".nav-item"); const subNavItems = document.querySelectorAll(".nav-subitem"); const sections = document.querySelectorAll(".view"); const titleNode = document.getElementById("view-title"); const themeToggle = document.getElementById("theme-toggle"); -const profileToggle = document.getElementById("profile-toggle"); -const profileMenu = document.getElementById("profile-menu"); -const profileInitials = document.getElementById("profile-initials"); -const profileName = document.getElementById("profile-name"); -const profileEmail = document.getElementById("profile-email"); -const profileUsersLink = document.getElementById("profile-users-link"); -const profileLogoutForm = document.getElementById("profile-logout-form"); -const profileLogoutToken = document.getElementById("profile-logout-token"); const backToTopButton = document.getElementById("back-to-top"); const storedTheme = window.localStorage.getItem("nexum-theme"); const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; let currentTheme = storedTheme || (systemPrefersDark ? "dark" : "light"); let currentView = "overview"; - -function isVisibleNode(node) { - return Boolean(node) && !node.classList.contains("is-hidden"); -} +let documents = []; +let activeDocumentId = null; function setText(node, value) { if (node) { @@ -45,22 +32,8 @@ function setText(node, value) { function setValue(node, value) { if (node) { - node.value = value; - } -} - -function setModelValue(node, value) { - if (!node) { - return; + node.value = value ?? ""; } - - node.value = value; - node.dataset.modelValue = value; - node.closest(".field")?.classList.remove("has-manual-correction"); -} - -function normalizeMetricRows(values) { - return values.map((item) => Array.isArray(item) ? item : [String(item.value ?? "n/d"), item.label ?? "Dato"]); } function renderMetricList(list, values) { @@ -69,7 +42,7 @@ function renderMetricList(list, values) { } list.replaceChildren(); - normalizeMetricRows(values).forEach(([value, label]) => { + values.forEach(([value, label]) => { const item = document.createElement("li"); const strong = document.createElement("strong"); const span = document.createElement("span"); @@ -85,12 +58,30 @@ function humanApiError(error) { return Object.values(error.errors).flat().join(" "); } + const message = String(error?.message || ""); + + if (error?.status === 419 || message.toLowerCase().includes("csrf token mismatch")) { + return "La pagina è rimasta aperta troppo a lungo. Ricaricala e riprova l'operazione."; + } + + if (message.toLowerCase().includes("sessione non inizializzata")) { + return "La pagina deve essere ricaricata prima di continuare."; + } + + if (error?.status === 413) { + return "Il file selezionato è troppo grande per questa PoC."; + } + + if (error?.status === 429) { + return "Troppe richieste ravvicinate. Attendi qualche secondo e riprova."; + } + return error?.message || "Operazione non disponibile."; } async function apiRequest(url, options = {}) { const headers = { - "Accept": "application/json", + Accept: "application/json", "X-Requested-With": "XMLHttpRequest", ...(options.headers || {}) }; @@ -109,65 +100,12 @@ async function apiRequest(url, options = {}) { const data = contentType.includes("application/json") ? await response.json() : {}; if (!response.ok) { - throw data; + throw { ...data, status: response.status }; } return data; } -async function postJson(url, payload) { - return apiRequest(url, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(payload) - }); -} - -function updateProfile(session) { - csrfToken = session.csrfToken || ""; - setText(profileInitials, session.user?.initials || "--"); - setText(profileName, session.user?.name || "Utente"); - setText(profileEmail, session.user?.email || "Sessione attiva"); - - if (profileUsersLink) { - const hasUsersLink = Boolean(session.links?.users); - profileUsersLink.classList.toggle("hidden", !hasUsersLink); - if (hasUsersLink) { - profileUsersLink.href = session.links.users; - } - } - if (profileLogoutForm && session.links?.logout) { - profileLogoutForm.action = session.links.logout; - } - if (profileLogoutToken) { - profileLogoutToken.value = csrfToken; - } -} - -function metricRowsText(list) { - return Array.from(list?.querySelectorAll("li") || []) - .map((item) => { - const value = item.querySelector("strong")?.textContent || ""; - const label = item.querySelector("span")?.textContent || ""; - return `${label}: ${value}`; - }) - .join("\n"); -} - -function downloadTextReport(filename, content) { - const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.append(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 0); -} - function applyTheme(theme) { currentTheme = theme; document.documentElement.dataset.theme = theme; @@ -185,73 +123,6 @@ themeToggle?.addEventListener("click", () => { applyTheme(currentTheme === "dark" ? "light" : "dark"); }); -profileToggle?.addEventListener("click", () => { - const hidden = profileMenu?.classList.toggle("hidden"); - profileToggle.setAttribute("aria-expanded", String(!hidden)); -}); - -document.addEventListener("click", (event) => { - if (!profileMenu || !profileToggle) { - return; - } - - if (profileMenu.contains(event.target) || profileToggle.contains(event.target)) { - return; - } - - profileMenu.classList.add("hidden"); - profileToggle.setAttribute("aria-expanded", "false"); -}); - -function updateBackToTopVisibility() { - backToTopButton?.classList.toggle("visible", window.scrollY > 360); -} - -function syncSubnavWithScroll() { - const activeTargets = Array.from(subNavItems) - .filter((button) => button.dataset.view === currentView && isVisibleNode(button)) - .map((button) => document.getElementById(button.dataset.target)) - .filter((target) => isVisibleNode(target)); - - if (activeTargets.length === 0) { - activateSubnav("workspace-top"); - return; - } - - const nearPageBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 8; - if (nearPageBottom) { - activateSubnav(activeTargets[activeTargets.length - 1].id); - return; - } - - const anchorOffset = 120; - let activeId = activeTargets[0].id; - let smallestDistance = Number.POSITIVE_INFINITY; - - activeTargets.forEach((target) => { - const distance = Math.abs(target.getBoundingClientRect().top - anchorOffset); - if (distance < smallestDistance) { - smallestDistance = distance; - activeId = target.id; - } - }); - - activateSubnav(activeId); -} - -function handleScroll() { - updateBackToTopVisibility(); - syncSubnavWithScroll(); -} - -window.addEventListener("scroll", handleScroll, { passive: true }); - -backToTopButton?.addEventListener("click", () => { - document.getElementById("workspace-top")?.scrollIntoView({ behavior: "smooth", block: "start" }); -}); - -updateBackToTopVisibility(); - function setView(viewName) { if (!views[viewName]) { return; @@ -275,33 +146,12 @@ function activateSubnav(targetId) { }); } -function setStepLocked(stepId, locked) { - const step = document.querySelector(`[data-step="${stepId}"]`); - if (!step) { - return; - } - - step.classList.toggle("locked", locked); - step.setAttribute("aria-disabled", String(locked)); - - step.querySelectorAll("button, input, select, textarea").forEach((control) => { - control.disabled = locked; - }); -} - -function unlockSteps(stepIds) { - stepIds.forEach((stepId) => setStepLocked(stepId, false)); -} - function goTo(viewName, targetId) { setView(viewName); activateSubnav(targetId); window.requestAnimationFrame(() => { - const target = document.getElementById(targetId); - if (target) { - target.scrollIntoView({ behavior: "smooth", block: "start" }); - } + document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth", block: "start" }); }); } @@ -311,245 +161,46 @@ document.querySelectorAll(".nav-item, .nav-subitem").forEach((button) => { }); }); -document.querySelectorAll("[data-jump]").forEach((button) => { - button.addEventListener("click", () => { - goTo(button.dataset.jump, button.dataset.target); - }); -}); +function updateBackToTopVisibility() { + backToTopButton?.classList.toggle("visible", window.scrollY > 360); +} -syncSubnavWithScroll(); +window.addEventListener("scroll", updateBackToTopVisibility, { passive: true }); +backToTopButton?.addEventListener("click", () => { + document.getElementById("workspace-top")?.scrollIntoView({ behavior: "smooth", block: "start" }); +}); +updateBackToTopVisibility(); const promptInput = document.getElementById("prompt-input"); const toneSelect = document.getElementById("tone-select"); const styleSelect = document.getElementById("style-select"); -const channelSelect = document.getElementById("channel-select"); -const audienceSelect = document.getElementById("audience-select"); const generateButton = document.getElementById("generate-button"); -const savePromptButton = document.getElementById("save-prompt-button"); -const cancelDraftButton = document.getElementById("cancel-draft-button"); -const regenerateButton = document.getElementById("regenerate-button"); -const saveDraftButton = document.getElementById("save-draft-button"); -const exportButton = document.getElementById("export-button"); -const exportFormatSelect = document.getElementById("export-format-select"); -const sendButton = document.getElementById("send-button"); -const recipientCategorySelect = document.getElementById("recipient-category-select"); -const recipientEmailInput = document.getElementById("recipient-email-input"); const generatedTitleInput = document.getElementById("generated-title-input"); const generatedBodyInput = document.getElementById("generated-body-input"); -const coverPreview = document.getElementById("cover-preview"); -const coverLabel = document.getElementById("cover-label"); -const coverUploadButton = document.getElementById("cover-upload-button"); -const coverFileInput = document.getElementById("cover-file-input"); const assistantStatus = document.getElementById("assistant-status"); const assistantComposeNote = document.getElementById("assistant-compose-note"); -const promptHistory = document.getElementById("prompt-history"); -const promptSearch = document.getElementById("prompt-search"); -const promptFilter = document.getElementById("prompt-filter"); -const promptEmpty = document.getElementById("prompt-empty"); +const assistantResult = document.getElementById("assistant-result"); const metaChars = document.getElementById("meta-chars"); const metaTime = document.getElementById("meta-time"); -const analyticsExportButton = document.getElementById("analytics-export-button"); -const analyticsStatus = document.getElementById("analytics-status"); -const assistantMetricPeriod = document.getElementById("assistant-metric-period"); -const assistantMetricChannel = document.getElementById("assistant-metric-channel"); -const assistantMetricList = document.getElementById("assistant-metric-list"); -const assistantUsageBreakdown = document.getElementById("assistant-usage-breakdown"); -const assistantRatingBreakdown = document.getElementById("assistant-rating-breakdown"); -const assistantFeedbackList = document.getElementById("assistant-feedback-list"); - -[ - "assistant-review", - "assistant-feedback" -].forEach((stepId) => setStepLocked(stepId, true)); - -function topicFromPrompt(prompt) { - const normalized = prompt.toLowerCase(); - - if (normalized.includes("cedolin")) { - return { - title: "Cedolini disponibili nell'area documentale", - subject: "i cedolini del mese", - action: "accedere all'area documentale e consultare il cedolino nella sezione dedicata", - benefit: "trovare il documento senza passaggi manuali o richieste al team HR" - }; - } - - if (normalized.includes("ferie") || normalized.includes("permess")) { - return { - title: "Aggiornamento procedura ferie e permessi", - subject: "la procedura di richiesta ferie e permessi", - action: "inserire le nuove richieste dal percorso aggiornato nel portale", - benefit: "ridurre errori e tempi di approvazione" - }; - } - - if (normalized.includes("benefit")) { - return { - title: "Aggiornamento benefit aziendali", - subject: "le informazioni sui benefit aziendali", - action: "consultare la scheda aggiornata nell'area comunicazioni", - benefit: "avere indicazioni più chiare su servizi, scadenze e modalità di accesso" - }; - } - - return { - title: "Nuova area documentale disponibile su NEXUM", - subject: "la nuova area documentale NEXUM", - action: "entrare in NEXUM e aprire la sezione Storico documenti", - benefit: "consultare comunicazioni, cedolini e materiali condivisi in modo più semplice" - }; -} - -function buildTitle(prompt, channel) { - const topic = topicFromPrompt(prompt); - - if (channel === "News portale") { - return topic.title.replace(" disponibile", ""); - } - - if (channel === "Notifica rapida") { - return topic.title - .replace("Nuova area documentale disponibile su NEXUM", "Area documentale aggiornata") - .replace("Aggiornamento ", ""); - } - - return topic.title; -} - -function buildBody(prompt, tone, style, channel, audience) { - const topic = topicFromPrompt(prompt); - const audienceLabel = audience.toLowerCase(); - const opening = - tone === "Più istituzionale" - ? `Gentili colleghi, vi informiamo che ${topic.subject} è ora disponibile.` - : tone === "Più sintetico" - ? `${topic.title}.` - : `Ciao, ${topic.subject} è ora disponibile.`; - - const benefitLine = - tone === "Più istituzionale" - ? `L'aggiornamento consente a ${audienceLabel} di ${topic.benefit}, mantenendo un accesso ordinato e tracciabile.` - : `La novità permette a ${audienceLabel} di ${topic.benefit}.`; - - const actionLine = - style === "Avviso operativo" - ? `Azione richiesta: ${topic.action}. In caso di dati non corretti, segnala l'anomalia al referente HR.` - : style === "Aggiornamento breve" - ? `Per procedere, ${topic.action}.` - : `Puoi ${topic.action}; le informazioni restano raccolte nello stesso spazio e sono disponibili quando servono.`; - - if (channel === "Notifica rapida") { - return `${opening} ${actionLine}`; - } - - if (channel === "News portale") { - return `${opening}\n\n${benefitLine}\n\n${actionLine}`; - } - - const closing = - tone === "Più istituzionale" - ? "Grazie per la collaborazione." - : "Grazie e buona consultazione."; - - return `${opening}\n\n${benefitLine}\n\n${actionLine}\n\n${closing}`; -} function updateMeta() { - if (!generatedBodyInput) { - return; - } - - const chars = generatedBodyInput.value.length; + const chars = generatedBodyInput?.value.length || 0; setText(metaChars, `${chars} caratteri`); - setText(metaTime, `${Math.max(1, Math.round(chars / 500))} min lettura`); -} - -let coverObjectUrl = ""; - -function resetCover(label = "Cover generata per il canale scelto") { - if (coverObjectUrl) { - URL.revokeObjectURL(coverObjectUrl); - coverObjectUrl = ""; - } - if (coverPreview) { - coverPreview.style.backgroundImage = ""; - coverPreview.classList.remove("has-cover"); - } - setText(coverLabel, label); - if (coverFileInput) { - coverFileInput.value = ""; - } + setText(metaTime, `${Math.max(0, Math.ceil(chars / 500))} min lettura`); } -function tagsFor(channel, tone, favorite = false) { - const tags = []; - if (favorite) { - tags.push("preferito"); - } - if (channel === "Email interna") { - tags.push("email"); - } - if (channel === "News portale") { - tags.push("portale"); - } - if (tone === "Più sintetico") { - tags.push("sintetico"); - } - if (tone === "Più istituzionale") { - tags.push("istituzionale"); - } - if (tone === "Chiaro e diretto") { - tags.push("chiaro"); - } - return tags.join(" "); -} +generatedBodyInput?.addEventListener("input", updateMeta); +updateMeta(); -function setFavoriteButtonState(button, favorite) { - button.classList.toggle("active", favorite); - button.setAttribute("aria-pressed", String(favorite)); - button.setAttribute("aria-label", favorite ? "Rimuovi dai preferiti" : "Aggiungi ai preferiti"); +function setAssistantResultLocked(locked) { + assistantResult?.classList.toggle("locked", locked); + assistantResult?.setAttribute("aria-disabled", String(locked)); + assistantResult?.querySelectorAll("input, textarea").forEach((control) => { + control.disabled = locked; + }); } -function appendHistoryItem(title, prompt, channel, tone, favorite = false) { - if (!promptHistory) { - return; - } - - const item = document.createElement("li"); - item.className = "history-item"; - item.dataset.tags = tagsFor(channel, tone, favorite); - - const main = document.createElement("div"); - main.className = "history-main"; - - const strong = document.createElement("strong"); - strong.textContent = title; - - const meta = document.createElement("span"); - meta.textContent = `${channel} · ${tone} · adesso`; - - const action = document.createElement("button"); - action.className = "text-button"; - action.type = "button"; - action.dataset.reusePrompt = prompt; - action.textContent = "Riusa"; - - const favoriteButton = document.createElement("button"); - favoriteButton.className = "favorite-button"; - favoriteButton.type = "button"; - favoriteButton.dataset.favorite = ""; - favoriteButton.textContent = "★"; - setFavoriteButtonState(favoriteButton, favorite); - - const actions = document.createElement("div"); - actions.className = "history-actions"; - actions.append(favoriteButton, action); - - main.append(strong, meta); - item.append(main, actions); - promptHistory.prepend(item); - applyPromptFilters(); -} +setAssistantResultLocked(true); function validatePrompt() { const prompt = promptInput?.value.trim() || ""; @@ -558,6 +209,7 @@ function validatePrompt() { promptInput?.focus(); return false; } + return true; } @@ -567,485 +219,48 @@ async function generateDraft() { } setText(assistantComposeNote, "Generazione in corso."); - if (generateButton) { - generateButton.disabled = true; - } + generateButton.disabled = true; try { - const result = await postJson(apiRoutes.communications, { - prompt: promptInput.value.trim(), - audience: audienceSelect?.value || "Tutti i dipendenti", - tone: toneSelect?.value || "Chiaro e diretto", - style: styleSelect?.value || "Testo informativo", - channel: channelSelect?.value || "Email interna" + const result = await apiRequest(apiRoutes.communications, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: promptInput.value.trim(), + tone: toneSelect?.value || "Chiaro e diretto", + style: styleSelect?.value || "Testo informativo" + }) }); - setValue(generatedTitleInput, result.communication?.title || ""); - setValue(generatedBodyInput, result.communication?.body || ""); + setValue(generatedTitleInput, result.communication?.title); + setValue(generatedBodyInput, result.communication?.body); + setAssistantResultLocked(false); updateMeta(); - resetCover("Cover non generata nella PoC"); setText(assistantStatus, result.communication?.status || "Bozza pronta"); - setText(assistantComposeNote, result.message || "Bozza generata e pronta per la revisione."); - - if (recipientCategorySelect && audienceSelect) { - recipientCategorySelect.value = audienceSelect.value; - } - - if (result.state) { - applyAppState(result.state); - } - - unlockSteps(["assistant-review", "assistant-feedback"]); - resetRatingState(); - goTo("assistant", "assistant-review"); + setText(assistantComposeNote, result.message || "Bozza generata."); + goTo("assistant", "assistant-result"); } catch (error) { setText(assistantComposeNote, humanApiError(error)); } finally { - if (generateButton) { - generateButton.disabled = false; - } - } -} - -generateButton?.addEventListener("click", () => { - generateDraft(); -}); - -regenerateButton?.addEventListener("click", () => { - generateDraft(); -}); - -function resetDraft() { - setValue(generatedTitleInput, ""); - setValue(generatedBodyInput, ""); - setValue(recipientCategorySelect, ""); - setValue(recipientEmailInput, ""); - resetCover(); - setText(assistantStatus, "Bozza annullata"); - setText(assistantComposeNote, "Bozza annullata. Puoi modificare il prompt e generarne una nuova."); - updateMeta(); - resetRatingState(); - setStepLocked("assistant-review", true); - setStepLocked("assistant-feedback", true); - goTo("assistant", "assistant-compose"); -} - -cancelDraftButton?.addEventListener("click", resetDraft); - -coverUploadButton?.addEventListener("click", () => { - coverFileInput?.click(); -}); - -coverFileInput?.addEventListener("change", () => { - const file = coverFileInput.files?.[0]; - if (!file) { - return; - } - - const supportedTypes = ["image/png", "image/jpeg", "image/webp"]; - if (!supportedTypes.includes(file.type)) { - setText(assistantStatus, "Formato immagine non supportato. Usa PNG, JPG o WebP."); - coverFileInput.value = ""; - return; - } - - if (file.size > 5 * 1024 * 1024) { - setText(assistantStatus, "Immagine troppo pesante. Scegli un file sotto 5 MB."); - coverFileInput.value = ""; - return; - } - - if (coverObjectUrl) { - URL.revokeObjectURL(coverObjectUrl); - } - coverObjectUrl = URL.createObjectURL(file); - if (coverPreview) { - coverPreview.style.backgroundImage = `linear-gradient(rgba(15, 23, 32, 0.18), rgba(15, 23, 32, 0.42)), url("${coverObjectUrl}")`; - coverPreview.classList.add("has-cover"); - } - setText(coverLabel, file.name); - setText(assistantStatus, "Cover sostituita."); -}); - -savePromptButton?.addEventListener("click", () => { - if (!validatePrompt()) { - return; - } - - setText(assistantComposeNote, "Salvataggio prompt dedicato predisposto; genera una bozza per registrare lo storico."); -}); - -saveDraftButton?.addEventListener("click", () => { - setText(assistantStatus, "Bozza salvata"); -}); - -exportButton?.addEventListener("click", () => { - const format = exportFormatSelect?.value || "PDF"; - setText(assistantStatus, `${format} pronto per il download`); -}); - -function parseExplicitRecipients(value) { - return value - .split(/[\s,;]+/) - .map((email) => email.trim()) - .filter(Boolean); -} - -function isValidEmail(email) { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -sendButton?.addEventListener("click", () => { - const category = recipientCategorySelect?.value || ""; - const explicitRecipients = parseExplicitRecipients(recipientEmailInput?.value || ""); - const invalidRecipients = explicitRecipients.filter((email) => !isValidEmail(email)); - - if (!category && explicitRecipients.length === 0) { - setText(assistantStatus, "Seleziona una categoria o indica almeno una persona."); - recipientCategorySelect?.focus(); - return; - } - - if (invalidRecipients.length > 0) { - setText(assistantStatus, `Controlla questi indirizzi: ${invalidRecipients.join(", ")}`); - recipientEmailInput?.focus(); - return; - } - - if (!ratingSubmitted) { - setText(assistantStatus, "Valuta la bozza prima dell'invio."); - setText(ratingNote, "Seleziona e invia una valutazione prima di procedere."); - ratingNote?.classList.remove("hidden"); - goTo("assistant", "assistant-feedback"); - return; - } - - const parts = []; - if (category) { - parts.push(category); - } - if (explicitRecipients.length > 0) { - parts.push(`${explicitRecipients.length} destinatari specifici`); - } - - setText(assistantStatus, `Invio registrato per: ${parts.join(" + ")}`); -}); - -generatedBodyInput?.addEventListener("input", updateMeta); -updateMeta(); - -function applyPromptFilters() { - if (!promptHistory) { - return; - } - - const query = (promptSearch?.value || "").trim().toLowerCase(); - const filter = promptFilter?.value || ""; - let visibleCount = 0; - - promptHistory.querySelectorAll(".history-item").forEach((item) => { - const text = item.textContent.toLowerCase(); - const tags = item.dataset.tags || ""; - const matchesQuery = !query || text.includes(query); - const matchesFilter = !filter || tags.includes(filter); - const visible = matchesQuery && matchesFilter; - item.classList.toggle("hidden", !visible); - if (visible) { - visibleCount += 1; - } - }); - - promptEmpty?.classList.toggle("hidden", visibleCount > 0); -} - -promptSearch?.addEventListener("input", applyPromptFilters); -promptFilter?.addEventListener("change", applyPromptFilters); - -promptHistory?.addEventListener("click", (event) => { - const favoriteButton = event.target.closest("[data-favorite]"); - if (favoriteButton) { - const item = favoriteButton.closest(".history-item"); - const tags = new Set((item?.dataset.tags || "").split(" ").filter(Boolean)); - const favorite = favoriteButton.getAttribute("aria-pressed") !== "true"; - - if (favorite) { - tags.add("preferito"); - } else { - tags.delete("preferito"); - } - - if (item) { - item.dataset.tags = Array.from(tags).join(" "); - } - setFavoriteButtonState(favoriteButton, favorite); - applyPromptFilters(); - return; - } - - const button = event.target.closest("[data-reuse-prompt]"); - if (!button) { - return; - } - - if (promptInput) { - promptInput.value = button.dataset.reusePrompt; - } - setText(assistantComposeNote, "Prompt caricato. Puoi modificarlo o generare una nuova bozza."); - goTo("assistant", "assistant-compose"); -}); - -let selectedRating = 0; -let ratingSubmitted = false; -const ratingButtons = document.querySelectorAll("[data-rating]"); -const ratingSubmitButton = document.getElementById("rating-submit-button"); -const ratingNote = document.getElementById("rating-note"); -const ratingComment = document.getElementById("rating-comment"); - -function updateRating(value) { - selectedRating = value; - const toneClass = - selectedRating <= 2 - ? "rating-low" - : selectedRating === 3 - ? "rating-mid" - : "rating-high"; - - ratingButtons.forEach((button) => { - const rating = Number(button.dataset.rating); - const active = rating <= selectedRating; - button.classList.toggle("active", active); - button.classList.toggle("rating-low", active && toneClass === "rating-low"); - button.classList.toggle("rating-mid", active && toneClass === "rating-mid"); - button.classList.toggle("rating-high", active && toneClass === "rating-high"); - button.setAttribute("aria-pressed", String(active)); - }); - ratingNote?.classList.add("hidden"); -} - -ratingButtons.forEach((button) => { - button.addEventListener("click", () => { - updateRating(Number(button.dataset.rating)); - }); -}); - -function resetRatingState() { - selectedRating = 0; - ratingSubmitted = false; - updateRating(0); - ratingButtons.forEach((button) => { - button.disabled = false; - }); - if (ratingComment) { - ratingComment.disabled = false; + generateButton.disabled = false; } - if (ratingSubmitButton) { - ratingSubmitButton.disabled = false; - } - ratingNote?.classList.add("hidden"); } -updateRating(selectedRating); - -ratingSubmitButton?.addEventListener("click", () => { - if (selectedRating === 0) { - setText(ratingNote, "Seleziona una valutazione prima di inviare."); - ratingNote?.classList.remove("hidden"); - return; - } - - setText(ratingNote, "Grazie, feedback registrato."); - ratingNote?.classList.remove("hidden"); - ratingSubmitted = true; - ratingButtons.forEach((button) => { - button.disabled = true; - }); - if (ratingComment) { - ratingComment.disabled = true; - } - ratingSubmitButton.disabled = true; -}); - -function renderPromptHistory(items = []) { - if (!promptHistory) { - return; - } - - promptHistory.replaceChildren(); - items.forEach((item) => { - const row = document.createElement("li"); - row.className = "history-item"; - row.dataset.tags = `${item.style || ""} ${item.tone || ""}`.toLowerCase(); - - const main = document.createElement("div"); - main.className = "history-main"; - - const title = document.createElement("strong"); - title.textContent = item.title || "Bozza senza titolo"; - - const meta = document.createElement("span"); - meta.textContent = `${item.style || "Stile non disponibile"} · ${item.tone || "Tono non disponibile"} · ${item.createdAt || "Data non disponibile"}`; - - const actions = document.createElement("div"); - actions.className = "history-actions"; - - const reuse = document.createElement("button"); - reuse.className = "text-button"; - reuse.type = "button"; - reuse.dataset.reusePrompt = item.prompt || ""; - reuse.textContent = "Riusa"; - - actions.append(reuse); - main.append(title, meta); - row.append(main, actions); - promptHistory.append(row); - }); - - promptEmpty?.classList.toggle("hidden", items.length > 0); -} - -function applyAssistantState(state) { - const metrics = state?.metrics || []; - const history = state?.history || []; - - renderMetricList(assistantMetricList, metrics); - renderMetricList(assistantUsageBreakdown, [ - [String(history.length), "Bozze presenti nello storico"] - ]); - renderMetricList(assistantRatingBreakdown, [ - ["n/d", "Feedback non disponibili"] - ]); - renderMetricList(assistantFeedbackList, [ - ["n/d", "Nessun feedback registrato"] - ]); - renderPromptHistory(history); - applyPromptFilters(); -} - -analyticsExportButton?.addEventListener("click", () => { - const period = assistantMetricPeriod?.value || "Ultimi 30 giorni"; - const channel = assistantMetricChannel?.value || "Tutti"; - downloadTextReport( - "report-metriche-assistant.txt", - [ - "Report metriche AI Assistant", - `Periodo: ${period}`, - `Canale: ${channel}`, - "", - metricRowsText(assistantMetricList), - "", - "Utilizzo operativo", - metricRowsText(assistantUsageBreakdown), - "", - "Qualita percepita", - metricRowsText(assistantRatingBreakdown), - "", - "Feedback recenti", - metricRowsText(assistantFeedbackList) - ].join("\n") - ); - setText(analyticsStatus, `Report Assistant scaricato (${period}, canale: ${channel}).`); - analyticsStatus?.classList.remove("hidden"); -}); - -function updateAssistantAnalytics() { - if (appState?.assistant) { - applyAssistantState(appState.assistant); - return; - } - - renderMetricList(assistantMetricList, [ - ["0", "Contenuti generati"], - ["0", "Bozze da rivedere"], - ["0", "Feedback raccolti"], - ["n/d", "Rating medio"] - ]); - renderMetricList(assistantUsageBreakdown, [["0", "Contenuti disponibili"]]); - renderMetricList(assistantRatingBreakdown, [["n/d", "Feedback non disponibili"]]); - renderMetricList(assistantFeedbackList, [["n/d", "Nessun feedback registrato"]]); - return; - - const period = assistantMetricPeriod?.value || "Ultimi 30 giorni"; - const channel = assistantMetricChannel?.value || "Tutti"; - const shortPeriod = period === "Ultimi 7 giorni"; - const currentMonth = period === "Mese corrente"; - const baseGenerated = shortPeriod ? 14 : currentMonth ? 31 : 38; - const baseSaved = shortPeriod ? 5 : currentMonth ? 10 : 13; - const channelShare = { - "Email interna": 0.48, - "News portale": 0.32, - "Notifica rapida": 0.2 - }; - const channelFactor = channel === "Tutti" ? 1 : channelShare[channel] || 1; - const generated = Math.max(1, Math.round(baseGenerated * channelFactor)); - const saved = Math.max(1, Math.round(baseSaved * channelFactor)); - const rating = channel === "Notifica rapida" ? "4,4" : channel === "News portale" ? "4,7" : "4,6"; - const feedbackTotal = Math.max(2, Math.round((shortPeriod ? 9 : currentMonth ? 24 : 31) * channelFactor)); - const dailyAverage = Math.max(1, Math.round(generated / (shortPeriod ? 7 : 30))); - - renderMetricList(assistantMetricList, [ - [String(generated), "Contenuti generati"], - [String(saved), "Prompt salvati"], - [rating, "Rating medio"], - [String(feedbackTotal), "Feedback raccolti"] - ]); - - const channelRows = channel === "Tutti" - ? Object.entries(channelShare).map(([name, share]) => [name, `${Math.max(1, Math.round(baseGenerated * share))} contenuti`]) - : [ - [channel, `${generated} contenuti`], - ["Prompt salvati", `${saved} configurazioni`], - ["Media giornaliera", `${dailyAverage} contenuti/giorno`] - ]; - - renderMetricList(assistantUsageBreakdown, channelRows); - - renderMetricList(assistantRatingBreakdown, [ - ["5 stelle", `${Math.max(1, Math.round(feedbackTotal * 0.58))} feedback`], - ["4 stelle", `${Math.max(1, Math.round(feedbackTotal * 0.34))} feedback`], - ["3 stelle o meno", `${Math.max(0, feedbackTotal - Math.round(feedbackTotal * 0.58) - Math.round(feedbackTotal * 0.34))} feedback`] - ]); - - renderMetricList(assistantFeedbackList, [ - ["5/5", channel === "Tutti" ? "Testo pronto senza correzioni rilevanti" : `Output efficace per ${channel.toLowerCase()}`], - ["4/5", shortPeriod ? "Richieste più sintetiche nei prompt recenti" : "Buona struttura, apertura da rendere più sintetica"] - ]); -} - -assistantMetricPeriod?.addEventListener("change", updateAssistantAnalytics); -assistantMetricChannel?.addEventListener("change", updateAssistantAnalytics); -updateAssistantAnalytics(); +generateButton?.addEventListener("click", generateDraft); const uploadBox = document.getElementById("upload-box"); const documentFileInput = document.getElementById("document-file-input"); const uploadState = document.getElementById("upload-state"); const uploadOutput = document.getElementById("upload-output"); -const copilotMetricPeriod = document.getElementById("copilot-metric-period"); -const copilotMetricStatus = document.getElementById("copilot-metric-status"); -const copilotMetricList = document.getElementById("copilot-metric-list"); -const copilotDocumentBreakdown = document.getElementById("copilot-document-breakdown"); -const copilotQualityBreakdown = document.getElementById("copilot-quality-breakdown"); -const copilotExportButton = document.getElementById("copilot-export-button"); -const copilotExportStatus = document.getElementById("copilot-export-status"); -const documentSearch = document.getElementById("document-search"); -const documentDeliveryFilter = document.getElementById("document-delivery-filter"); -const documentConfidenceMode = document.getElementById("document-confidence-mode"); -const documentConfidenceThreshold = document.getElementById("document-confidence-threshold"); -const documentMonthFilter = document.getElementById("document-month-filter"); -const documentYearFilter = document.getElementById("document-year-filter"); -const documentPageSizeSelect = document.getElementById("document-page-size-select"); -const documentResetFilters = document.getElementById("document-reset-filters"); -const documentFilterStatus = document.getElementById("document-filter-status"); +const documentSummaryList = document.getElementById("document-summary-list"); const documentHistory = document.getElementById("document-history"); const documentEmpty = document.getElementById("document-empty"); -const documentSummaryList = document.getElementById("document-summary-list"); -const documentPageInfo = document.getElementById("document-page-info"); -const documentPrevButton = document.getElementById("document-prev-button"); -const documentNextButton = document.getElementById("document-next-button"); const copilotDetailSection = document.getElementById("copilot-detail"); const detailTitle = document.getElementById("detail-title"); const detailPreviewTitle = document.getElementById("detail-preview-title"); const detailPreviewMeta = document.getElementById("detail-preview-meta"); const detailPreviewLines = document.getElementById("detail-preview-lines"); +const detailPreviewFrame = document.getElementById("detail-preview-frame"); const detailEmployeeInput = document.getElementById("detail-employee-input"); const detailCompanyInput = document.getElementById("detail-company-input"); const detailFileInput = document.getElementById("detail-file-input"); @@ -1054,1045 +269,150 @@ const detailPagesInput = document.getElementById("detail-pages-input"); const detailTypeInput = document.getElementById("detail-type-input"); const detailDescriptionInput = document.getElementById("detail-description-input"); const detailConfidenceInput = document.getElementById("detail-confidence-input"); -const detailDeliveryStatusInput = document.getElementById("detail-delivery-status-input"); -const detailEditButton = document.getElementById("detail-edit-button"); -const detailSaveButton = document.getElementById("detail-save-button"); -const detailCancelButton = document.getElementById("detail-cancel-button"); -const detailSendButton = document.getElementById("detail-send-button"); -const detailStatusMessage = document.getElementById("detail-status-message"); -const detailSendDraft = document.getElementById("detail-send-draft"); -const sendRecipientInput = document.getElementById("send-recipient-input"); -const sendSubjectInput = document.getElementById("send-subject-input"); -const sendBodyInput = document.getElementById("send-body-input"); -const sendConfirmButton = document.getElementById("send-confirm-button"); -const sendCancelButton = document.getElementById("send-cancel-button"); -const sendStatusMessage = document.getElementById("send-status-message"); -let currentAnalysisDocumentId = "cedolino-giulia-conti"; -let activeDocumentId = currentAnalysisDocumentId; -let detailPreviewMode = "original"; -let currentDocumentPage = 1; -let documentPageSize = 6; const confidenceReviewThreshold = 80; -const documentDetails = { - "cedolino-marco-rinaldi": { - title: "Cedolino mensile - Marco Rinaldi", - batchId: "cedolini-aprile-2026", - sourceTitle: "Lotto cedolini aprile 2026", - employee: "Marco Rinaldi", - company: "Eggon S.r.l.", - file: "cedolini-aprile-2026.pdf", - date: "30/04/2026", - pages: "1", - type: "Cedolino mensile", - description: "Cedolino mensile estratto dal lotto di aprile.", - confidence: "98%", - deliveryStatus: "Non inviato", - previewLines: [ - "Pagina 1: Marco Rinaldi - cedolino mensile", - "Pagina 2: Elena Ferri - cedolino mensile", - "Pagina 3: Giulia Conti - confidenza destinatario 78%" - ], - recipients: [ - { name: "Marco Rinaldi", page: "pagina 1", confidence: 98, status: "confirmed", documentId: "cedolino-marco-rinaldi" } - ] - }, - "cedolino-elena-ferri": { - title: "Cedolino mensile - Elena Ferri", - batchId: "cedolini-aprile-2026", - sourceTitle: "Lotto cedolini aprile 2026", - employee: "Elena Ferri", - company: "Eggon S.r.l.", - file: "cedolini-aprile-2026.pdf", - date: "30/04/2026", - pages: "1", - type: "Cedolino mensile", - description: "Cedolino mensile estratto dal lotto di aprile.", - confidence: "95%", - deliveryStatus: "Non inviato", - previewLines: [ - "Pagina 1: Marco Rinaldi - cedolino mensile", - "Pagina 2: Elena Ferri - cedolino mensile", - "Pagina 3: Giulia Conti - confidenza destinatario 78%" - ], - recipients: [ - { name: "Elena Ferri", page: "pagina 2", confidence: 95, status: "confirmed", documentId: "cedolino-elena-ferri" } - ] - }, - "cedolino-giulia-conti": { - title: "Cedolino mensile - Giulia Conti", - batchId: "cedolini-aprile-2026", - sourceTitle: "Lotto cedolini aprile 2026", - employee: "Giulia Conti", - company: "Eggon S.r.l.", - file: "cedolini-aprile-2026.pdf", - date: "30/04/2026", - pages: "1", - type: "Cedolino mensile", - description: "Cedolino mensile estratto dal lotto di aprile.", - confidence: "78%", - deliveryStatus: "Non inviato", - previewLines: [ - "Pagina 1: Marco Rinaldi - cedolino mensile", - "Pagina 2: Elena Ferri - cedolino mensile", - "Pagina 3: Giulia Conti - confidenza destinatario 78%" - ], - recipients: [ - { name: "Giulia Conti", page: "pagina 3", confidence: 78, status: "needs-review", documentId: "cedolino-giulia-conti" } - ] - }, - "contratto-onboarding": { - title: "Contratto onboarding - Luca Bianchi", - batchId: "contratto-onboarding", - employee: "Luca Bianchi", - company: "Nexum Labs", - file: "onboarding-luca-bianchi.pdf", - date: "12/04/2026", - pages: "8", - type: "Contratto", - description: "Contratto di assunzione con allegati amministrativi.", - confidence: "96%", - deliveryStatus: "Non inviato", - previewLines: [ - "Pagina 1: dati anagrafici e azienda", - "Pagine 2-6: clausole contrattuali", - "Pagine 7-8: allegati amministrativi" - ], - recipients: [ - { name: "Luca Bianchi", page: "documento completo", confidence: 96, status: "confirmed", documentId: "contratto-onboarding" } - ] - }, - "comunicazione-benefit": { - title: "Comunicazione benefit - Dipendenti sede centrale", - batchId: "comunicazione-benefit", - employee: "Dipendenti sede centrale", - company: "Eggon S.r.l.", - file: "benefit-marzo-2026.pdf", - date: "25/03/2026", - pages: "2", - type: "Comunicazione HR", - description: "Comunicazione interna sui benefit aziendali.", - confidence: "98%", - deliveryStatus: "Inviato", - previewLines: [ - "Pagina 1: riepilogo benefit", - "Pagina 2: istruzioni di accesso al portale" - ], - recipients: [ - { name: "Dipendenti sede centrale", page: "documento completo", confidence: 98, status: "sent", documentId: "comunicazione-benefit" } - ] - }, - "cedolino-sara-martini": { - title: "Cedolino mensile - Sara Martini", - batchId: "cedolini-aprile-2026-b", - employee: "Sara Martini", - company: "Eggon S.r.l.", - file: "cedolini-aprile-2026-b.pdf", - date: "30/04/2026", - pages: "1", - type: "Cedolino mensile", - description: "Cedolino mensile estratto dal secondo lotto di aprile.", - confidence: "91%", - deliveryStatus: "Non inviato", - previewLines: [ - "Pagina 1: Sara Martini - cedolino mensile", - "Pagina 2: Paolo Greco - cedolino mensile", - "Pagina 3: Nadia Costa - confidenza destinatario 76%" - ], - recipients: [ - { name: "Sara Martini", page: "pagina 1", confidence: 91, status: "confirmed", documentId: "cedolino-sara-martini" } - ] - }, - "cedolino-paolo-greco": { - title: "Cedolino mensile - Paolo Greco", - batchId: "cedolini-aprile-2026-b", - employee: "Paolo Greco", - company: "Eggon S.r.l.", - file: "cedolini-aprile-2026-b.pdf", - date: "30/04/2026", - pages: "1", - type: "Cedolino mensile", - description: "Cedolino mensile estratto dal secondo lotto di aprile.", - confidence: "93%", - deliveryStatus: "Inviato", - previewLines: [ - "Pagina 1: Sara Martini - cedolino mensile", - "Pagina 2: Paolo Greco - cedolino mensile", - "Pagina 3: Nadia Costa - confidenza destinatario 76%" - ], - recipients: [ - { name: "Paolo Greco", page: "pagina 2", confidence: 93, status: "sent", documentId: "cedolino-paolo-greco" } - ] - }, - "cedolino-nadia-costa": { - title: "Cedolino mensile - Nadia Costa", - batchId: "cedolini-aprile-2026-b", - employee: "Nadia Costa", - company: "Eggon S.r.l.", - file: "cedolini-aprile-2026-b.pdf", - date: "30/04/2026", - pages: "1", - type: "Cedolino mensile", - description: "Cedolino mensile estratto dal secondo lotto di aprile.", - confidence: "76%", - deliveryStatus: "Non inviato", - previewLines: [ - "Pagina 1: Sara Martini - cedolino mensile", - "Pagina 2: Paolo Greco - cedolino mensile", - "Pagina 3: Nadia Costa - confidenza destinatario 76%" - ], - recipients: [ - { name: "Nadia Costa", page: "pagina 3", confidence: 76, status: "needs-review", documentId: "cedolino-nadia-costa" } - ] - }, - "contratto-maria-neri": { - title: "Contratto onboarding - Maria Neri", - batchId: "contratto-maria-neri", - employee: "Maria Neri", - company: "Nexum Labs", - file: "onboarding-maria-neri.pdf", - date: "18/04/2026", - pages: "7", - type: "Contratto", - description: "Contratto di assunzione con allegati amministrativi.", - confidence: "94%", - deliveryStatus: "Non inviato", - previewLines: [ - "Pagina 1: dati anagrafici e azienda", - "Pagine 2-6: condizioni contrattuali", - "Pagina 7: allegato amministrativo" - ], - recipients: [ - { name: "Maria Neri", page: "documento completo", confidence: 94, status: "confirmed", documentId: "contratto-maria-neri" } - ] - }, - "comunicazione-welfare": { - title: "Comunicazione welfare - Team HR", - batchId: "comunicazione-welfare", - employee: "Team HR", - company: "Eggon S.r.l.", - file: "welfare-aprile-2026.pdf", - date: "05/04/2026", - pages: "2", - type: "Comunicazione HR", - description: "Comunicazione interna sul piano welfare aggiornato.", - confidence: "97%", - deliveryStatus: "Inviato", - previewLines: [ - "Pagina 1: riepilogo piano welfare", - "Pagina 2: modalita di accesso" - ], - recipients: [ - { name: "Team HR", page: "documento completo", confidence: 97, status: "sent", documentId: "comunicazione-welfare" } - ] - } -}; - -Object.keys(documentDetails).forEach((key) => { - delete documentDetails[key]; -}); - -const copilotRun = { - processed: false, - recipients: [] -}; - -let selectedRecipientId = "giulia-conti"; - -let currentDocumentState = { - label: "Da verificare", - statusClass: "", - itemClass: "needs-review", - stateTags: "verifica bassa-confidenza" -}; - -function isDocumentSent(detail) { - return detail?.deliveryStatus === "Inviato" || (detail?.recipients || []).some((recipient) => recipient.status === "sent"); -} - -function displayDeliveryStatus(detail) { - return isDocumentSent(detail) ? "Inviato" : "Non inviato"; -} - -function confidenceNumber(detail) { - const parsed = Number.parseInt(String(detail?.confidence || "").replace("%", ""), 10); - return Number.isFinite(parsed) ? parsed : 0; +function confidenceValue(documentItem) { + return Number.isFinite(Number(documentItem?.confidence)) ? Number(documentItem.confidence) : null; } -function dateParts(detail) { - const match = String(detail?.date || "").match(/^(\d{2})\/(\d{2})\/(\d{4})$/); - return { - month: match?.[2] || "", - year: match?.[3] || "" - }; -} - -function createRecipient(name, confidence, page, status, note, documentId) { - return { - id: name.toLowerCase().replace(/\s+/g, "-"), - documentId, - name, - confidence, - page, - status, - note - }; -} - -function getBatchDocumentIds(documentId) { - const batchId = documentDetails[documentId]?.batchId || documentId; - return Object.keys(documentDetails).filter((id) => (documentDetails[id].batchId || id) === batchId); -} - -function recipientFromDetail(documentId) { - const detail = documentDetails[documentId]; - const sourceRecipient = detail?.recipients?.[0]; - - return createRecipient( - detail?.employee || "Non disponibile", - sourceRecipient?.confidence || confidenceNumber(detail), - sourceRecipient?.page || "documento completo", - sourceRecipient?.status || (isDocumentSent(detail) ? "sent" : "confirmed"), - sourceRecipient?.status === "needs-review" - ? "Richiede conferma operatore" - : sourceRecipient?.status === "sent" || isDocumentSent(detail) - ? "Invio già tracciato" - : "Confermato da OCR", - documentId - ); -} - -function documentStateFromDetail(detail) { - const recipients = detail.recipients || []; - const hasReview = recipients.some((recipient) => recipient.status === "needs-review"); - const hasSent = isDocumentSent(detail); - - if (hasSent) { - return { - label: "Inviato", - statusClass: "sent", - itemClass: "", - stateTags: `inviato ${detail.type} ${detail.company} ${detail.employee}` - }; - } - - if (hasReview) { - return { - label: "Da verificare", - statusClass: "", - itemClass: "needs-review", - stateTags: `verifica bassa-confidenza ${detail.type} ${detail.company} ${detail.employee}` - }; +function documentStatusClass(documentItem) { + const confidence = confidenceValue(documentItem); + if (confidence === null || confidence < confidenceReviewThreshold) { + return "needs-review"; } - return { - label: "Confermato", - statusClass: "confirmed", - itemClass: "confirmed", - stateTags: `confermato ${detail.type} ${detail.company} ${detail.employee}` - }; + return "confirmed"; } -function resetCopilotRecipients() { - copilotRun.recipients = [ - createRecipient("Marco Rinaldi", 98, "pagina 1", "confirmed", "Destinatario rilevato nello stesso lotto", "cedolino-marco-rinaldi"), - createRecipient("Elena Ferri", 95, "pagina 2", "confirmed", "Destinatario rilevato nello stesso lotto", "cedolino-elena-ferri"), - createRecipient("Giulia Conti", 78, "pagina 3", "needs-review", "Richiede conferma operatore", "cedolino-giulia-conti") - ]; - selectedRecipientId = "giulia-conti"; - currentAnalysisDocumentId = "cedolino-giulia-conti"; +function renderDocumentSummary() { + const withConfidence = documents.filter((item) => confidenceValue(item) !== null).length; + const needsReview = documents.filter((item) => { + const confidence = confidenceValue(item); + return confidence === null || confidence < confidenceReviewThreshold; + }).length; + + renderMetricList(documentSummaryList, [ + [String(documents.length), "Sotto-documenti"], + [String(withConfidence), "Campi con confidenza"], + [String(needsReview), "Da verificare"] + ]); } -function syncRecipientsIntoDocument() { - if (copilotRun.recipients.length === 0) { +function renderDocuments() { + if (!documentHistory) { return; } - const previewLines = copilotRun.recipients.map((recipient) => ( - `${recipient.page}: ${recipient.name} - confidenza ${recipient.confidence}%` - )); - - copilotRun.recipients.forEach((recipient) => { - const detail = documentDetails[recipient.documentId]; - if (!detail) { - return; - } - - detail.employee = recipient.name; - detail.confidence = `${recipient.confidence}%`; - detail.deliveryStatus = recipient.status === "sent" ? "Inviato" : "Non inviato"; - detail.previewLines = previewLines; - detail.recipients = [{ - name: recipient.name, - page: recipient.page, - confidence: recipient.confidence, - status: recipient.status, - documentId: recipient.documentId - }]; - }); -} + documentHistory.replaceChildren(); + renderDocumentSummary(); + documentEmpty?.classList.toggle("hidden", documents.length > 0); -function buildDocumentTags(documentId, state) { - const detail = documentDetails[documentId]; - return [ - state.stateTags, - detail.type, - detail.company, - detail.employee, - detail.file, - detail.date, - displayDeliveryStatus(detail).toLowerCase().replace(" ", "-"), - `confidenza-${confidenceNumber(detail)}` - ] - .join(" ") - .toLowerCase(); -} + documents.forEach((documentItem) => { + const row = document.createElement("li"); + row.className = `history-item document-item ${documentStatusClass(documentItem)}`; + row.classList.toggle("selected", documentItem.id === activeDocumentId); -function updateDocumentCard(documentId, state = documentStateFromDetail(documentDetails[documentId])) { - const item = document.querySelector(`[data-document-id="${documentId}"]`); - const status = item?.querySelector("[data-document-status]"); - const summary = item?.querySelector("[data-document-summary]"); - const meta = item?.querySelector(".document-meta"); - const detail = documentDetails[documentId]; - if (!item || !status || !detail) { - return; - } + const main = document.createElement("div"); + main.className = "history-main"; - item.dataset.tags = buildDocumentTags(documentId, state); - item.classList.toggle("needs-review", state.itemClass === "needs-review"); - item.classList.toggle("confirmed", state.itemClass === "confirmed"); - item.classList.toggle("sent", state.statusClass === "sent"); - status.textContent = state.label; - status.className = state.statusClass - ? `document-status ${state.statusClass}` - : "document-status"; - - if (summary) { - summary.textContent = `${detail.employee} · ${detail.company} · confidenza ${detail.confidence || "Non disponibile"}`; - } + const title = document.createElement("strong"); + title.textContent = documentItem.title || documentItem.file || "Documento rilevato"; - if (meta) { - meta.replaceChildren(); - [ - `File: ${detail.file}`, - `Data: ${detail.date}`, - detail.pages === "1" ? "Pagina singola" : `Pagine: ${detail.pages}` - ].forEach((value) => { - const itemMeta = document.createElement("span"); - itemMeta.textContent = value; - meta.append(itemMeta); + const meta = document.createElement("span"); + const confidence = confidenceValue(documentItem); + meta.textContent = [ + documentItem.employee || "Destinatario non disponibile", + documentItem.company || "Azienda non disponibile", + confidence === null ? "Confidenza n/d" : `Confidenza ${confidence}%` + ].join(" · "); + + const action = document.createElement("button"); + action.className = "text-button"; + action.type = "button"; + action.textContent = "Apri"; + action.addEventListener("click", () => { + showDocumentDetail(documentItem.id); }); - } -} -function updateCurrentDocumentCard() { - updateDocumentCard(currentAnalysisDocumentId, currentDocumentState); -} - -function updateAllDocumentCards() { - applyDocumentFilters({ keepPage: true }); -} - -const detailEditableInputs = [ - detailEmployeeInput, - detailCompanyInput, - detailDateInput, - detailTypeInput, - detailDescriptionInput -]; - -const detailReadonlyInputs = [ - detailFileInput, - detailPagesInput, - detailConfidenceInput, - detailDeliveryStatusInput -]; - -function setDetailEditMode(editing) { - detailEditableInputs.forEach((input) => { - if (input) { - input.readOnly = !editing; - input.disabled = !editing; - input.tabIndex = editing ? 0 : -1; - } - }); - detailReadonlyInputs.forEach((input) => { - if (input) { - input.readOnly = true; - input.disabled = true; - input.tabIndex = -1; - } + main.append(title, meta); + row.append(main, action); + documentHistory.append(row); }); - document.querySelector(".document-inspector")?.classList.toggle("is-editing", editing); - detailEditButton?.classList.toggle("hidden", editing); - detailSaveButton?.classList.toggle("hidden", !editing); - detailCancelButton?.classList.toggle("hidden", !editing); } -function setDetailValues(detail) { - const inspector = document.querySelector(".document-inspector"); - inspector?.classList.toggle("confidence-low", confidenceNumber(detail) < confidenceReviewThreshold); - inspector?.classList.toggle("confidence-high", confidenceNumber(detail) >= confidenceReviewThreshold); - - setText(detailTitle, detail.title); - setValue(detailEmployeeInput, detail.employee); - setValue(detailCompanyInput, detail.company); - setValue(detailFileInput, detail.file); - setValue(detailDateInput, detail.date); - setValue(detailPagesInput, detail.pages); - setValue(detailTypeInput, detail.type); - setValue(detailDescriptionInput, detail.description); - setValue(detailConfidenceInput, detail.confidence || "Non disponibile"); - setValue(detailDeliveryStatusInput, displayDeliveryStatus(detail)); -} - -function renderDetailPreview(detail) { - setText(detailPreviewTitle, detail.title); - const pageText = detail.pages === "1" ? "1 pagina" : `${detail.pages} pagine`; - setText(detailPreviewMeta, `${detail.file} · ${pageText}`); - - document.querySelectorAll("[data-detail-preview]").forEach((button) => { - button.classList.toggle("active", button.dataset.detailPreview === detailPreviewMode); - }); - - const recipients = detail.recipients || []; - const selectedRecipient = recipients.find((recipient) => recipient.name === detail.employee) || recipients[0]; - const splitLines = selectedRecipient - ? [`${selectedRecipient.page}: ${selectedRecipient.name} - ${detail.type}`] - : [detail.previewLines?.[0]].filter(Boolean); - const fullLines = detail.previewLines?.length ? detail.previewLines : splitLines; - const lines = detailPreviewMode === "split" - ? splitLines - : fullLines; - - if (!detailPreviewLines) { +function renderDocumentPreview(documentItem) { + if (!detailPreviewFrame) { return; } - detailPreviewLines.replaceChildren(); - detailPreviewLines.classList.toggle("pdf-preview-mode", detailPreviewMode === "original"); - - if (detailPreviewMode === "original") { - const frame = document.createElement("div"); - frame.className = "pdf-placeholder-frame"; - - const chrome = document.createElement("div"); - chrome.className = "pdf-placeholder-chrome"; - - const title = document.createElement("strong"); - title.textContent = "Anteprima PDF originale"; - - const hint = document.createElement("span"); - hint.textContent = "Viewer integrato previsto"; + detailPreviewFrame.replaceChildren(); - const body = document.createElement("div"); - body.className = "pdf-placeholder-body"; - body.textContent = `${detail.file} - apertura del documento originale direttamente nell'applicativo.`; - - chrome.append(title, hint); - frame.append(chrome, body); - detailPreviewLines.append(frame); + if (!documentItem.previewUrl) { + const fallback = document.createElement("span"); + fallback.textContent = "Preview documento non disponibile."; + detailPreviewFrame.append(fallback); return; } - (lines.length ? lines : ["Anteprima non disponibile"]).forEach((line) => { - const item = document.createElement("span"); - item.textContent = line; - detailPreviewLines.append(item); - }); + const iframe = document.createElement("iframe"); + iframe.src = documentItem.previewUrl; + iframe.title = `Preview ${documentItem.file || "documento"}`; + iframe.loading = "lazy"; + detailPreviewFrame.append(iframe); } -function updateDocumentDetail(documentId = activeDocumentId) { - const detail = documentDetails[documentId]; - if (!detail || !copilotDetailSection) { +function showDocumentDetail(documentId) { + const documentItem = documents.find((item) => item.id === documentId); + if (!documentItem) { return; } activeDocumentId = documentId; - setDetailValues(detail); - renderDetailPreview(detail); - setDetailEditMode(false); - documentHistory?.querySelectorAll("[data-document-detail]").forEach((button) => { - button.classList.toggle("active", button.dataset.documentDetail === documentId); - }); -} - -function hideSendDraft() { - detailSendDraft?.classList.add("is-hidden"); -} - -function buildSendDraft(detail) { - setValue(sendRecipientInput, detail.employee); - setValue(sendSubjectInput, `${detail.type} disponibile`); + renderDocuments(); + + setText(detailTitle, documentItem.title || "Documento rilevato"); + setText(detailPreviewTitle, documentItem.file || "File non disponibile"); + setText(detailPreviewMeta, `${documentItem.pages || "n/d"} pagine · ${documentItem.type || "Tipologia non disponibile"}`); + setValue(detailEmployeeInput, documentItem.employee || "Non disponibile"); + setValue(detailCompanyInput, documentItem.company || "Non disponibile"); + setValue(detailFileInput, documentItem.file || "Non disponibile"); + setValue(detailDateInput, documentItem.date || "Non disponibile"); + setValue(detailPagesInput, documentItem.pages || "Non disponibile"); + setValue(detailTypeInput, documentItem.type || "Non disponibile"); + setValue(detailDescriptionInput, documentItem.description || "Non disponibile"); setValue( - sendBodyInput, - `Gentile ${detail.employee},\n\nil documento "${detail.type}" del ${detail.date} è disponibile in allegato.\n\nCordiali saluti.` + detailConfidenceInput, + confidenceValue(documentItem) === null ? "Non disponibile" : `${documentItem.confidence}%` ); - setText(sendStatusMessage, "Il documento selezionato viene allegato automaticamente."); - detailSendDraft?.classList.remove("is-hidden"); -} - -function showDocumentDetail(documentId, { openSend = false } = {}) { - copilotDetailSection?.classList.remove("is-hidden"); - detailPreviewMode = "original"; - updateDocumentDetail(documentId); - - if (openSend) { - buildSendDraft(documentDetails[documentId]); - } else { - hideSendDraft(); - } - setText(detailStatusMessage, "Seleziona Modifica per correggere i campi consentiti dall'ADR."); - goTo("copilot", "copilot-detail"); -} - -function saveDetailChanges() { - const detail = documentDetails[activeDocumentId]; - if (!detail) { - return; - } - - detail.employee = detailEmployeeInput?.value.trim() || "Non disponibile"; - detail.company = detailCompanyInput?.value.trim() || "Non disponibile"; - detail.date = detailDateInput?.value.trim() || "Non disponibile"; - detail.type = detailTypeInput?.value.trim() || "Non disponibile"; - detail.description = detailDescriptionInput?.value.trim() || "Non disponibile"; - - const recipient = copilotRun.recipients.find((item) => item.documentId === activeDocumentId); - if (recipient) { - recipient.name = detail.employee; - recipient.status = recipient.status === "sent" ? "sent" : "confirmed"; - recipient.note = "Dati confermati dall'operatore"; - selectedRecipientId = recipient.id; - currentAnalysisDocumentId = activeDocumentId; - } - - detail.recipients = [{ - ...(detail.recipients?.[0] || {}), - name: detail.employee, - status: recipient?.status || detail.recipients?.[0]?.status || "confirmed", - documentId: activeDocumentId - }]; - detail.deliveryStatus = detail.recipients[0].status === "sent" ? "Inviato" : "Non inviato"; - - syncRecipientsIntoDocument(); - updateAllDocumentCards(); - updateDocumentDetail(activeDocumentId); - setText(detailStatusMessage, "Modifiche salvate sui campi editabili. L'invio selezionato è pronto."); -} - -function confirmDetailSend() { - const detail = documentDetails[activeDocumentId]; - const recipient = sendRecipientInput?.value.trim() || ""; - const subject = sendSubjectInput?.value.trim() || ""; - const body = sendBodyInput?.value.trim() || ""; - - if (!detail || !recipient || !subject || !body) { - setText(sendStatusMessage, "Destinatario, oggetto e testo sono obbligatori prima dell'invio."); - return; - } - - detail.deliveryStatus = "Inviato"; - detail.recipients = (detail.recipients || []).map((item) => ({ - ...item, - status: "sent", - documentId: activeDocumentId - })); - - const runRecipient = copilotRun.recipients.find((item) => item.documentId === activeDocumentId); - if (runRecipient) { - runRecipient.status = "sent"; - runRecipient.note = "Invio confermato dall'operatore"; - selectedRecipientId = runRecipient.id; - currentAnalysisDocumentId = activeDocumentId; - currentDocumentState = { - label: "Inviato", - statusClass: "sent", - itemClass: "", - stateTags: `inviato ${detail.type} ${detail.company} ${detail.employee}` - }; - syncRecipientsIntoDocument(); - } - - hideSendDraft(); - setText(sendStatusMessage, "Invio completato."); - setText(detailStatusMessage, "Invio completato. Stato invio aggiornato."); - updateAllDocumentCards(); - updateDocumentDetail(activeDocumentId); - updateCopilotAnalytics(); -} - -function notAvailable(value) { - return value === null || value === undefined || value === "" ? "Non disponibile" : String(value); -} - -function documentFromApi(item) { - const confidence = item.confidence === null || item.confidence === undefined ? "" : `${item.confidence}%`; - const employee = notAvailable(item.employee); - const company = notAvailable(item.company); - const type = notAvailable(item.type); - - return { - title: item.title || item.file || "Documento", - batchId: item.id, - employee, - company, - file: notAvailable(item.file), - date: notAvailable(item.date), - pages: item.pages ? String(item.pages) : "Non disponibile", - type, - description: notAvailable(item.description), - confidence, - deliveryStatus: item.deliveryStatus || "Da inviare", - previewLines: item.previewLines || [ - "Anteprima non disponibile.", - "Campi non ancora rilevati." - ], - recipients: [ - { - name: employee, - page: item.pages ? `${item.pages} pagina/e` : "Non disponibile", - confidence: Number.isFinite(Number(item.confidence)) ? Number(item.confidence) : 0, - status: item.deliveryStatus === "Inviato" - ? "sent" - : confidence === "" - ? "needs-review" - : "confirmed", - documentId: item.id - } - ] - }; -} - -function applyDocumentState(documents = []) { - Object.keys(documentDetails).forEach((key) => { - delete documentDetails[key]; - }); - - documents.forEach((item) => { - documentDetails[item.id] = documentFromApi(item); + detailPreviewLines?.replaceChildren(); + (documentItem.previewLines || []).forEach((line) => { + const item = document.createElement("span"); + item.textContent = line; + detailPreviewLines?.append(item); }); + renderDocumentPreview(documentItem); - const firstDocumentId = Object.keys(documentDetails)[0] || ""; - activeDocumentId = firstDocumentId; - currentAnalysisDocumentId = firstDocumentId; - selectedRecipientId = firstDocumentId; - copilotRun.processed = documents.length > 0; - copilotRun.recipients = firstDocumentId ? Object.keys(documentDetails).map(recipientFromDetail) : []; - - if (documents.length === 0) { - copilotDetailSection?.classList.add("is-hidden"); - setText(uploadState, "In attesa di caricamento"); - setText(uploadOutput, "Le entry di invio compariranno nello storico sottostante."); - } - - resetDocumentFilters(); - applyDocumentFilters(); -} - -function applyCopilotState(state) { - renderMetricList(copilotMetricList, state?.metrics || []); - renderMetricList(copilotDocumentBreakdown, [ - [String(state?.documents?.length || 0), "Documenti nel periodo"], - [String((state?.documents || []).filter((item) => item.deliveryStatus === "Inviato").length), "Invii già completati"], - [String((state?.documents || []).filter((item) => item.deliveryStatus !== "Inviato").length), "Invii non completati"] - ]); - renderMetricList(copilotQualityBreakdown, [ - [`${confidenceReviewThreshold}%`, "Soglia di revisione umana"], - [String((state?.documents || []).filter((item) => item.confidence === null || item.confidence < confidenceReviewThreshold).length), "Documenti sotto soglia"], - ["n/d", "Tempo medio per documento"] - ]); - applyDocumentState(state?.documents || []); + copilotDetailSection?.classList.remove("is-hidden"); + goTo("copilot", "copilot-detail"); } -function applyAppState(state) { - appState = state || {}; - applyAssistantState(appState.assistant || {}); - applyCopilotState(appState.copilot || {}); - - const assistantMetrics = appState.assistant?.metrics || []; - const copilotMetrics = appState.copilot?.metrics || []; - const drafts = assistantMetrics.find((metric) => metric.label === "Bozze da rivedere")?.value ?? 0; - const review = copilotMetrics.find((metric) => metric.label === "Da verificare")?.value ?? 0; - const ready = copilotMetrics.find((metric) => metric.label === "Pronti per invio")?.value ?? 0; - - renderMetricList(document.getElementById("overview-priority-list"), [ - [String(drafts), "Bozze da rivedere"], - [String(review), "Invii con anomalie"], - [String(ready), "Invii pronti"] - ]); -} +function applyCopilotState(state, focusDetail = false) { + documents = state?.documents || []; + activeDocumentId = documents[0]?.id || null; + renderDocuments(); -function updateCopilotAnalytics() { - if (appState?.copilot) { - applyCopilotState(appState.copilot); - return; + if (activeDocumentId && focusDetail) { + showDocumentDetail(activeDocumentId); } - - renderMetricList(copilotMetricList, [ - ["0", "Documenti analizzati"], - ["0", "Da verificare"], - ["0", "Pronti per invio"], - ["0", "Inviati"], - ["n/d", "Tempo medio analisi"] - ]); - renderMetricList(copilotDocumentBreakdown, [ - ["0", "Documenti nel periodo"], - ["0", "Invii già completati"], - ["0", "Invii non completati"] - ]); - renderMetricList(copilotQualityBreakdown, [ - [`${confidenceReviewThreshold}%`, "Soglia di revisione umana"], - ["0", "Documenti sotto soglia"], - ["n/d", "Tempo medio per documento"] - ]); - return; - - const period = copilotMetricPeriod?.value || "Ultimi 30 giorni"; - const status = copilotMetricStatus?.value || "Tutti"; - const allEntries = Object.values(documentDetails); - const visibleEntries = allEntries.filter((detail) => { - if (status === "Inviato") { - return displayDeliveryStatus(detail) === "Inviato"; - } - - if (status === "Non inviato") { - return displayDeliveryStatus(detail) !== "Inviato"; - } - - if (status === "Sotto soglia") { - return confidenceNumber(detail) < confidenceReviewThreshold; - } - - return true; - }); - const periodFactor = period === "Ultimi 7 giorni" ? 0.42 : period === "Mese corrente" ? 0.78 : 1; - const analyzed = Math.max(visibleEntries.length, Math.round(184 * periodFactor * (visibleEntries.length / allEntries.length))); - const belowVisible = visibleEntries.filter((detail) => confidenceNumber(detail) < confidenceReviewThreshold).length; - const belowShare = visibleEntries.length ? Math.round((belowVisible / visibleEntries.length) * 100) : 0; - const belowAnalyzed = Math.round((analyzed * belowShare) / 100); - const sentEntries = visibleEntries.filter((detail) => displayDeliveryStatus(detail) === "Inviato").length; - const nonSentEntries = visibleEntries.length - sentEntries; - const reviewEntries = visibleEntries.filter((detail) => ( - confidenceNumber(detail) < confidenceReviewThreshold - || detail.recipients?.some((recipient) => recipient.status === "needs-review") - )).length; - const correctRate = status === "Sotto soglia" ? "88%" : status === "Inviato" ? "99%" : "97%"; - const recipientRate = sentEntries > 0 && sentEntries === visibleEntries.length ? "96%" : "94%"; - const averageTime = period === "Ultimi 7 giorni" ? "16 sec" : "18 sec"; - - renderMetricList(copilotMetricList, [ - [String(analyzed), "Documenti analizzati"], - [correctRate, "Classificazioni corrette"], - [`${belowAnalyzed} (${belowShare}%)`, "Documenti sotto soglia di confidenza"], - [recipientRate, "Destinatari riconosciuti automaticamente"], - [averageTime, "Tempo medio analisi"] - ]); - - renderMetricList(copilotDocumentBreakdown, [ - [String(visibleEntries.length), "Invii nel filtro metriche"], - [String(sentEntries), "Invii completati"], - [String(nonSentEntries), "Invii non completati"], - [String(reviewEntries), "Revisioni umane richieste"] - ]); - - renderMetricList(copilotQualityBreakdown, [ - [`${confidenceReviewThreshold}%`, "Soglia di revisione umana"], - [`${belowAnalyzed} (${belowShare}%)`, "Documenti sotto soglia"], - [recipientRate, "Destinatari riconosciuti automaticamente"], - [averageTime, "Tempo medio per documento"] - ]); -} - -function getFilteredDocumentIds() { - const query = (documentSearch?.value || "").trim().toLowerCase(); - const deliveryFilter = documentDeliveryFilter?.value || ""; - const confidenceMode = documentConfidenceMode?.value || ""; - const parsedThreshold = Number.parseInt(documentConfidenceThreshold?.value || String(confidenceReviewThreshold), 10); - const threshold = Number.isFinite(parsedThreshold) ? parsedThreshold : confidenceReviewThreshold; - const monthFilter = documentMonthFilter?.value || ""; - const yearFilter = documentYearFilter?.value || ""; - - return Object.keys(documentDetails).filter((documentId) => { - const detail = documentDetails[documentId]; - const searchableText = `${detail.title} ${detail.employee} ${detail.company} ${detail.file} ${detail.type}`.toLowerCase(); - const deliveryStatus = displayDeliveryStatus(detail) === "Inviato" ? "inviato" : "non-inviato"; - const confidence = confidenceNumber(detail); - const parts = dateParts(detail); - const matchesQuery = !query || searchableText.includes(query); - const matchesDelivery = !deliveryFilter || deliveryStatus === deliveryFilter; - const matchesConfidence = !confidenceMode - || (confidenceMode === "lt" && confidence < threshold) - || (confidenceMode === "gte" && confidence >= threshold); - const matchesMonth = !monthFilter || parts.month === monthFilter; - const matchesYear = !yearFilter || parts.year === yearFilter; - return matchesQuery && matchesDelivery && matchesConfidence && matchesMonth && matchesYear; - }); -} - -function updateDocumentFilterStatus(filteredIds) { - const deliveryFilter = documentDeliveryFilter?.selectedOptions?.[0]?.textContent || "Tutti"; - const confidenceMode = documentConfidenceMode?.selectedOptions?.[0]?.textContent || "Qualsiasi"; - const threshold = documentConfidenceThreshold?.value || String(confidenceReviewThreshold); - const month = documentMonthFilter?.selectedOptions?.[0]?.textContent || "Tutti"; - const year = documentYearFilter?.selectedOptions?.[0]?.textContent || "Tutti"; - const confidenceText = confidenceMode === "Qualsiasi" ? confidenceMode : `${confidenceMode} ${threshold}%`; - const periodText = month === "Tutti" && year === "Tutti" ? "Tutti" : `${month} ${year}`; - - setText( - documentFilterStatus, - `${filteredIds.length} invii trovati - stato: ${deliveryFilter}, confidenza: ${confidenceText}, periodo: ${periodText}.` - ); } -function resetDocumentFilters() { - setValue(documentSearch, ""); - setValue(documentDeliveryFilter, ""); - setValue(documentConfidenceMode, ""); - setValue(documentConfidenceThreshold, String(confidenceReviewThreshold)); - setValue(documentMonthFilter, ""); - setValue(documentYearFilter, ""); -} - -function updateDocumentSummary(filteredIds) { - const details = filteredIds.map((documentId) => documentDetails[documentId]); - const review = details.filter((detail) => detail.recipients?.some((recipient) => recipient.status === "needs-review")).length; - const sent = details.filter((detail) => displayDeliveryStatus(detail) === "Inviato").length; - const ready = details.length - review - sent; - - renderMetricList(documentSummaryList, [ - [String(details.length), "Risultati filtrati"], - [String(review), "Da verificare"], - [String(Math.max(ready, 0)), "Pronti per invio"], - [String(sent), "Inviati"] - ]); -} - -function renderDocumentHistory(filteredIds) { - if (!documentHistory) { - return; - } - - const pageCount = Math.max(1, Math.ceil(filteredIds.length / documentPageSize)); - currentDocumentPage = Math.min(Math.max(currentDocumentPage, 1), pageCount); - const pageStart = (currentDocumentPage - 1) * documentPageSize; - const pageIds = filteredIds.slice(pageStart, pageStart + documentPageSize); - - documentHistory.replaceChildren(); - pageIds.forEach((documentId) => { - const detail = documentDetails[documentId]; - const state = documentStateFromDetail(detail); - const row = document.createElement("li"); - row.className = `document-row document-item ${state.itemClass || state.statusClass || ""}`; - row.dataset.documentId = documentId; - - const recipient = document.createElement("span"); - recipient.className = "document-cell-main"; - recipient.innerHTML = `${detail.employee}${detail.company}`; - - const type = document.createElement("span"); - type.textContent = detail.type; - - const file = document.createElement("span"); - file.className = "document-cell-file"; - file.textContent = detail.file; - - const date = document.createElement("span"); - date.textContent = detail.date; - - const confidence = document.createElement("span"); - confidence.textContent = detail.confidence || "Non disponibile"; - - const status = document.createElement("span"); - status.className = state.statusClass ? `document-status ${state.statusClass}` : "document-status"; - status.textContent = state.label; - - const actions = document.createElement("span"); - actions.className = "history-actions"; - - const detailButton = document.createElement("button"); - detailButton.className = "text-button"; - detailButton.type = "button"; - detailButton.dataset.documentDetail = documentId; - detailButton.textContent = "Consulta"; - - actions.append(detailButton); - row.append(recipient, type, file, date, confidence, status, actions); - documentHistory.append(row); - }); - - for (let index = pageIds.length; index < documentPageSize; index += 1) { - const placeholder = document.createElement("li"); - placeholder.className = "document-row document-row-placeholder"; - placeholder.setAttribute("aria-hidden", "true"); - for (let column = 0; column < 7; column += 1) { - const cell = document.createElement("span"); - cell.textContent = "\u00a0"; - placeholder.append(cell); - } - documentHistory.append(placeholder); - } - - setText(documentPageInfo, `Pagina ${currentDocumentPage} di ${pageCount} - ${filteredIds.length} risultati`); - if (documentPrevButton) { - documentPrevButton.disabled = currentDocumentPage <= 1; - } - if (documentNextButton) { - documentNextButton.disabled = currentDocumentPage >= pageCount; - } -} - -function applyDocumentFilters({ keepPage = false } = {}) { - if (!keepPage) { - currentDocumentPage = 1; - } - - const filteredIds = getFilteredDocumentIds(); - updateDocumentSummary(filteredIds); - updateDocumentFilterStatus(filteredIds); - renderDocumentHistory(filteredIds); - documentEmpty?.classList.toggle("hidden", filteredIds.length > 0); -} - -function setCurrentDocumentState(label, tags, statusClass, itemClass) { - currentDocumentState = { - label, - statusClass, - itemClass, - stateTags: tags - }; - updateCurrentDocumentCard(); -} - -function loadDocumentIntoFlow(documentId) { - const detail = documentDetails[documentId]; - if (!detail) { - return; - } - - currentAnalysisDocumentId = documentId; - copilotRun.processed = true; - copilotRun.recipients = getBatchDocumentIds(documentId).map(recipientFromDetail); - const selectedRecipient = copilotRun.recipients.find((recipient) => recipient.documentId === documentId) - || copilotRun.recipients.find((recipient) => recipient.status === "needs-review") - || copilotRun.recipients[0]; - selectedRecipientId = selectedRecipient?.id || ""; - currentAnalysisDocumentId = selectedRecipient?.documentId || documentId; - - const selectedDetail = documentDetails[currentAnalysisDocumentId] || detail; - setText(uploadState, `Invio selezionato: ${selectedDetail.title}`); - setText(uploadOutput, `${copilotRun.recipients.length} entry disponibili nello storico invii.`); - - currentDocumentState = documentStateFromDetail(selectedDetail); - updateAllDocumentCards(); - if (!copilotDetailSection?.classList.contains("is-hidden")) { - updateDocumentDetail(currentAnalysisDocumentId); - } - goTo("copilot", "copilot-documents"); -} +uploadBox?.addEventListener("click", () => { + documentFileInput?.click(); +}); -async function processUpload(file) { +documentFileInput?.addEventListener("change", async () => { + const file = documentFileInput.files?.[0]; if (!file) { return; } @@ -2100,17 +420,15 @@ async function processUpload(file) { if (file.type !== "application/pdf") { setText(uploadState, "Formato non valido"); setText(uploadOutput, "Carica un file PDF."); + documentFileInput.value = ""; return; } - uploadBox?.classList.add("processing"); - copilotRun.processed = false; - copilotRun.recipients = []; - setText(uploadState, "Analisi automatica in corso"); - setText(uploadOutput, "OCR locale/Textract in corso. Lo storico si aggiorna a fine analisi."); - const formData = new FormData(); formData.append("document", file); + setText(uploadState, "Analisi in corso"); + setText(uploadOutput, "Split iniziale e rilevazione campi in esecuzione."); + uploadBox?.classList.add("processing"); try { const result = await apiRequest(apiRoutes.documentOcr, { @@ -2118,133 +436,25 @@ async function processUpload(file) { body: formData }); - setText(uploadState, "Documento acquisito"); - setText(uploadOutput, result.message || "Campi inizializzati come non disponibili."); - - if (result.state) { - applyAppState(result.state); - } - - goTo("copilot", "copilot-documents"); + applyCopilotState(result.state?.copilot || { documents: result.documents || [] }, true); + setText(uploadState, "Analisi completata"); + setText(uploadOutput, result.message || "Documento analizzato."); } catch (error) { - setText(uploadState, "Analisi non completata"); + setText(uploadState, "Analisi non riuscita"); setText(uploadOutput, humanApiError(error)); } finally { uploadBox?.classList.remove("processing"); - if (documentFileInput) { - documentFileInput.value = ""; - } + documentFileInput.value = ""; } -} - -uploadBox?.addEventListener("click", () => documentFileInput?.click()); - -documentFileInput?.addEventListener("change", () => { - processUpload(documentFileInput.files?.[0]); -}); - -documentSearch?.addEventListener("input", applyDocumentFilters); -documentDeliveryFilter?.addEventListener("change", applyDocumentFilters); -documentConfidenceMode?.addEventListener("change", applyDocumentFilters); -documentConfidenceThreshold?.addEventListener("input", applyDocumentFilters); -documentMonthFilter?.addEventListener("change", applyDocumentFilters); -documentYearFilter?.addEventListener("change", applyDocumentFilters); -documentPageSizeSelect?.addEventListener("change", () => { - const nextSize = Number.parseInt(documentPageSizeSelect.value, 10); - documentPageSize = Number.isFinite(nextSize) ? nextSize : 6; - applyDocumentFilters(); -}); -documentResetFilters?.addEventListener("click", () => { - resetDocumentFilters(); - applyDocumentFilters(); -}); -copilotMetricPeriod?.addEventListener("change", updateCopilotAnalytics); -copilotMetricStatus?.addEventListener("change", updateCopilotAnalytics); -copilotExportButton?.addEventListener("click", () => { - const period = copilotMetricPeriod?.value || "Ultimi 30 giorni"; - const status = copilotMetricStatus?.value || "Tutti"; - downloadTextReport( - "report-metriche-copilot.txt", - [ - "Report metriche AI Co-Pilot", - `Periodo: ${period}`, - `Stato: ${status}`, - "", - metricRowsText(copilotMetricList), - "", - "Volumi e stato invii", - metricRowsText(copilotDocumentBreakdown), - "", - "Qualita OCR", - metricRowsText(copilotQualityBreakdown) - ].join("\n") - ); - setText(copilotExportStatus, `Report Co-Pilot scaricato (${period}, stato: ${status}).`); - copilotExportStatus?.classList.remove("hidden"); -}); -documentPrevButton?.addEventListener("click", () => { - currentDocumentPage -= 1; - applyDocumentFilters({ keepPage: true }); -}); -documentNextButton?.addEventListener("click", () => { - currentDocumentPage += 1; - applyDocumentFilters({ keepPage: true }); -}); -documentHistory?.addEventListener("click", (event) => { - const detailButton = event.target.closest("[data-document-detail]"); - if (detailButton) { - showDocumentDetail(detailButton.dataset.documentDetail); - return; - } -}); - -document.querySelectorAll("[data-detail-preview]").forEach((button) => { - button.addEventListener("click", () => { - detailPreviewMode = button.dataset.detailPreview; - renderDetailPreview(documentDetails[activeDocumentId]); - }); -}); - -detailEditButton?.addEventListener("click", () => { - setDetailEditMode(true); - hideSendDraft(); - setText(detailStatusMessage, "Puoi modificare nome e cognome, azienda, data documento, tipologia e breve descrizione."); }); -detailCancelButton?.addEventListener("click", () => { - updateDocumentDetail(activeDocumentId); - setText(detailStatusMessage, "Modifica annullata."); -}); - -detailSaveButton?.addEventListener("click", saveDetailChanges); - -detailSendButton?.addEventListener("click", () => { - setDetailEditMode(false); - buildSendDraft(documentDetails[activeDocumentId]); -}); - -sendCancelButton?.addEventListener("click", () => { - hideSendDraft(); - setText(detailStatusMessage, "Invio annullato. Il documento resta non inviato."); -}); - -sendConfirmButton?.addEventListener("click", confirmDetailSend); - -async function initializeApp() { +async function bootstrapState() { try { - const session = await apiRequest(apiRoutes.session); - updateProfile(session); - const state = await apiRequest(apiRoutes.state); - applyAppState(state); - } catch (error) { - setText(profileInitials, "--"); - setText(uploadOutput, "Sessione non inizializzata. Accedi di nuovo se le operazioni non rispondono."); - setText(assistantComposeNote, humanApiError(error)); + applyCopilotState(state.copilot || { documents: [] }); + } catch { + renderDocuments(); } } -applyDocumentFilters(); -updateAllDocumentCards(); -updateCopilotAnalytics(); -initializeApp(); +bootstrapState(); diff --git a/public/nexum-app/styles.css b/public/nexum-app/styles.css index c08cef8..94930ae 100644 --- a/public/nexum-app/styles.css +++ b/public/nexum-app/styles.css @@ -1235,6 +1235,26 @@ img { font-size: 0.9rem; } +.document-preview-frame { + display: grid; + place-items: center; + min-height: 420px; + margin-top: 8px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--muted); + text-align: center; +} + +.document-preview-frame iframe { + width: 100%; + height: 420px; + border: 0; + background: var(--surface); +} + .document-preview-lines.pdf-preview-mode { margin-top: 10px; } diff --git a/resources/views/auth/user-login.blade.php b/resources/views/auth/user-login.blade.php deleted file mode 100644 index 5c28406..0000000 --- a/resources/views/auth/user-login.blade.php +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - NEXUM | Accesso utenti - - - - -
      - -
      - - diff --git a/resources/views/filament/pages/dashboard.blade.php b/resources/views/filament/pages/dashboard.blade.php deleted file mode 100644 index 5679949..0000000 --- a/resources/views/filament/pages/dashboard.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - -
      -
      -
      -
      -

      Amministrazione

      -

      Gestione credenziali

      -
      -
      - -

      - Da qui puoi creare, consultare e aggiornare gli account autorizzati ad accedere alla PoC NEXUM. - Le password vengono salvate tramite hashing Laravel e non sono mai rese visibili dopo la creazione. -

      - - -
      -
      -
      diff --git a/resources/views/filament/resources/original-document-resource/pages/view-sub-document.blade.php b/resources/views/filament/resources/original-document-resource/pages/view-sub-document.blade.php deleted file mode 100644 index 9fd7cfc..0000000 --- a/resources/views/filament/resources/original-document-resource/pages/view-sub-document.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - - {{ $this->infolist }} - diff --git a/resources/views/poc/app.blade.php b/resources/views/poc/app.blade.php index 2594898..18bcbe4 100644 --- a/resources/views/poc/app.blade.php +++ b/resources/views/poc/app.blade.php @@ -3,8 +3,9 @@ - NEXUM | Eggon Console - + + NEXUM | PoC +
      @@ -13,28 +14,24 @@
      -