diff --git a/.dockerignore b/.dockerignore index ad756771..c847e275 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,7 @@ infra vendor node_modules +**/node_modules apps/frontend/dist .env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8461a655..a8e8d925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,20 @@ jobs: if-no-files-found: warn retention-days: 7 + # Controparte PHP dell'audit npm del job frontend: come quello, guarda le + # sole dipendenze di produzione ed e' in coda, cosi' un advisory non + # nasconde l'esito dei test. I pacchetti abbandonati vengono riportati ma + # non fanno fallire il job: non sono vulnerabilita'. + # --locked audita quanto dichiarato dal lock, non il vendor cotto + # nell'immagine, cosi' il gate resta indipendente dal momento del build. + # La soglia e' applicata dallo script: composer da solo fallisce su + # qualsiasi severita' e non sa filtrarla. + - name: Audit production PHP dependencies + run: > + docker compose run --rm --no-deps app + composer audit --locked --no-dev --abandoned=report --format=json + | node scripts/ci/check-composer-advisories.mjs + frontend: name: Frontend checks runs-on: ubuntu-latest diff --git a/README.md b/README.md index 3442fc35..01e1fdca 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,11 @@ I due contenuti hanno criticità diverse e sono trattati di conseguenza: senza t A generazione conclusa l’operatore può aprire l’anteprima del documento finale impaginato ed esportarlo in PDF. Ogni pagina riporta il marcatore «Creato da AI Assistant», così la provenienza del contenuto resta leggibile anche fuori dall’applicativo. Il PDF viene materializzato sullo storage a oggetti alla prima richiesta e riusato finché il contenuto non cambia. -Sulla bozza l’operatore mantiene il controllo: può correggere a mano titolo e testo finché è in lavorazione, chiedere una nuova variante che rigenera testo e copertina conservando prompt, tono e stile, scartarla mantenendola tracciata, oppure eliminarla definitivamente dallo storico. Può inoltre assegnare una valutazione da 1 a 5 stelle con un commento facoltativo, una sola volta per generazione: è il segnale di qualità percepita, esposto anche come metrica. Lo storico è filtrabile per parola chiave, tono, stile e giorno di creazione, con i criteri applicati lato API. +Sulla bozza l’operatore mantiene il controllo: può correggere a mano titolo e testo, chiedere una nuova variante che rigenera testo e copertina conservando prompt, tono e stile, scartarla mantenendola tracciata, oppure eliminarla definitivamente dallo storico. Può inoltre assegnare una valutazione da 1 a 5 stelle con un commento facoltativo, una sola volta per generazione: è il segnale di qualità percepita, esposto anche come metrica. + +Nello storico entra solo ciò che l’operatore decide di conservare: una bozza vi compare dopo un salvataggio esplicito, e finché non lo riceve resta nell’area di lavoro corrente. Il salvataggio decide cosa viene archiviato, non congela il contenuto, che resta modificabile e rigenerabile come prima. Le generazioni archiviate si possono contrassegnare come preferite e lo storico è filtrabile per parola chiave, tono, stile e giorno di creazione, con i criteri applicati lato API. + +I parametri di partenza si possono riusare senza ripartire da zero: testo, tono e stile del form si salvano come preset con un nome a scelta, e se il nome manca o è già in uso il sistema ne assegna uno progressivo. Riaprire un preset ricompila il form senza avviare alcuna generazione. Il flusso evidenzia il ruolo del backend come livello di controllo tra interfaccia e modello AI: il provider genera il contenuto, mentre l’applicazione mantiene responsabilità su validazione, persistenza, stato e tracciabilità. @@ -71,11 +75,13 @@ Il flusso evidenzia il ruolo del backend come livello di controllo tra interfacc Il flusso Co-Pilot documentale gestisce PDF multi-destinatario caricati dall’operatore. Dopo l’upload, il backend valida il file, registra il documento originale, salva il contenuto nello storage S3-compatible e avvia una state machine in LocalStack Step Functions. +Chi carica il documento può dichiararne fin da subito tipologia, azienda, mese e anno. Sono tutti facoltativi, ma quello che dichiara fa fede: l’AI continua a produrre la propria estrazione, che resta consultabile, e i campi indicati a mano non vengono sovrascritti. Dichiararli non gonfia però la confidenza, che misura quanto ha riconosciuto il modello e quindi valuta soltanto i campi rimasti a suo carico. + La state machine pubblica task su SQS usando il callback pattern con task token. I worker Laravel consumano i messaggi, eseguono le fasi previste e notificano a Step Functions il completamento o il fallimento del task. Le fasi principali comprendono OCR, split logico del documento, estrazione dei dati, generazione dei sotto-documenti, aggiornamento dello stato e registrazione degli eventi applicativi. Il risultato è una pipeline documentale composta da passaggi isolati, monitorabili e riavviabili, con persistenza dello stato e visibilità sui risultati prodotti. -Sui sotto-documenti prodotti l’operatore lavora in revisione human-in-the-loop: corregge i campi estratti (inclusi email destinatario, codice fiscale e matricola, con validazione dedicata) e li marca come validati. Da ogni sotto-documento il sistema compone un messaggio di invio precompilato con destinatario, oggetto e testo, che si può correggere, visualizzare in anteprima ed esportare in PDF. Il recapito avviene fuori dalla piattaforma tramite canali terzi: per questo lo stato di invio coincide con l’avvenuto **scaricamento** del PDF, e non con un invio effettuato dal sistema. Lo storico dei documenti è filtrabile per nome, cognome o azienda, stato di invio, soglia di confidenza e periodo, sempre con i criteri applicati lato API e limitati al tenant chiamante. +Sui sotto-documenti prodotti l’operatore lavora in revisione human-in-the-loop: corregge i campi estratti (inclusi email destinatario, codice fiscale e matricola, con validazione dedicata) e li marca come validati. Il dettaglio riporta anche l’email del destinatario, copiabile negli appunti con un comando, e la data e ora di caricamento del documento di origine. Da ogni sotto-documento il sistema compone un messaggio di invio precompilato con destinatario, oggetto e testo, che si può correggere, visualizzare in anteprima ed esportare in PDF. Il recapito avviene fuori dalla piattaforma tramite canali terzi: per questo lo stato di invio coincide con l’avvenuto **scaricamento** del PDF, e non con un invio effettuato dal sistema. Lo storico dei documenti è filtrabile per nome, cognome o azienda, stato di invio, soglia di confidenza e periodo, sempre con i criteri applicati lato API e limitati al tenant chiamante. ## Monitoring e osservabilità diff --git a/app/Http/Controllers/Api/V1/CommunicationController.php b/app/Http/Controllers/Api/V1/CommunicationController.php index e3c347c5..a51c90f1 100644 --- a/app/Http/Controllers/Api/V1/CommunicationController.php +++ b/app/Http/Controllers/Api/V1/CommunicationController.php @@ -28,9 +28,10 @@ class CommunicationController use AuthorizesCommunications, ResolvesActor; /** - * Storico delle bozze del tenant, filtrabile (UC-15..UC-18). Come lo - * storico esposto in `state.assistant.history`, esclude le bozze scartate: - * restano tracciate ma fuori dall'area di lavoro dell'operatore. + * Storico del tenant, filtrabile (UC-15..UC-18). Una bozza vi entra solo + * dopo un salvataggio esplicito (UC-9): finche' resta in stato draft (o + * dopo uno scarto) non compare qui, e' visibile solo nell'area di lavoro + * corrente dell'operatore. */ public function index(ListCommunicationsRequest $request, MvpStateService $state): JsonResponse { @@ -39,7 +40,7 @@ public function index(ListCommunicationsRequest $request, MvpStateService $state $query = Communication::query() ->where('tenant_id', $actor->tenantId) - ->where('status', '!=', CommunicationStatus::Discarded); + ->where('status', CommunicationStatus::Approved); if ($keyword = trim((string) ($filters['keyword'] ?? ''))) { $query->where('prompt', 'like', '%'.$keyword.'%'); @@ -85,6 +86,7 @@ public function store( 'generation_status' => CommunicationGenerationStatus::Pending, 'cover_status' => CoverImageStatus::Pending, 'status' => CommunicationStatus::Draft, + 'is_favorite' => false, ]); $audit->record( @@ -108,6 +110,62 @@ public function store( ], 202); } + public function favorite(Request $request, Communication $communication, AuditLogger $audit, MvpStateService $state): JsonResponse + { + $actor = $this->actor($request); + $this->assertCommunicationOwnership($communication, $actor); + + if ($communication->is_favorite) { + throw ValidationException::withMessages([ + 'communication' => ['La generazione è già contrassegnata come preferita.'], + ]); + } + + $communication->update(['is_favorite' => true]); + $audit->record( + 'mvp-communication-favorited', + $actor, + 'communication', + (string) $communication->id, + [], + $request, + ); + + return response()->json([ + 'message' => 'Generazione aggiunta ai preferiti.', + 'communication' => $state->communication($communication->fresh()), + 'state' => $state->forActor($actor), + ]); + } + + public function unfavorite(Request $request, Communication $communication, AuditLogger $audit, MvpStateService $state): JsonResponse + { + $actor = $this->actor($request); + $this->assertCommunicationOwnership($communication, $actor); + + if (! $communication->is_favorite) { + throw ValidationException::withMessages([ + 'communication' => ['La generazione non è contrassegnata come preferita.'], + ]); + } + + $communication->update(['is_favorite' => false]); + $audit->record( + 'mvp-communication-unfavorited', + $actor, + 'communication', + (string) $communication->id, + [], + $request, + ); + + return response()->json([ + 'message' => 'Generazione rimossa dai preferiti.', + 'communication' => $state->communication($communication->fresh()), + 'state' => $state->forActor($actor), + ]); + } + public function update( UpdateCommunicationRequest $request, Communication $communication, @@ -117,11 +175,7 @@ public function update( $actor = $this->actor($request); $this->assertCommunicationOwnership($communication, $actor); - if ($communication->status !== CommunicationStatus::Draft) { - throw ValidationException::withMessages([ - 'communication' => ['Solo le bozze in stato draft sono modificabili.'], - ]); - } + $this->assertCommunicationIsEditable($communication); $validated = $request->validated(); @@ -166,6 +220,46 @@ public function regenerate( ], 202); } + /** + * Rende la bozza visibile nello storico (UC-9): resta comunque + * modificabile e rigenerabile come prima, il salvataggio decide solo + * cosa compare nell'elenco, non blocca il contenuto. + * + * @throws AuthorizationException + */ + public function save( + Request $request, + Communication $communication, + AuditLogger $audit, + MvpStateService $state, + ): JsonResponse { + $actor = $this->actor($request); + $this->assertCommunicationOwnership($communication, $actor); + + abort_if( + $communication->status !== CommunicationStatus::Draft, + 422, + 'Solo le bozze in stato draft possono essere salvate nello storico.', + ); + + $communication->update(['status' => CommunicationStatus::Approved]); + + $audit->record( + 'mvp-communication-saved', + $actor, + 'communication', + (string) $communication->id, + [], + $request, + ); + + return response()->json([ + 'message' => 'Bozza salvata nello storico.', + 'communication' => $state->communication($communication->refresh()), + 'state' => $state->forActor($actor), + ]); + } + /** * @throws AuthorizationException */ diff --git a/app/Http/Controllers/Api/V1/CommunicationCoverController.php b/app/Http/Controllers/Api/V1/CommunicationCoverController.php index 39cd0321..959835d3 100644 --- a/app/Http/Controllers/Api/V1/CommunicationCoverController.php +++ b/app/Http/Controllers/Api/V1/CommunicationCoverController.php @@ -36,6 +36,7 @@ public function updateCoverImage( ): JsonResponse { $actor = $this->actor($request); $this->assertCommunicationOwnership($communication, $actor); + $this->assertCommunicationIsEditable($communication); /** @var UploadedFile $file */ $file = $request->file('image'); @@ -72,6 +73,7 @@ public function removeCoverImage( ): JsonResponse { $actor = $this->actor($request); $this->assertCommunicationOwnership($communication, $actor); + $this->assertCommunicationIsEditable($communication); $covers->remove($communication); diff --git a/app/Http/Controllers/Api/V1/Concerns/AuthorizesCommunications.php b/app/Http/Controllers/Api/V1/Concerns/AuthorizesCommunications.php index 2b84c5e1..9cf71ecb 100644 --- a/app/Http/Controllers/Api/V1/Concerns/AuthorizesCommunications.php +++ b/app/Http/Controllers/Api/V1/Concerns/AuthorizesCommunications.php @@ -42,6 +42,18 @@ private function assertCommunicationReadyForExport(Communication $communication) } } + /** + * @throws ValidationException + */ + private function assertCommunicationIsEditable(Communication $communication): void + { + if ($communication->status === CommunicationStatus::Discarded) { + throw ValidationException::withMessages([ + 'communication' => ['Una bozza scartata non e\' modificabile.'], + ]); + } + } + private function assertCommunicationCanRegenerate(Communication $communication): void { abort_if( diff --git a/app/Http/Controllers/Api/V1/DocumentController.php b/app/Http/Controllers/Api/V1/DocumentController.php index 7cb21f40..7abd4400 100644 --- a/app/Http/Controllers/Api/V1/DocumentController.php +++ b/app/Http/Controllers/Api/V1/DocumentController.php @@ -90,14 +90,18 @@ public function store(UploadDocumentRequest $request, DocumentProcessingService { $validated = $request->validated(); $actor = $this->actor($request); + $manualMetadata = $request->manualMetadata(); - $original = $documents->storeUpload($validated['document'], $actor); + $original = $documents->storeUpload($validated['document'], $actor, $manualMetadata); $audit->record( 'mvp-document-upload-accepted', $actor, 'original_document', (string) $original->id, - ['filename' => $original->original_filename], + [ + 'filename' => $original->original_filename, + 'manual_metadata' => array_filter($manualMetadata, static fn ($value) => $value !== null), + ], $request, ); diff --git a/app/Http/Controllers/Api/V1/PromptConfigurationController.php b/app/Http/Controllers/Api/V1/PromptConfigurationController.php new file mode 100644 index 00000000..b04647ba --- /dev/null +++ b/app/Http/Controllers/Api/V1/PromptConfigurationController.php @@ -0,0 +1,97 @@ +actor($request); + $validated = $request->validated(); + + $configuration = PromptConfiguration::create([ + 'tenant_id' => $actor->tenantId, + 'created_by' => $actor->id, + 'name' => $namer->resolve($actor->tenantId, $validated['name'] ?? null), + 'prompt' => $validated['prompt'], + 'tone' => $validated['tone'], + 'style' => $validated['style'], + ]); + + $audit->record( + 'mvp-prompt-configuration-saved', + $actor, + 'prompt_configuration', + (string) $configuration->id, + ['name' => $configuration->name], + $request, + ); + + return response()->json([ + 'message' => 'Configurazione salvata.', + 'configuration' => $state->promptConfiguration($configuration), + 'state' => $state->forActor($actor), + ], 201); + } + + /** + * @throws AuthorizationException + */ + public function destroy( + Request $request, + PromptConfiguration $promptConfiguration, + AuditLogger $audit, + MvpStateService $state, + ): JsonResponse { + $actor = $this->actor($request); + $this->assertOwnership($promptConfiguration, $actor); + + $promptConfiguration->delete(); + + $audit->record( + 'mvp-prompt-configuration-deleted', + $actor, + 'prompt_configuration', + (string) $promptConfiguration->id, + [], + $request, + ); + + return response()->json([ + 'message' => 'Configurazione eliminata.', + 'state' => $state->forActor($actor), + ]); + } + + /** + * @throws AuthorizationException + */ + private function assertOwnership(PromptConfiguration $configuration, MvpUser $actor): void + { + if ($configuration->tenant_id !== $actor->tenantId) { + throw new AuthorizationException('Prompt configuration is outside the authenticated tenant scope.'); + } + } +} diff --git a/app/Http/Requests/GenerateCommunicationRequest.php b/app/Http/Requests/GenerateCommunicationRequest.php index 9a325ca4..801321ea 100644 --- a/app/Http/Requests/GenerateCommunicationRequest.php +++ b/app/Http/Requests/GenerateCommunicationRequest.php @@ -8,7 +8,7 @@ class GenerateCommunicationRequest extends FormRequest { - private const TONES = [ + public const TONES = [ 'Chiaro e diretto', 'Più istituzionale', 'Più sintetico', @@ -16,7 +16,7 @@ class GenerateCommunicationRequest extends FormRequest 'Tecnico', ]; - private const STYLES = [ + public const STYLES = [ 'Testo informativo', 'Avviso operativo', 'Aggiornamento breve', diff --git a/app/Http/Requests/SavePromptConfigurationRequest.php b/app/Http/Requests/SavePromptConfigurationRequest.php new file mode 100644 index 00000000..e278fca8 --- /dev/null +++ b/app/Http/Requests/SavePromptConfigurationRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['nullable', 'string', 'max:150'], + 'prompt' => ['required', 'string', 'min:12', 'max:5000'], + 'tone' => ['required', 'string', Rule::in(GenerateCommunicationRequest::TONES)], + 'style' => ['required', 'string', Rule::in(GenerateCommunicationRequest::STYLES)], + ]; + } +} diff --git a/app/Http/Requests/UploadDocumentRequest.php b/app/Http/Requests/UploadDocumentRequest.php index 0edadfee..bcac7f70 100644 --- a/app/Http/Requests/UploadDocumentRequest.php +++ b/app/Http/Requests/UploadDocumentRequest.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Validation\Validator as ValidatorContract; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Process; +use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; use setasign\Fpdi\Fpdi; @@ -27,6 +28,36 @@ public function rules(): array return [ 'document' => ['required', 'file', 'mimetypes:application/pdf', 'max:'.$maxKilobytes], + // Metadati manuali: opzionali; se presenti vengono preservati sull'output AI. + 'documentType' => ['sometimes', 'nullable', 'string', Rule::in(UpdateExtractedDataRequest::DOCUMENT_TYPES)], + 'companyName' => ['sometimes', 'nullable', 'string', 'max:500'], + 'month' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:12'], + 'year' => ['sometimes', 'nullable', 'integer', 'min:1900', 'max:2100'], + ]; + } + + /** + * Metadati manuali da applicare dopo l'estrazione AI (null = lascia decidere all'AI). + * + * @return array{ + * document_type: ?string, + * company_name: ?string, + * reference_month: ?int, + * reference_year: ?int + * } + */ + public function manualMetadata(): array + { + $validated = $this->validated(); + + $documentType = isset($validated['documentType']) ? trim((string) $validated['documentType']) : ''; + $companyName = isset($validated['companyName']) ? trim((string) $validated['companyName']) : ''; + + return [ + 'document_type' => $documentType !== '' ? $documentType : null, + 'company_name' => $companyName !== '' ? $companyName : null, + 'reference_month' => isset($validated['month']) ? (int) $validated['month'] : null, + 'reference_year' => isset($validated['year']) ? (int) $validated['year'] : null, ]; } diff --git a/app/Models/Communication.php b/app/Models/Communication.php index fa6eeeaa..33dd6f82 100644 --- a/app/Models/Communication.php +++ b/app/Models/Communication.php @@ -35,6 +35,7 @@ * @property CoverImageStatus $cover_status * @property string|null $cover_error * @property CommunicationStatus $status + * @property bool $is_favorite * @property int|null $rating * @property string|null $rating_comment * @property Carbon|null $rated_at @@ -68,6 +69,7 @@ class Communication extends Model 'cover_status', 'cover_error', 'status', + 'is_favorite', 'rating', 'rating_comment', 'rated_at', @@ -81,6 +83,7 @@ protected function casts(): array { return [ 'status' => CommunicationStatus::class, + 'is_favorite' => 'boolean', 'generation_status' => CommunicationGenerationStatus::class, 'cover_image_source' => CoverImageSource::class, 'cover_status' => CoverImageStatus::class, diff --git a/app/Models/ExtractedData.php b/app/Models/ExtractedData.php index 25052867..cc10de81 100644 --- a/app/Models/ExtractedData.php +++ b/app/Models/ExtractedData.php @@ -16,6 +16,7 @@ * @property Carbon|null $document_date * @property string|null $document_type * @property string|null $description + * @property string|null $recipient_email * @property int|null $confidence_score * @property array|null $ai_payload * @property SubDocument|null $subDocument @@ -35,6 +36,7 @@ class ExtractedData extends Model 'document_date', 'document_type', 'description', + 'recipient_email', 'confidence_score', 'ai_payload', ]; diff --git a/app/Models/OriginalDocument.php b/app/Models/OriginalDocument.php index 85c71bb6..87f4cdf5 100644 --- a/app/Models/OriginalDocument.php +++ b/app/Models/OriginalDocument.php @@ -15,6 +15,10 @@ * @property string|null $created_by * @property string $file_path * @property string $original_filename + * @property string|null $manual_document_type + * @property string|null $manual_company_name + * @property int|null $manual_reference_month + * @property int|null $manual_reference_year * @property ProcessingStatus $processing_status * @property string|null $error_message * @property string|null $s3_bucket @@ -38,6 +42,10 @@ class OriginalDocument extends Model 'created_by', 'file_path', 'original_filename', + 'manual_document_type', + 'manual_company_name', + 'manual_reference_month', + 'manual_reference_year', 'processing_status', 'error_message', 's3_bucket', @@ -60,6 +68,8 @@ protected function casts(): array { return [ 'processing_status' => ProcessingStatus::class, + 'manual_reference_month' => 'integer', + 'manual_reference_year' => 'integer', 'workflow_started_at' => 'datetime', 'workflow_completed_at' => 'datetime', 'workflow_failed_at' => 'datetime', @@ -68,6 +78,17 @@ protected function casts(): array ]; } + /** + * True se almeno un metadato manuale e' stato impostato in fase di upload. + */ + public function hasManualUploadMetadata(): bool + { + return $this->manual_document_type !== null + || $this->manual_company_name !== null + || $this->manual_reference_month !== null + || $this->manual_reference_year !== null; + } + /** * @return HasMany */ diff --git a/app/Models/PromptConfiguration.php b/app/Models/PromptConfiguration.php new file mode 100644 index 00000000..0a17dfac --- /dev/null +++ b/app/Models/PromptConfiguration.php @@ -0,0 +1,31 @@ +nameExists($tenantId, $trimmed)) { + return $trimmed; + } + + $counter = 1; + + while ($this->nameExists($tenantId, "Senza nome ({$counter})")) { + $counter++; + } + + return "Senza nome ({$counter})"; + } + + private function nameExists(string $tenantId, string $name): bool + { + return PromptConfiguration::query() + ->where('tenant_id', $tenantId) + ->where('name', $name) + ->exists(); + } +} diff --git a/app/Mvp/Documents/Services/DocumentProcessingService.php b/app/Mvp/Documents/Services/DocumentProcessingService.php index 0e5e9f13..53a0f7f3 100644 --- a/app/Mvp/Documents/Services/DocumentProcessingService.php +++ b/app/Mvp/Documents/Services/DocumentProcessingService.php @@ -30,9 +30,16 @@ public function __construct( ) {} /** + * @param array{ + * document_type?: ?string, + * company_name?: ?string, + * reference_month?: ?int, + * reference_year?: ?int + * } $manualMetadata + * * @throws \RuntimeException when the upload cannot be persisted to the configured disk. */ - public function storeUpload(UploadedFile $file, MvpUser $actor): OriginalDocument + public function storeUpload(UploadedFile $file, MvpUser $actor, array $manualMetadata = []): OriginalDocument { $path = $file->store('originals', $this->documentDisk()); @@ -42,16 +49,28 @@ public function storeUpload(UploadedFile $file, MvpUser $actor): OriginalDocumen $safeName = preg_replace('/[^\w.\-]/u', '_', $file->getClientOriginalName()) ?: 'documento.pdf'; - return $this->handleStoredFile($path, $safeName, $actor); + return $this->handleStoredFile($path, $safeName, $actor, $manualMetadata); } - public function handleStoredFile(string $path, string $filename, ?MvpUser $actor = null): OriginalDocument + /** + * @param array{ + * document_type?: ?string, + * company_name?: ?string, + * reference_month?: ?int, + * reference_year?: ?int + * } $manualMetadata + */ + public function handleStoredFile(string $path, string $filename, ?MvpUser $actor = null, array $manualMetadata = []): OriginalDocument { return OriginalDocument::create([ 'tenant_id' => $actor?->tenantId ?? 'mvp-local-tenant', 'created_by' => $actor?->id, 'file_path' => $path, 'original_filename' => $filename, + 'manual_document_type' => $manualMetadata['document_type'] ?? null, + 'manual_company_name' => $manualMetadata['company_name'] ?? null, + 'manual_reference_month' => $manualMetadata['reference_month'] ?? null, + 'manual_reference_year' => $manualMetadata['reference_year'] ?? null, 'processing_status' => ProcessingStatus::Pending, ]); } @@ -59,11 +78,15 @@ public function handleStoredFile(string $path, string $filename, ?MvpUser $actor public function extractAndSaveFields(SubDocument $subDocument): void { try { - $fields = $this->extractFields($subDocument); + $aiFields = $this->extractFields($subDocument); + // I metadati impostati in upload prevalgono sull'estrazione AI. + $fields = $this->applyManualMetadataOverrides($aiFields, $subDocument->originalDocument); // La confidenza effettiva non è l'auto-valutazione del modello (non // calibrata), ma un valore oggettivo: leggibilità OCR × completezza // dei campi chiave. L'output grezzo del modello resta in ai_payload. - $confidenceScore = $this->computeConfidenceScore($fields, $subDocument); + // Si misura sull'estrazione grezza, non sui campi già sovrascritti + // dai metadati di upload: la confidenza riguarda l'AI, non l'operatore. + $confidenceScore = $this->computeConfidenceScore($aiFields, $subDocument); $reviewStatus = $this->reviewStatusForConfidence($confidenceScore); $subDocument->update([ 'review_status' => $reviewStatus, @@ -73,7 +96,7 @@ public function extractAndSaveFields(SubDocument $subDocument): void ['sub_document_id' => $subDocument->id], array_merge($fields, [ 'confidence_score' => $confidenceScore, - 'ai_payload' => $fields, + 'ai_payload' => $aiFields, ]), ); $this->metrics->recordDomainCounter('ai_extractions_total', [ @@ -285,30 +308,74 @@ private function handleProcessingFailure(OriginalDocument $original, \Throwable * of the key fields were actually extracted. Replaces the model's own * uncalibrated self-assessment. * - * @param array{employee_first_name: ?string, employee_last_name: ?string, company_name: ?string, document_date: ?string, document_type: ?string, description: ?string, confidence_score: ?int} $fields + * Misura la sola estrazione automatica, come da UC-39.10: i campi dichiarati + * in upload dal consulente fanno fede e non vengono valutati, quindi restano + * esclusi sia dai campi trovati sia dal totale. Dichiarare metadati non alza + * il punteggio, determina solo su quali campi l'estrazione viene valutata. + * + * @param array{employee_first_name: ?string, employee_last_name: ?string, company_name: ?string, document_date: ?string, document_type: ?string, description: ?string, confidence_score: ?int} $aiFields Estrazione grezza del modello, senza i sovrascritti manuali. */ - private function computeConfidenceScore(array $fields, SubDocument $subDocument): int + private function computeConfidenceScore(array $aiFields, SubDocument $subDocument): int { - $keyFields = ['employee_first_name', 'employee_last_name', 'company_name', 'document_date']; + $original = $subDocument->originalDocument; + $keyFields = array_values(array_diff( + ['employee_first_name', 'employee_last_name', 'company_name', 'document_date'], + $this->manuallyDeclaredKeyFields($original), + )); + + $ocrConfidence = $this->ocrConfidenceForRange( + $original, + (int) $subDocument->start_page, + (int) $subDocument->end_page + ); + + // Nessun campo chiave a carico dell'estrazione automatica: non c'e' nulla + // da valutare, resta la sola leggibilita' della scansione. + if ($keyFields === []) { + return max(0, min(100, (int) round($ocrConfidence))); + } + $found = 0; foreach ($keyFields as $key) { - if (isset($fields[$key]) && trim((string) $fields[$key]) !== '') { + if (isset($aiFields[$key]) && trim((string) $aiFields[$key]) !== '') { $found++; } } $completeness = $found / count($keyFields); - $ocrConfidence = $this->ocrConfidenceForRange( - $subDocument->originalDocument, - (int) $subDocument->start_page, - (int) $subDocument->end_page - ); - return max(0, min(100, (int) round($ocrConfidence * $completeness))); } + /** + * Campi chiave gia' dichiarati in upload, che non sono piu' a carico + * dell'estrazione automatica. `document_type` non compare fra questi: + * non rientra nei campi chiave della confidenza. + * + * @return list + */ + private function manuallyDeclaredKeyFields(?OriginalDocument $original): array + { + if ($original === null) { + return []; + } + + $declared = []; + + if ($original->manual_company_name !== null) { + $declared[] = 'company_name'; + } + + // E' sufficiente che sia dichiarato uno dei due: la data risultante non + // deriva piu' dalla sola estrazione automatica. + if ($original->manual_reference_month !== null || $original->manual_reference_year !== null) { + $declared[] = 'document_date'; + } + + return $declared; + } + /** * Average Textract OCR confidence (0-100) over the recipient's page range, * falling back to the document-level average. @@ -470,6 +537,63 @@ private function extractFields(SubDocument $subDocument): array return $this->bedrock->extractFields($ocrText); } + /** + * Tipologia, azienda e mese/anno impostati in upload restano autoritativi. + * L'AI continua a produrre l'estrazione (e il payload grezzo), ma non sovrascrive + * i campi già dichiarati dal consulente. + * + * @param array{employee_first_name: ?string, employee_last_name: ?string, company_name: ?string, document_date: ?string, document_type: ?string, description: ?string, confidence_score: ?int} $fields + * @return array{employee_first_name: ?string, employee_last_name: ?string, company_name: ?string, document_date: ?string, document_type: ?string, description: ?string, confidence_score: ?int} + */ + private function applyManualMetadataOverrides(array $fields, ?OriginalDocument $original): array + { + if ($original === null || ! $original->hasManualUploadMetadata()) { + return $fields; + } + + if ($original->manual_document_type !== null) { + $fields['document_type'] = $original->manual_document_type; + } + + if ($original->manual_company_name !== null) { + $fields['company_name'] = $original->manual_company_name; + } + + $month = $original->manual_reference_month; + $year = $original->manual_reference_year; + + if ($month !== null && $year !== null) { + $fields['document_date'] = sprintf('%04d-%02d-01', $year, $month); + } elseif ($year !== null || $month !== null) { + $fields['document_date'] = $this->mergeManualDateWithAi($fields['document_date'] ?? null, $month, $year); + } + + return $fields; + } + + /** + * Completa mese o anno mancante usando la data AI quando disponibile. + */ + private function mergeManualDateWithAi(?string $aiDate, ?int $month, ?int $year): ?string + { + $aiYear = null; + $aiMonth = null; + + if (is_string($aiDate) && preg_match('/^(\d{4})-(\d{2})/', $aiDate, $matches) === 1) { + $aiYear = (int) $matches[1]; + $aiMonth = (int) $matches[2]; + } + + $resolvedYear = $year ?? $aiYear; + $resolvedMonth = $month ?? $aiMonth; + + if ($resolvedYear === null || $resolvedMonth === null) { + return $aiDate; + } + + return sprintf('%04d-%02d-01', $resolvedYear, $resolvedMonth); + } + /** * Random per-run boundary token used to delimit pages in the OCR text fed to * the classifier. Unguessable so it cannot collide with document content. diff --git a/app/Mvp/Support/MvpStateService.php b/app/Mvp/Support/MvpStateService.php index d120ab79..c89ae18f 100644 --- a/app/Mvp/Support/MvpStateService.php +++ b/app/Mvp/Support/MvpStateService.php @@ -5,6 +5,7 @@ use App\Models\Communication; use App\Models\ExtractedData; use App\Models\OriginalDocument; +use App\Models\PromptConfiguration; use App\Models\SubDocument; use App\Mvp\Communications\Enums\CommunicationStatus; use App\Mvp\Documents\Enums\ReviewStatus; @@ -38,14 +39,22 @@ public function assistantState(MvpUser $actor): array $drafts = (clone $baseQuery)->where('status', CommunicationStatus::Draft)->count(); $rated = (clone $baseQuery)->whereNotNull('rating')->count(); $averageRating = (clone $baseQuery)->whereNotNull('rating')->avg('rating'); - // Una bozza scartata (UC-7) resta tracciata (audit, metrica Prometheus per - // stato) ma non deve piu' comparire nell'area di lavoro dell'operatore: - // e' li' che l'utente si aspetta di vederla sparire, non solo etichettata. + // Una bozza entra nello storico solo dopo un salvataggio esplicito + // (UC-9): finche' resta draft, o dopo uno scarto (UC-7), non deve + // comparire qui, e' l'operatore a decidere cosa fissare nello storico. $history = (clone $baseQuery) - ->where('status', '!=', CommunicationStatus::Discarded) + ->where('status', CommunicationStatus::Approved) ->latest() ->limit(10) ->get(); + // Preset di prompt salvati (UC-19): elenco limitato, non filtrabile, + // pensato per un riuso rapido dal form di generazione, non come + // archivio ricercabile. + $promptConfigurations = PromptConfiguration::query() + ->where('tenant_id', $actor->tenantId) + ->latest() + ->limit(20) + ->get(); return [ // La `key` e' l'identificativo stabile: la label e' testo di @@ -62,6 +71,24 @@ public function assistantState(MvpUser $actor): array ], ], 'history' => $history->map(fn ($communication) => $this->communication($communication))->values()->all(), + 'promptConfigurations' => $promptConfigurations->map(fn ($configuration) => $this->promptConfiguration($configuration))->values()->all(), + ]; + } + + /** + * @return array + */ + public function promptConfiguration(PromptConfiguration $configuration): array + { + return [ + 'id' => $configuration->id, + 'name' => $configuration->name, + 'prompt' => $configuration->prompt, + 'tone' => $configuration->tone, + 'style' => $configuration->style, + // ISO, non formattata per la lettura: serve anche a filtrare per + // data lato frontend (vedi formatDateForDisplay in assistant-page). + 'createdAt' => $configuration->created_at?->format('Y-m-d'), ]; } @@ -121,6 +148,7 @@ public function communication(Communication $communication): array 'error' => $communication->error_message, 'status' => $communication->status->label(), 'statusValue' => $communication->status->value, + 'isFavorite' => (bool) $communication->is_favorite, 'createdAt' => $communication->created_at?->format('d/m/Y H:i'), 'rating' => $communication->rating, 'ratingComment' => $communication->rating_comment, @@ -179,6 +207,7 @@ public function document(SubDocument $subDocument): array 'employee' => $employee !== '' ? $employee : null, 'companyName' => $data?->company_name, 'recipientEmail' => $data?->recipient_email, + 'uploadedAt' => $original?->created_at?->format('d/m/Y H:i'), 'fiscalCode' => $data?->fiscal_code, 'employeeId' => $data?->employee_id, 'file' => $original?->original_filename, diff --git a/apps/frontend/package.json b/apps/frontend/package.json index ab09b4a4..6db7af18 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -13,21 +13,21 @@ "openapi:check": "npm run openapi:generate && git diff --exit-code -- src/api/generated" }, "dependencies": { - "@angular/common": "^21.2.0", - "@angular/compiler": "^21.2.0", - "@angular/core": "^21.2.0", - "@angular/forms": "^21.2.0", - "@angular/platform-browser": "^21.2.0", - "@angular/router": "^21.2.0", + "@angular/common": "^21.2.19", + "@angular/compiler": "^21.2.19", + "@angular/core": "^21.2.19", + "@angular/forms": "^21.2.19", + "@angular/platform-browser": "^21.2.19", + "@angular/router": "^21.2.19", "@lucide/angular": "^1.21.0", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "~0.15.1" }, "devDependencies": { - "@angular/build": "^21.2.0", - "@angular/cli": "^21.2.0", - "@angular/compiler-cli": "^21.2.0", + "@angular/build": "^21.2.19", + "@angular/cli": "^21.2.19", + "@angular/compiler-cli": "^21.2.19", "@types/jest": "^30.0.0", "angular-eslint": "^21.4.0", "eslint": "^9.20.0", @@ -35,7 +35,7 @@ "jest": "^30.0.0", "jest-preset-angular": "^17.0.0", "jsdom": "^29.1.1", - "orval": "^8.18.0", + "orval": "8.23.0", "test-exclude": "^8.0.0", "typescript": "~5.9.2", "typescript-eslint": "^8.24.0" diff --git a/apps/frontend/src/api/generated/model/assistantState.ts b/apps/frontend/src/api/generated/model/assistantState.ts index b759ad27..4d4835d4 100644 --- a/apps/frontend/src/api/generated/model/assistantState.ts +++ b/apps/frontend/src/api/generated/model/assistantState.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. @@ -7,8 +7,10 @@ */ import type { Communication } from './communication'; import type { Metric } from './metric'; +import type { PromptConfiguration } from './promptConfiguration'; export interface AssistantState { metrics: Metric[]; history: Communication[]; + promptConfigurations: PromptConfiguration[]; } diff --git a/apps/frontend/src/api/generated/model/communication.ts b/apps/frontend/src/api/generated/model/communication.ts index 0b89d5e2..7b83952c 100644 --- a/apps/frontend/src/api/generated/model/communication.ts +++ b/apps/frontend/src/api/generated/model/communication.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. @@ -31,6 +31,7 @@ export interface Communication { error?: string | null; status: string; statusValue?: string; + isFavorite: boolean; /** @nullable */ createdAt?: string | null; /** diff --git a/apps/frontend/src/api/generated/model/communicationCoverStatus.ts b/apps/frontend/src/api/generated/model/communicationCoverStatus.ts index 718c8ceb..e8da68ba 100644 --- a/apps/frontend/src/api/generated/model/communicationCoverStatus.ts +++ b/apps/frontend/src/api/generated/model/communicationCoverStatus.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/communicationGenerationStatus.ts b/apps/frontend/src/api/generated/model/communicationGenerationStatus.ts index 4f864e5d..0aa9a9de 100644 --- a/apps/frontend/src/api/generated/model/communicationGenerationStatus.ts +++ b/apps/frontend/src/api/generated/model/communicationGenerationStatus.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/communicationMutationResponse.ts b/apps/frontend/src/api/generated/model/communicationMutationResponse.ts index 9d1d3ed2..27960574 100644 --- a/apps/frontend/src/api/generated/model/communicationMutationResponse.ts +++ b/apps/frontend/src/api/generated/model/communicationMutationResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/conflictResponse.ts b/apps/frontend/src/api/generated/model/conflictResponse.ts index 51157c56..43e57842 100644 --- a/apps/frontend/src/api/generated/model/conflictResponse.ts +++ b/apps/frontend/src/api/generated/model/conflictResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/copilotState.ts b/apps/frontend/src/api/generated/model/copilotState.ts index f196e648..f9cd0a5e 100644 --- a/apps/frontend/src/api/generated/model/copilotState.ts +++ b/apps/frontend/src/api/generated/model/copilotState.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/deleteDocumentResponse.ts b/apps/frontend/src/api/generated/model/deleteDocumentResponse.ts index 7c018d0c..ef515376 100644 --- a/apps/frontend/src/api/generated/model/deleteDocumentResponse.ts +++ b/apps/frontend/src/api/generated/model/deleteDocumentResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/errorEnvelope.ts b/apps/frontend/src/api/generated/model/errorEnvelope.ts index e5f28145..f3ce3124 100644 --- a/apps/frontend/src/api/generated/model/errorEnvelope.ts +++ b/apps/frontend/src/api/generated/model/errorEnvelope.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/errorEnvelopeError.ts b/apps/frontend/src/api/generated/model/errorEnvelopeError.ts index 995b0072..d81c3d23 100644 --- a/apps/frontend/src/api/generated/model/errorEnvelopeError.ts +++ b/apps/frontend/src/api/generated/model/errorEnvelopeError.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/forbiddenResponse.ts b/apps/frontend/src/api/generated/model/forbiddenResponse.ts index bfeb7456..29273545 100644 --- a/apps/frontend/src/api/generated/model/forbiddenResponse.ts +++ b/apps/frontend/src/api/generated/model/forbiddenResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/generateCommunicationRequest.ts b/apps/frontend/src/api/generated/model/generateCommunicationRequest.ts index 18499b83..a5145c0e 100644 --- a/apps/frontend/src/api/generated/model/generateCommunicationRequest.ts +++ b/apps/frontend/src/api/generated/model/generateCommunicationRequest.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/generateCommunicationRequestStyle.ts b/apps/frontend/src/api/generated/model/generateCommunicationRequestStyle.ts index ca90db37..186745c5 100644 --- a/apps/frontend/src/api/generated/model/generateCommunicationRequestStyle.ts +++ b/apps/frontend/src/api/generated/model/generateCommunicationRequestStyle.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/generateCommunicationRequestTone.ts b/apps/frontend/src/api/generated/model/generateCommunicationRequestTone.ts index 10e610da..047a5710 100644 --- a/apps/frontend/src/api/generated/model/generateCommunicationRequestTone.ts +++ b/apps/frontend/src/api/generated/model/generateCommunicationRequestTone.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/ifNoneMatchParameter.ts b/apps/frontend/src/api/generated/model/ifNoneMatchParameter.ts index 131ac44e..a9a8b43a 100644 --- a/apps/frontend/src/api/generated/model/ifNoneMatchParameter.ts +++ b/apps/frontend/src/api/generated/model/ifNoneMatchParameter.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/index.ts b/apps/frontend/src/api/generated/model/index.ts index d0e0e1de..46ea4389 100644 --- a/apps/frontend/src/api/generated/model/index.ts +++ b/apps/frontend/src/api/generated/model/index.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. @@ -29,13 +29,19 @@ export * from './listMvpDocumentsParams'; export * from './metric'; export * from './mvpState'; export * from './notFoundResponse'; +export * from './promptConfiguration'; export * from './rateCommunicationRequest'; export * from './rateCommunicationResponse'; +export * from './savePromptConfigurationRequest'; +export * from './savePromptConfigurationRequestStyle'; +export * from './savePromptConfigurationRequestTone'; +export * from './savePromptConfigurationResponse'; export * from './startCommunicationGenerationResponse'; export * from './subDocument'; export * from './subDocumentReviewStatus'; export * from './subDocumentSendStatus'; export * from './unauthorizedResponse'; +export * from './updateCommunicationFavoriteResponse'; export * from './updateCommunicationRequest'; export * from './updateCommunicationResponse'; export * from './updateExtractedDataRequest'; @@ -44,6 +50,7 @@ export * from './updateSendMessageRequest'; export * from './updateSubDocumentReviewResponse'; export * from './uploadDocumentResponse'; export * from './uploadMvpDocumentBody'; +export * from './uploadMvpDocumentBodyDocumentType'; export * from './upstreamUnavailableResponse'; export * from './validationErrorEnvelope'; export * from './validationErrorEnvelopeError'; diff --git a/apps/frontend/src/api/generated/model/listCommunicationsResponse.ts b/apps/frontend/src/api/generated/model/listCommunicationsResponse.ts index fce13184..e1657dc1 100644 --- a/apps/frontend/src/api/generated/model/listCommunicationsResponse.ts +++ b/apps/frontend/src/api/generated/model/listCommunicationsResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/listDocumentsResponse.ts b/apps/frontend/src/api/generated/model/listDocumentsResponse.ts index ea08f8aa..fe87b358 100644 --- a/apps/frontend/src/api/generated/model/listDocumentsResponse.ts +++ b/apps/frontend/src/api/generated/model/listDocumentsResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/listMvpCommunicationsParams.ts b/apps/frontend/src/api/generated/model/listMvpCommunicationsParams.ts index 0806f370..017a6755 100644 --- a/apps/frontend/src/api/generated/model/listMvpCommunicationsParams.ts +++ b/apps/frontend/src/api/generated/model/listMvpCommunicationsParams.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/listMvpDocumentsConfidenceCriterion.ts b/apps/frontend/src/api/generated/model/listMvpDocumentsConfidenceCriterion.ts index 438729a7..83e5f72f 100644 --- a/apps/frontend/src/api/generated/model/listMvpDocumentsConfidenceCriterion.ts +++ b/apps/frontend/src/api/generated/model/listMvpDocumentsConfidenceCriterion.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/listMvpDocumentsParams.ts b/apps/frontend/src/api/generated/model/listMvpDocumentsParams.ts index 86fad0e7..f52f89c8 100644 --- a/apps/frontend/src/api/generated/model/listMvpDocumentsParams.ts +++ b/apps/frontend/src/api/generated/model/listMvpDocumentsParams.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/metric.ts b/apps/frontend/src/api/generated/model/metric.ts index df0528f6..449cf3ad 100644 --- a/apps/frontend/src/api/generated/model/metric.ts +++ b/apps/frontend/src/api/generated/model/metric.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/mvpState.ts b/apps/frontend/src/api/generated/model/mvpState.ts index e18b0bb3..b0da6b40 100644 --- a/apps/frontend/src/api/generated/model/mvpState.ts +++ b/apps/frontend/src/api/generated/model/mvpState.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/notFoundResponse.ts b/apps/frontend/src/api/generated/model/notFoundResponse.ts index 16141a2b..2f5b25fa 100644 --- a/apps/frontend/src/api/generated/model/notFoundResponse.ts +++ b/apps/frontend/src/api/generated/model/notFoundResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/promptConfiguration.ts b/apps/frontend/src/api/generated/model/promptConfiguration.ts new file mode 100644 index 00000000..3854bd0d --- /dev/null +++ b/apps/frontend/src/api/generated/model/promptConfiguration.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.23.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ + +export interface PromptConfiguration { + id: number; + name: string; + prompt: string; + tone: string; + style: string; + /** @nullable */ + createdAt?: string | null; +} diff --git a/apps/frontend/src/api/generated/model/rateCommunicationRequest.ts b/apps/frontend/src/api/generated/model/rateCommunicationRequest.ts index 5ea42233..88989397 100644 --- a/apps/frontend/src/api/generated/model/rateCommunicationRequest.ts +++ b/apps/frontend/src/api/generated/model/rateCommunicationRequest.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/rateCommunicationResponse.ts b/apps/frontend/src/api/generated/model/rateCommunicationResponse.ts index 2ad19894..fb4a925b 100644 --- a/apps/frontend/src/api/generated/model/rateCommunicationResponse.ts +++ b/apps/frontend/src/api/generated/model/rateCommunicationResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/savePromptConfigurationRequest.ts b/apps/frontend/src/api/generated/model/savePromptConfigurationRequest.ts new file mode 100644 index 00000000..11993cbb --- /dev/null +++ b/apps/frontend/src/api/generated/model/savePromptConfigurationRequest.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v8.23.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ +import type { SavePromptConfigurationRequestStyle } from './savePromptConfigurationRequestStyle'; +import type { SavePromptConfigurationRequestTone } from './savePromptConfigurationRequestTone'; + +export interface SavePromptConfigurationRequest { + /** + * Nome identificativo scelto dal Redattore. Se vuoto o gia' in uso per il tenant, il sistema assegna un'etichetta progressiva (es. "Senza nome (1)") (UC-19). + * @maxLength 150 + * @nullable + */ + name?: string | null; + /** + * @minLength 12 + * @maxLength 5000 + */ + prompt: string; + tone: SavePromptConfigurationRequestTone; + style: SavePromptConfigurationRequestStyle; +} diff --git a/apps/frontend/src/api/generated/model/savePromptConfigurationRequestStyle.ts b/apps/frontend/src/api/generated/model/savePromptConfigurationRequestStyle.ts new file mode 100644 index 00000000..0739237c --- /dev/null +++ b/apps/frontend/src/api/generated/model/savePromptConfigurationRequestStyle.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.23.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ + +export type SavePromptConfigurationRequestStyle = typeof SavePromptConfigurationRequestStyle[keyof typeof SavePromptConfigurationRequestStyle]; + + +export const SavePromptConfigurationRequestStyle = { + Testo_informativo: 'Testo informativo', + Avviso_operativo: 'Avviso operativo', + Aggiornamento_breve: 'Aggiornamento breve', +} as const; diff --git a/apps/frontend/src/api/generated/model/savePromptConfigurationRequestTone.ts b/apps/frontend/src/api/generated/model/savePromptConfigurationRequestTone.ts new file mode 100644 index 00000000..4d273a68 --- /dev/null +++ b/apps/frontend/src/api/generated/model/savePromptConfigurationRequestTone.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.23.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ + +export type SavePromptConfigurationRequestTone = typeof SavePromptConfigurationRequestTone[keyof typeof SavePromptConfigurationRequestTone]; + + +export const SavePromptConfigurationRequestTone = { + Chiaro_e_diretto: 'Chiaro e diretto', + Più_istituzionale: 'Più istituzionale', + Più_sintetico: 'Più sintetico', + Empatico: 'Empatico', + Tecnico: 'Tecnico', +} as const; diff --git a/apps/frontend/src/api/generated/model/savePromptConfigurationResponse.ts b/apps/frontend/src/api/generated/model/savePromptConfigurationResponse.ts new file mode 100644 index 00000000..03dc6907 --- /dev/null +++ b/apps/frontend/src/api/generated/model/savePromptConfigurationResponse.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.23.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ +import type { MvpState } from './mvpState'; +import type { PromptConfiguration } from './promptConfiguration'; + +export interface SavePromptConfigurationResponse { + message: string; + configuration: PromptConfiguration; + state: MvpState; +} diff --git a/apps/frontend/src/api/generated/model/startCommunicationGenerationResponse.ts b/apps/frontend/src/api/generated/model/startCommunicationGenerationResponse.ts index e515f6b6..8a7377cf 100644 --- a/apps/frontend/src/api/generated/model/startCommunicationGenerationResponse.ts +++ b/apps/frontend/src/api/generated/model/startCommunicationGenerationResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/subDocument.ts b/apps/frontend/src/api/generated/model/subDocument.ts index 820d46d4..281d0c14 100644 --- a/apps/frontend/src/api/generated/model/subDocument.ts +++ b/apps/frontend/src/api/generated/model/subDocument.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. @@ -30,6 +30,10 @@ export interface SubDocument { /** @nullable */ description?: string | null; /** @nullable */ + recipientEmail?: string | null; + /** @nullable */ + uploadedAt?: string | null; + /** @nullable */ confidence?: number | null; reviewStatus: SubDocumentReviewStatus; reviewStatusLabel: string; @@ -45,8 +49,6 @@ export interface SubDocument { sendExportUrl?: string; previewLines: string[]; /** @nullable */ - recipientEmail?: string | null; - /** @nullable */ fiscalCode?: string | null; /** @nullable */ employeeId?: string | null; diff --git a/apps/frontend/src/api/generated/model/subDocumentReviewStatus.ts b/apps/frontend/src/api/generated/model/subDocumentReviewStatus.ts index c9c06e54..aedb6317 100644 --- a/apps/frontend/src/api/generated/model/subDocumentReviewStatus.ts +++ b/apps/frontend/src/api/generated/model/subDocumentReviewStatus.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/subDocumentSendStatus.ts b/apps/frontend/src/api/generated/model/subDocumentSendStatus.ts index 367176e5..5d49863a 100644 --- a/apps/frontend/src/api/generated/model/subDocumentSendStatus.ts +++ b/apps/frontend/src/api/generated/model/subDocumentSendStatus.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/unauthorizedResponse.ts b/apps/frontend/src/api/generated/model/unauthorizedResponse.ts index 8669a834..f0b844d0 100644 --- a/apps/frontend/src/api/generated/model/unauthorizedResponse.ts +++ b/apps/frontend/src/api/generated/model/unauthorizedResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/updateCommunicationFavoriteResponse.ts b/apps/frontend/src/api/generated/model/updateCommunicationFavoriteResponse.ts new file mode 100644 index 00000000..f2145378 --- /dev/null +++ b/apps/frontend/src/api/generated/model/updateCommunicationFavoriteResponse.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v8.23.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ +import type { Communication } from './communication'; +import type { MvpState } from './mvpState'; + +export interface UpdateCommunicationFavoriteResponse { + message: string; + communication: Communication; + state: MvpState; +} diff --git a/apps/frontend/src/api/generated/model/updateCommunicationRequest.ts b/apps/frontend/src/api/generated/model/updateCommunicationRequest.ts index 25b56ebd..25d3d46b 100644 --- a/apps/frontend/src/api/generated/model/updateCommunicationRequest.ts +++ b/apps/frontend/src/api/generated/model/updateCommunicationRequest.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/updateCommunicationResponse.ts b/apps/frontend/src/api/generated/model/updateCommunicationResponse.ts index b8c21a2f..e853ab49 100644 --- a/apps/frontend/src/api/generated/model/updateCommunicationResponse.ts +++ b/apps/frontend/src/api/generated/model/updateCommunicationResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/updateExtractedDataRequest.ts b/apps/frontend/src/api/generated/model/updateExtractedDataRequest.ts index 2f6145e1..baf43144 100644 --- a/apps/frontend/src/api/generated/model/updateExtractedDataRequest.ts +++ b/apps/frontend/src/api/generated/model/updateExtractedDataRequest.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/updateMvpCommunicationCoverImageBody.ts b/apps/frontend/src/api/generated/model/updateMvpCommunicationCoverImageBody.ts index fe1a9efb..ec53c3b8 100644 --- a/apps/frontend/src/api/generated/model/updateMvpCommunicationCoverImageBody.ts +++ b/apps/frontend/src/api/generated/model/updateMvpCommunicationCoverImageBody.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/updateSendMessageRequest.ts b/apps/frontend/src/api/generated/model/updateSendMessageRequest.ts index 60088ca9..3541d6d7 100644 --- a/apps/frontend/src/api/generated/model/updateSendMessageRequest.ts +++ b/apps/frontend/src/api/generated/model/updateSendMessageRequest.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/updateSubDocumentReviewResponse.ts b/apps/frontend/src/api/generated/model/updateSubDocumentReviewResponse.ts index 96403be9..6c512f0b 100644 --- a/apps/frontend/src/api/generated/model/updateSubDocumentReviewResponse.ts +++ b/apps/frontend/src/api/generated/model/updateSubDocumentReviewResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/uploadDocumentResponse.ts b/apps/frontend/src/api/generated/model/uploadDocumentResponse.ts index 6ceb3cf9..4addf291 100644 --- a/apps/frontend/src/api/generated/model/uploadDocumentResponse.ts +++ b/apps/frontend/src/api/generated/model/uploadDocumentResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/uploadMvpDocumentBody.ts b/apps/frontend/src/api/generated/model/uploadMvpDocumentBody.ts index 27e04e78..04cc2c84 100644 --- a/apps/frontend/src/api/generated/model/uploadMvpDocumentBody.ts +++ b/apps/frontend/src/api/generated/model/uploadMvpDocumentBody.ts @@ -1,11 +1,37 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. * OpenAPI spec version: 1.0.0 */ +import type { UploadMvpDocumentBodyDocumentType } from './uploadMvpDocumentBodyDocumentType'; export type UploadMvpDocumentBody = { document: Blob; + /** + * Tipologia documento impostata manualmente in upload. Se presente, prevale sull'estrazione AI e non viene sovrascritta. + * @nullable + */ + documentType?: UploadMvpDocumentBodyDocumentType; + /** + * Azienda di riferimento impostata manualmente in upload. Se presente, prevale sull'estrazione AI e non viene sovrascritta. + * @maxLength 500 + * @nullable + */ + companyName?: string | null; + /** + * Mese di riferimento (1-12) impostato manualmente in upload. Combinato con year forma la data documento preservata dall'AI. + * @minimum 1 + * @maximum 12 + * @nullable + */ + month?: number | null; + /** + * Anno di riferimento impostato manualmente in upload. Combinato con month forma la data documento preservata dall'AI. + * @minimum 1900 + * @maximum 2100 + * @nullable + */ + year?: number | null; }; diff --git a/apps/frontend/src/api/generated/model/uploadMvpDocumentBodyDocumentType.ts b/apps/frontend/src/api/generated/model/uploadMvpDocumentBodyDocumentType.ts new file mode 100644 index 00000000..a0a8e0e2 --- /dev/null +++ b/apps/frontend/src/api/generated/model/uploadMvpDocumentBodyDocumentType.ts @@ -0,0 +1,23 @@ +/** + * Generated by orval v8.23.0 🍺 + * Do not edit manually. + * Alittlebyte MVP API + * Versioned JSON contract consumed by the Angular SPA. + * OpenAPI spec version: 1.0.0 + */ + +/** + * Tipologia documento impostata manualmente in upload. Se presente, prevale sull'estrazione AI e non viene sovrascritta. + * @nullable + */ +export type UploadMvpDocumentBodyDocumentType = typeof UploadMvpDocumentBodyDocumentType[keyof typeof UploadMvpDocumentBodyDocumentType] | null; + + +export const UploadMvpDocumentBodyDocumentType = { + cedolino: 'cedolino', + CU: 'CU', + comunicazione: 'comunicazione', + documento_da_firmare: 'documento da firmare', + lettera: 'lettera', + altro: 'altro', +} as const; diff --git a/apps/frontend/src/api/generated/model/upstreamUnavailableResponse.ts b/apps/frontend/src/api/generated/model/upstreamUnavailableResponse.ts index eb68d08e..68a9c147 100644 --- a/apps/frontend/src/api/generated/model/upstreamUnavailableResponse.ts +++ b/apps/frontend/src/api/generated/model/upstreamUnavailableResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/validationErrorEnvelope.ts b/apps/frontend/src/api/generated/model/validationErrorEnvelope.ts index aeb1d0f0..a2638585 100644 --- a/apps/frontend/src/api/generated/model/validationErrorEnvelope.ts +++ b/apps/frontend/src/api/generated/model/validationErrorEnvelope.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/validationErrorEnvelopeError.ts b/apps/frontend/src/api/generated/model/validationErrorEnvelopeError.ts index 861621e5..84e92f35 100644 --- a/apps/frontend/src/api/generated/model/validationErrorEnvelopeError.ts +++ b/apps/frontend/src/api/generated/model/validationErrorEnvelopeError.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/validationErrorEnvelopeErrorFields.ts b/apps/frontend/src/api/generated/model/validationErrorEnvelopeErrorFields.ts index fb4f2021..a1b1a4b8 100644 --- a/apps/frontend/src/api/generated/model/validationErrorEnvelopeErrorFields.ts +++ b/apps/frontend/src/api/generated/model/validationErrorEnvelopeErrorFields.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/model/validationErrorResponse.ts b/apps/frontend/src/api/generated/model/validationErrorResponse.ts index c00d8f8c..2262eba0 100644 --- a/apps/frontend/src/api/generated/model/validationErrorResponse.ts +++ b/apps/frontend/src/api/generated/model/validationErrorResponse.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. diff --git a/apps/frontend/src/api/generated/mvp-api.ts b/apps/frontend/src/api/generated/mvp-api.ts index d7d24bfe..cd8acd66 100644 --- a/apps/frontend/src/api/generated/mvp-api.ts +++ b/apps/frontend/src/api/generated/mvp-api.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v8.18.0 🍺 + * Generated by orval v8.23.0 🍺 * Do not edit manually. * Alittlebyte MVP API * Versioned JSON contract consumed by the Angular SPA. @@ -36,7 +36,10 @@ import type { MvpState, RateCommunicationRequest, RateCommunicationResponse, + SavePromptConfigurationRequest, + SavePromptConfigurationResponse, StartCommunicationGenerationResponse, + UpdateCommunicationFavoriteResponse, UpdateCommunicationRequest, UpdateCommunicationResponse, UpdateExtractedDataRequest, @@ -130,12 +133,12 @@ function filterParams( if (filtered.length) { filteredParams[key] = filtered; } - } else if ( - preserveRequiredNullables && - value === null && - requiredNullableKeys.has(key) - ) { - filteredParams[key] = null; + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; } else if ( value != null && (typeof value === 'string' || @@ -268,6 +271,148 @@ export class AlittlebyteMVPAPIService { ); } +/** + * @summary Save the current prompt configuration for later reuse (UC-19) + */ + saveMvpPromptConfiguration(savePromptConfigurationRequest: SavePromptConfigurationRequest, options?: HttpClientBodyOptions): Observable; + saveMvpPromptConfiguration(savePromptConfigurationRequest: SavePromptConfigurationRequest, options?: HttpClientEventOptions): Observable>; + saveMvpPromptConfiguration(savePromptConfigurationRequest: SavePromptConfigurationRequest, options?: HttpClientResponseOptions): Observable>; + saveMvpPromptConfiguration( + savePromptConfigurationRequest: SavePromptConfigurationRequest, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `/api/v1/prompt-configurations`, + savePromptConfigurationRequest,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `/api/v1/prompt-configurations`, + savePromptConfigurationRequest,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.post( + `/api/v1/prompt-configurations`, + savePromptConfigurationRequest,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + +/** + * @summary Permanently delete a saved prompt configuration + */ + deleteMvpPromptConfiguration(promptConfiguration: number, options?: HttpClientBodyOptions): Observable; + deleteMvpPromptConfiguration(promptConfiguration: number, options?: HttpClientEventOptions): Observable>; + deleteMvpPromptConfiguration(promptConfiguration: number, options?: HttpClientResponseOptions): Observable>; + deleteMvpPromptConfiguration( + promptConfiguration: number, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.delete( + `/api/v1/prompt-configurations/${promptConfiguration}`,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.delete( + `/api/v1/prompt-configurations/${promptConfiguration}`,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.delete( + `/api/v1/prompt-configurations/${promptConfiguration}`,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + +/** + * @summary Add a communication to favorites + */ + favoriteMvpCommunication(communication: number, options?: HttpClientBodyOptions): Observable; + favoriteMvpCommunication(communication: number, options?: HttpClientEventOptions): Observable>; + favoriteMvpCommunication(communication: number, options?: HttpClientResponseOptions): Observable>; + favoriteMvpCommunication( + communication: number, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `/api/v1/communications/${communication}/favorite`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `/api/v1/communications/${communication}/favorite`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.post( + `/api/v1/communications/${communication}/favorite`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + +/** + * @summary Remove a communication from favorites + */ + unfavoriteMvpCommunication(communication: number, options?: HttpClientBodyOptions): Observable; + unfavoriteMvpCommunication(communication: number, options?: HttpClientEventOptions): Observable>; + unfavoriteMvpCommunication(communication: number, options?: HttpClientResponseOptions): Observable>; + unfavoriteMvpCommunication( + communication: number, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.delete( + `/api/v1/communications/${communication}/favorite`,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.delete( + `/api/v1/communications/${communication}/favorite`,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.delete( + `/api/v1/communications/${communication}/favorite`,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + /** * @summary Update the title and body of a draft communication */ @@ -380,6 +525,43 @@ export class AlittlebyteMVPAPIService { ); } +/** + * @summary Save a communication draft to history, leaving it editable and regenerable + */ + saveMvpCommunication(communication: number, options?: HttpClientBodyOptions): Observable; + saveMvpCommunication(communication: number, options?: HttpClientEventOptions): Observable>; + saveMvpCommunication(communication: number, options?: HttpClientResponseOptions): Observable>; + saveMvpCommunication( + communication: number, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `/api/v1/communications/${communication}/save`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `/api/v1/communications/${communication}/save`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.post( + `/api/v1/communications/${communication}/save`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + /** * @summary Discard a communication draft */ @@ -729,6 +911,18 @@ formData.append(`image`, updateMvpCommunicationCoverImageBody.image); uploadMvpDocument( uploadMvpDocumentBody: UploadMvpDocumentBody, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> {const formData = new FormData(); formData.append(`document`, uploadMvpDocumentBody.document); +if(uploadMvpDocumentBody.documentType !== undefined && uploadMvpDocumentBody.documentType !== null) { + formData.append(`documentType`, uploadMvpDocumentBody.documentType); + } +if(uploadMvpDocumentBody.companyName !== undefined && uploadMvpDocumentBody.companyName !== null) { + formData.append(`companyName`, uploadMvpDocumentBody.companyName); + } +if(uploadMvpDocumentBody.month !== undefined && uploadMvpDocumentBody.month !== null) { + formData.append(`month`, uploadMvpDocumentBody.month.toString()) + } +if(uploadMvpDocumentBody.year !== undefined && uploadMvpDocumentBody.year !== null) { + formData.append(`year`, uploadMvpDocumentBody.year.toString()) + } if (options?.observe === 'events') { return this.http.post( diff --git a/apps/frontend/src/app/core/navigation/app-views.ts b/apps/frontend/src/app/core/navigation/app-views.ts index 263fa748..59cc45e9 100644 --- a/apps/frontend/src/app/core/navigation/app-views.ts +++ b/apps/frontend/src/app/core/navigation/app-views.ts @@ -30,7 +30,7 @@ export const mvpNavGroups: SidebarNavGroup[] = [ label: "Overview", children: [ { label: "Moduli", targetId: "overview-modules" }, - { label: "Priorita", targetId: "overview-priorities" } + { label: "Priorità", targetId: "overview-priorities" } ] } ] diff --git a/apps/frontend/src/app/core/state/mvp-state.store.spec.ts b/apps/frontend/src/app/core/state/mvp-state.store.spec.ts index 4166298c..8e47e7be 100644 --- a/apps/frontend/src/app/core/state/mvp-state.store.spec.ts +++ b/apps/frontend/src/app/core/state/mvp-state.store.spec.ts @@ -6,7 +6,7 @@ import type { MvpState, SubDocument } from "../../../api/generated/model"; function stateWith(assistantMetrics: MvpState["assistant"]["metrics"], copilotMetrics: MvpState["copilot"]["metrics"]): MvpState { return { - assistant: { metrics: assistantMetrics, history: [] }, + assistant: { metrics: assistantMetrics, history: [], promptConfigurations: [] }, copilot: { metrics: copilotMetrics, documents: [] } } as MvpState; } @@ -55,10 +55,21 @@ describe("MvpStateStore", () => { expect(store.state()).toBeNull(); expect(store.documents()).toEqual([]); expect(store.history()).toEqual([]); + expect(store.promptConfigurations()).toEqual([]); expect(store.assistantMetrics()).toEqual([]); expect(store.copilotMetrics()).toEqual([]); }); + it("espone le configurazioni di prompt salvate (UC-19)", () => { + const state = stateWith([], []); + state.assistant.promptConfigurations = [ + { id: 1, name: "Ferie estive", prompt: "Un prompt qualsiasi", tone: "Empatico", style: "Comunicato" } + ]; + store.setState(state); + + expect(store.promptConfigurations()).toEqual(state.assistant.promptConfigurations); + }); + it("carica lo stato una sola volta e aggiorna loading ed errore", () => { const state = stateWith([{ key: "assistant.drafts", value: 3, label: "Bozze" }], []); getMvpState.mockReturnValue(of(state)); diff --git a/apps/frontend/src/app/core/state/mvp-state.store.ts b/apps/frontend/src/app/core/state/mvp-state.store.ts index 260a43ab..e64a2c9d 100644 --- a/apps/frontend/src/app/core/state/mvp-state.store.ts +++ b/apps/frontend/src/app/core/state/mvp-state.store.ts @@ -26,6 +26,7 @@ export class MvpStateStore { readonly documents = computed(() => this._state()?.copilot.documents ?? []); readonly history = computed(() => this._state()?.assistant.history ?? []); + readonly promptConfigurations = computed(() => this._state()?.assistant.promptConfigurations ?? []); readonly assistantMetrics = computed(() => this._state()?.assistant.metrics ?? []); readonly copilotMetrics = computed(() => this._state()?.copilot.metrics ?? []); diff --git a/apps/frontend/src/app/features/assistant/assistant-page.spec.ts b/apps/frontend/src/app/features/assistant/assistant-page.spec.ts index 18d3eab9..cf8c69c0 100644 --- a/apps/frontend/src/app/features/assistant/assistant-page.spec.ts +++ b/apps/frontend/src/app/features/assistant/assistant-page.spec.ts @@ -1,7 +1,7 @@ import { signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { of, throwError } from "rxjs"; -import type { Communication } from "../../../api/generated/model"; +import type { Communication, PromptConfiguration } from "../../../api/generated/model"; import { MvpStateStore } from "../../core/state/mvp-state.store"; import type { CommunicationDraftForm, GeneratedDraft } from "./assistant.model"; import { AssistantPage } from "./assistant-page"; @@ -24,6 +24,7 @@ function communication(overrides: Partial = {}): Communication { generationStatusLabel: "Completata", status: "Bozza", statusValue: "draft", + isFavorite: false, rating: null, ratingComment: null, ratedAt: null, @@ -38,11 +39,13 @@ describe("AssistantPage", () => { style: "Testo informativo" }; let history: ReturnType>; + let promptConfigurations: ReturnType>; let assistant: Record; let animation: jest.SpyInstance; beforeEach(() => { history = signal([]); + promptConfigurations = signal([]); assistant = { searchCommunications: jest.fn(() => of([])), generate: jest.fn(), @@ -52,7 +55,12 @@ describe("AssistantPage", () => { update: jest.fn(), removeCoverImage: jest.fn(), discard: jest.fn(), - deleteFromHistory: jest.fn() + saveToHistory: jest.fn(), + saveConfiguration: jest.fn(), + deleteConfiguration: jest.fn(), + deleteFromHistory: jest.fn(), + favorite: jest.fn(), + unfavorite: jest.fn() }; animation = jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { callback(0); @@ -60,7 +68,10 @@ describe("AssistantPage", () => { }); TestBed.configureTestingModule({ providers: [ - { provide: MvpStateStore, useValue: { history, error: signal(null) } }, + { + provide: MvpStateStore, + useValue: { history, error: signal(null), promptConfigurations } + }, { provide: AssistantService, useValue: assistant } ] }); @@ -297,6 +308,102 @@ describe("AssistantPage", () => { expect(page["previewDraft"]()).not.toBeNull(); }); + it("salva una bozza nello storico (UC-9) o mantiene lo stato in caso di errore", () => { + const page = createPage(); + page["saveToHistory"](); + expect(assistant["saveToHistory"]).not.toHaveBeenCalled(); + + setDraft(page); + assistant["saveToHistory"].mockReturnValue( + of({ message: "Bozza salvata nello storico.", communication: communication({ status: "Approvata", statusValue: "approved" }) }) + ); + page["saveToHistory"](); + expect(page["status"]()).toBe("Bozza salvata nello storico."); + expect(page["previewDraft"]()?.status).toBe("Approvata"); + expect(page["isSavingToHistory"]()).toBe(false); + + setDraft(page); + assistant["saveToHistory"].mockReturnValue(throwError(() => new Error("salvataggio fallito"))); + page["saveToHistory"](); + expect(page["status"]()).toBe("salvataggio fallito"); + }); + + it("salva la configurazione del prompt o segnala l'errore (UC-19)", () => { + const page = createPage(); + assistant["saveConfiguration"].mockReturnValue(of({ message: "Configurazione salvata." })); + + page["saveConfiguration"]({ prompt: "Un prompt qualsiasi lungo abbastanza", tone: "Tecnico", style: "Avviso operativo" }); + + expect(page["status"]()).toBe("Configurazione salvata."); + expect(page["isSavingConfiguration"]()).toBe(false); + + assistant["saveConfiguration"].mockReturnValue(throwError(() => new Error("salvataggio config fallito"))); + page["saveConfiguration"]({ prompt: "Un prompt qualsiasi lungo abbastanza", tone: "Tecnico", style: "Avviso operativo" }); + + expect(page["saveConfigurationError"]()).toBe("salvataggio config fallito"); + }); + + it("filtra le configurazioni salvate con gli stessi filtri dello storico", () => { + promptConfigurations.set([ + { id: 1, name: "Ferie estive", prompt: "Avviso sulle ferie estive", tone: "Empatico", style: "Avviso operativo", createdAt: "2026-03-04" }, + { id: 2, name: "Manutenzione", prompt: "Comunicazione tecnica di manutenzione", tone: "Tecnico", style: "Testo informativo", createdAt: "2026-05-01" } + ]); + const page = createPage(); + + expect(page["filteredPromptConfigurations"]()).toHaveLength(2); + + page["activeFilters"].set({ keyword: "ferie" }); + expect(page["filteredPromptConfigurations"]().map((c) => c.id)).toEqual([1]); + + page["activeFilters"].set({ tone: "Tecnico" }); + expect(page["filteredPromptConfigurations"]().map((c) => c.id)).toEqual([2]); + + page["activeFilters"].set({ style: "Avviso operativo" }); + expect(page["filteredPromptConfigurations"]().map((c) => c.id)).toEqual([1]); + + page["activeFilters"].set({ date: "2026-05-01" }); + expect(page["filteredPromptConfigurations"]().map((c) => c.id)).toEqual([2]); + + page["activeFilters"].set({ keyword: "nessuna corrispondenza" }); + expect(page["filteredPromptConfigurations"]()).toEqual([]); + }); + + it("riusa una configurazione salvata senza chiamare il backend (UC-19)", () => { + const page = createPage(); + + page["useConfiguration"]({ + id: 1, + name: "Ferie estive", + prompt: "Prompt della configurazione salvata", + tone: "Empatico", + style: "Avviso operativo" + }); + + expect(page["prefillPayload"]()).toEqual({ + prompt: "Prompt della configurazione salvata", + tone: "Empatico", + style: "Avviso operativo" + }); + expect(assistant["generate"]).not.toHaveBeenCalled(); + }); + + it("elimina una configurazione salvata o segnala l'errore", () => { + const page = createPage(); + page["confirmingConfigDeleteId"].set(3); + assistant["deleteConfiguration"].mockReturnValue(of({ message: "Configurazione eliminata." })); + + page["deleteConfiguration"](3); + + expect(page["status"]()).toBe("Configurazione eliminata."); + expect(page["confirmingConfigDeleteId"]()).toBeNull(); + expect(page["isDeletingConfig"]()).toBe(false); + + assistant["deleteConfiguration"].mockReturnValue(throwError(() => new Error("eliminazione config fallita"))); + page["deleteConfiguration"](3); + + expect(page["status"]()).toBe("eliminazione config fallita"); + }); + it("elimina dallo storico e pulisce le anteprime collegate", () => { const page = createPage(); setDraft(page); @@ -326,6 +433,37 @@ describe("AssistantPage", () => { expect(page["isDeletingHistoryItem"]()).toBe(false); }); + it("aggiunge e rimuove una comunicazione dai preferiti", () => { + const page = createPage(); + const notFavorite = communication({ id: 7, isFavorite: false }); + + assistant["favorite"].mockReturnValue(of({ message: "Generazione aggiunta ai preferiti." })); + page["toggleFavorite"](notFavorite); + + expect(assistant["favorite"]).toHaveBeenCalledWith(7); + expect(assistant["unfavorite"]).not.toHaveBeenCalled(); + expect(page["status"]()).toBe("Generazione aggiunta ai preferiti."); + expect(page["togglingFavoriteId"]()).toBeNull(); + + const favorite = communication({ id: 7, isFavorite: true }); + assistant["unfavorite"].mockReturnValue(of({ message: "Generazione rimossa dai preferiti." })); + page["toggleFavorite"](favorite); + + expect(assistant["unfavorite"]).toHaveBeenCalledWith(7); + expect(page["status"]()).toBe("Generazione rimossa dai preferiti."); + expect(page["togglingFavoriteId"]()).toBeNull(); + }); + + it("espone l'errore di aggiornamento dei preferiti", () => { + const page = createPage(); + assistant["favorite"].mockReturnValue(throwError(() => new Error("preferiti non disponibili"))); + + page["toggleFavorite"](communication({ id: 7, isFavorite: false })); + + expect(page["status"]()).toBe("preferiti non disponibili"); + expect(page["togglingFavoriteId"]()).toBeNull(); + }); + it("azzera tutti i filtri e aggiorna il flag", () => { const page = createPage(); page["activeFilters"].set({ keyword: "ferie" }); diff --git a/apps/frontend/src/app/features/assistant/assistant-page.ts b/apps/frontend/src/app/features/assistant/assistant-page.ts index 44242465..66319e30 100644 --- a/apps/frontend/src/app/features/assistant/assistant-page.ts +++ b/apps/frontend/src/app/features/assistant/assistant-page.ts @@ -1,18 +1,25 @@ import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { LucideTrash2 } from "@lucide/angular"; +import { LucideStar, LucideTrash2 } from "@lucide/angular"; import { debounceTime, distinctUntilChanged, finalize } from "rxjs"; import { AssistantService, type CommunicationFilters } from "./data/assistant.service"; -import type { Communication, UpdateCommunicationRequest } from "../../../api/generated/model"; +import type { + Communication, + GenerateCommunicationRequestStyle, + GenerateCommunicationRequestTone, + PromptConfiguration, + SavePromptConfigurationRequest, + UpdateCommunicationRequest +} from "../../../api/generated/model"; import { MvpStateStore } from "../../core/state/mvp-state.store"; import { getApiErrorMessage } from "../../core/errors/api-error"; +import { ButtonComponent } from "../../shared/components/button/button"; import { EmptyStateComponent } from "../../shared/components/empty-state/empty-state"; import { ErrorStateComponent } from "../../shared/components/error-state/error-state"; import { SectionComponent } from "../../layout/section/section"; import { StatusBadgeComponent } from "../../shared/components/status-badge/status-badge"; -import { ButtonComponent } from "../../shared/components/button/button"; -import { formatFallback } from "../../shared/util/formatters"; +import { formatDateForDisplay, formatFallback } from "../../shared/util/formatters"; import { CommunicationGeneratorPanelComponent } from "./components/communication-generator-panel"; import { GeneratedCommunicationPreviewComponent } from "./components/generated-communication-preview"; import { communicationStyles, communicationTones } from "./assistant.model"; @@ -33,6 +40,7 @@ import type { EmptyStateComponent, ErrorStateComponent, GeneratedCommunicationPreviewComponent, + LucideStar, LucideTrash2, ReactiveFormsModule, SectionComponent, @@ -48,13 +56,19 @@ import type { [isGenerating]="isGenerating()" [status]="status()" [phase]="phase()" + [promptConfigurations]="promptConfigurations()" + [isSavingConfiguration]="isSavingConfiguration()" + [saveConfigurationError]="saveConfigurationError()" + [prefill]="prefillPayload()" (generate)="generate($event)" + (saveConfiguration)="saveConfiguration($event)" /> @@ -104,6 +119,63 @@ import type { + @if (filteredPromptConfigurations().length) { +
+ Configurazioni salvate + @for (configuration of filteredPromptConfigurations(); track configuration.id) { +
+
+ + {{ configuration.name }} + {{ formatDateForDisplay(configuration.createdAt) }} + +
+ + @if (confirmingConfigDeleteId() !== configuration.id) { + + } +
+
+ @if (confirmingConfigDeleteId() === configuration.id) { +
+

Eliminare definitivamente questa configurazione salvata?

+
+ + +
+
+ } +
+ } +
+ } + @if (filteredCommunications().length) { @for (communication of filteredCommunications(); track communication.id) {
@@ -118,6 +190,21 @@ import type {

{{ formatFallback(communication.createdAt) }}

+ @if (confirmingDeleteId() !== communication.id) { + + } @else { + + } +
`, styleUrl: "./communication-generator-panel.css" @@ -62,25 +108,78 @@ export class CommunicationGeneratorPanelComponent { readonly isGenerating = input.required(); readonly status = input.required(); readonly phase = input.required(); + readonly promptConfigurations = input([]); + readonly isSavingConfiguration = input(false); + readonly saveConfigurationError = input(null); + /** Valori da caricare nel form da fuori (riuso di una configurazione salvata, UC-19). */ + readonly prefill = input(null); readonly generate = output(); + readonly saveConfiguration = output(); protected readonly tones = communicationTones; protected readonly styles = communicationStyles; + protected readonly isConfiguringName = signal(false); + protected readonly configNameControl = new FormControl("", { nonNullable: true }); protected readonly form = new FormGroup({ prompt: new FormControl( - "Scrivi una comunicazione interna per informare i dipendenti che la nuova area documentale NEXUM e disponibile. Spiega cosa cambia, dove trovare i documenti e perche la consultazione diventa piu semplice.", + "Scrivi una comunicazione interna per informare i dipendenti che la nuova area documentale NEXUM è disponibile. Spiega cosa cambia, dove trovare i documenti e perché la consultazione diventa più semplice.", { nonNullable: true, validators: [Validators.required, Validators.minLength(12)] } ), tone: new FormControl("Chiaro e diretto", { nonNullable: true }), style: new FormControl("Testo informativo", { nonNullable: true }) }); + constructor() { + // Riuso di una configurazione salvata (UC-19): il genitore spinge i + // valori da fuori, qui li applichiamo al form. + effect(() => { + const values = this.prefill(); + + if (values) { + this.form.setValue(values); + } + }); + + // Il salvataggio (UC-19) e' confermato dal genitore tramite lo stato + // aggiornato: quando l'elenco cambia (nuova configurazione salvata) il + // modulo del nome si richiude da solo. In caso di errore l'elenco non + // cambia, quindi il modulo resta aperto con il messaggio visibile. + effect(() => { + this.promptConfigurations(); + this.isConfiguringName.set(false); + this.configNameControl.reset(""); + }); + } + protected submit(): void { if (this.form.invalid) { this.form.markAllAsTouched(); return; } + // Avviare una nuova generazione chiude un eventuale salvataggio + // configurazione lasciato a meta': non ha senso restasse aperto sopra + // una bozza che nel frattempo cambia. + this.isConfiguringName.set(false); + this.configNameControl.reset(""); this.generate.emit(this.form.getRawValue()); } + + protected confirmSaveConfiguration(): void { + if (this.form.controls.prompt.invalid) { + this.form.controls.prompt.markAsTouched(); + return; + } + + const raw = this.form.getRawValue(); + const name = this.configNameControl.value.trim(); + + this.saveConfiguration.emit({ + name: name === "" ? undefined : name, + prompt: raw.prompt, + tone: raw.tone, + style: raw.style + }); + } + } diff --git a/apps/frontend/src/app/features/assistant/components/communication-status-card.css b/apps/frontend/src/app/features/assistant/components/communication-status-card.css index e58d3930..510c56f5 100644 --- a/apps/frontend/src/app/features/assistant/components/communication-status-card.css +++ b/apps/frontend/src/app/features/assistant/components/communication-status-card.css @@ -1,5 +1,7 @@ .card { display: grid; + grid-template-columns: 1fr auto; + align-items: start; gap: var(--mvp-space-2); padding: var(--mvp-space-3); border: 1px solid var(--mvp-border); @@ -75,6 +77,19 @@ font-size: var(--mvp-font-sm); } +.favoriteToggle svg { + stroke: currentColor; + fill: none; +} + +.favoriteToggle.isFavorite { + color: var(--mvp-primary); +} + +.favoriteToggle.isFavorite svg { + fill: currentColor; +} + :host { display: contents; } diff --git a/apps/frontend/src/app/features/assistant/components/generated-communication-preview.spec.ts b/apps/frontend/src/app/features/assistant/components/generated-communication-preview.spec.ts index 09b00ef2..e3a3ae67 100644 --- a/apps/frontend/src/app/features/assistant/components/generated-communication-preview.spec.ts +++ b/apps/frontend/src/app/features/assistant/components/generated-communication-preview.spec.ts @@ -48,6 +48,8 @@ describe("GeneratedCommunicationPreviewComponent", () => { expect(component["isCoverPending"](draft({ coverStatus: "processing" }))).toBe(true); expect(component["isCoverPending"](draft({ coverStatus: "failed" }))).toBe(false); expect(component["isDiscarded"](draft({ status: "Scartata" }))).toBe(true); + expect(component["isApproved"](draft({ status: "Approvata" }))).toBe(true); + expect(component["isApproved"](draft({ status: "Bozza" }))).toBe(false); expect(component["isReadyForPreview"](draft())).toBe(true); expect(component["isReadyForPreview"](draft({ generationStatus: "processing" }))).toBe(false); expect(component["isReadyForPreview"](draft({ status: "Scartata" }))).toBe(false); @@ -243,17 +245,41 @@ describe("GeneratedCommunicationPreviewComponent", () => { expect(component["form"].controls.body.touched).toBe(true); }); - it("propaga le azioni di copertina, rigenerazione e scarto", () => { + it("propaga le azioni di copertina, rigenerazione, salvataggio e scarto", () => { const fixture = render(draft({ coverImageUrl: "/cover.png", coverStatus: "ready" })); const events: string[] = []; fixture.componentInstance.removeCover.subscribe(() => events.push("remove")); fixture.componentInstance.regenerate.subscribe(() => events.push("regenerate")); fixture.componentInstance.discard.subscribe(() => events.push("discard")); + fixture.componentInstance.saveToHistory.subscribe(() => events.push("saveToHistory")); fixture.componentInstance.removeCover.emit(); fixture.componentInstance.regenerate.emit(); fixture.componentInstance.discard.emit(); + fixture.componentInstance.saveToHistory.emit(); - expect(events).toEqual(["remove", "regenerate", "discard"]); + expect(events).toEqual(["remove", "regenerate", "discard", "saveToHistory"]); + }); + + it("nasconde solo il salvataggio una volta che la bozza e' nello storico, restando modificabile e rigenerabile", () => { + const fixture = render(draft({ status: "Approvata", statusValue: "approved" })); + const element = fixture.nativeElement as HTMLElement; + const labels = Array.from(element.querySelectorAll("button")).map((button) => button.textContent?.trim()); + + expect(labels).not.toContain("Salva nello storico"); + expect(labels).toContain("Rigenera bozza"); + expect(labels).toContain("Modifica"); + }); + + it("nasconde modifica, rigenerazione e azioni copertina per una bozza scartata", () => { + const fixture = render(draft({ status: "Scartata", statusValue: "draft" })); + const element = fixture.nativeElement as HTMLElement; + const labels = Array.from(element.querySelectorAll("button")).map((button) => button.textContent?.trim()); + + expect(labels).not.toContain("Salva nello storico"); + expect(labels).not.toContain("Rigenera bozza"); + expect(labels).not.toContain("Modifica"); + expect(labels).not.toContain("Cambia immagine"); + expect(labels).not.toContain("Rimuovi immagine"); }); }); diff --git a/apps/frontend/src/app/features/assistant/components/generated-communication-preview.ts b/apps/frontend/src/app/features/assistant/components/generated-communication-preview.ts index 3f1978b7..06f27458 100644 --- a/apps/frontend/src/app/features/assistant/components/generated-communication-preview.ts +++ b/apps/frontend/src/app/features/assistant/components/generated-communication-preview.ts @@ -56,28 +56,30 @@ import {

{{ currentDraft.coverError }}

} -
- - - -
+ @if (!isDiscarded(currentDraft)) { +
+ + + +
+ } @if (saveError()) {

{{ saveError() }}

} @@ -105,7 +107,7 @@ import { {{ isSaving() ? "Salvataggio" : "Salva" }} - } @else if (currentDraft.statusValue === "draft") { + } @else if (!isDiscarded(currentDraft)) {
@if (!isDiscarded(currentDraft)) { + @if (!isApproved(currentDraft)) { + + } + } + @if (!isDiscarded(currentDraft)) { @if (isConfirmingDiscard()) {

- Sei sicuro di voler scartare questa bozza? Non sara' piu' modificabile ne' rigenerabile. + Sei sicuro di voler scartare questa bozza? Non sarà più modificabile né rigenerabile.

+ } +
+ + +
+ + + + + +
+ + @if (phase() !== null) { @@ -31,5 +111,53 @@ export class DocumentUploadPanelComponent { readonly isUploading = input.required(); readonly status = input.required(); readonly phase = input.required(); - readonly upload = output(); + readonly upload = output(); + + protected readonly documentTypes = DOCUMENT_TYPE_OPTIONS; + protected readonly months = MONTHS; + + protected readonly metadataForm = new FormGroup({ + documentType: new FormControl("", { nonNullable: true }), + month: new FormControl("", { nonNullable: true }), + year: new FormControl("", { nonNullable: true }), + companyName: new FormControl("", { nonNullable: true }) + }); + + constructor() { + effect(() => { + if (this.isUploading()) { + this.metadataForm.disable({ emitEvent: false }); + } else { + this.metadataForm.enable({ emitEvent: false }); + } + }); + } + + protected selectDocumentType(option: DocumentTypeOption): void { + if (this.metadataForm.disabled) { + return; + } + + const current = this.metadataForm.controls.documentType.value; + this.metadataForm.controls.documentType.setValue(current === option ? "" : option); + } + + protected onFileSelected(file: File): void { + this.upload.emit({ file, metadata: this.toMetadata() }); + } + + private toMetadata(): DocumentUploadMetadata { + const value = this.metadataForm.getRawValue(); + const documentType = value.documentType.trim(); + const companyName = value.companyName.trim(); + const month = Number.parseInt(value.month, 10); + const year = Number.parseInt(value.year, 10); + + return { + documentType: documentType === "" ? undefined : (documentType as DocumentTypeOption), + companyName: companyName === "" ? undefined : companyName, + month: Number.isInteger(month) && month >= 1 && month <= 12 ? month : undefined, + year: Number.isInteger(year) && year >= 1900 && year <= 2100 ? year : undefined + }; + } } diff --git a/apps/frontend/src/app/features/copilot/components/sub-document-list.css b/apps/frontend/src/app/features/copilot/components/sub-document-list.css index 8c1fd071..9a2ccc68 100644 --- a/apps/frontend/src/app/features/copilot/components/sub-document-list.css +++ b/apps/frontend/src/app/features/copilot/components/sub-document-list.css @@ -176,8 +176,12 @@ gap: var(--mvp-space-3); } +/* align-content start: le celle della griglia sono alte quanto la piu' alta + della riga, e senza questo vincolo il campo affiancato a uno in errore si + stirerebbe per riempire lo spazio, disallineando le caselle. */ .field { display: grid; + align-content: start; gap: var(--mvp-space-1); color: var(--mvp-text); font-weight: 700; @@ -265,10 +269,14 @@ background: var(--mvp-warning-soft); } -.fieldError { +/* Piu' specifico di ".field span": a parita' di peso quella regola vincerebbe + e imporrebbe al messaggio lo stile dell'etichetta, cioe' maiuscolo, 800 e + colore muted, rendendolo indistinguibile da un titolo di campo. */ +.field .fieldError { color: var(--mvp-warning); - font-size: var(--mvp-font-xs); - font-weight: 700; + font-size: var(--mvp-font-sm); + font-weight: 600; + text-transform: none; } .field select { @@ -285,6 +293,27 @@ grid-column: 1 / -1; } +.fieldWithAction { + display: flex; + align-items: center; + gap: var(--mvp-space-2); +} + +.fieldWithAction input { + flex: 1; +} + +.copyButton { + flex-shrink: 0; +} + +.copyFeedback { + color: var(--mvp-success); + font-size: var(--mvp-font-xs); + font-weight: 700; + text-transform: none; +} + .reviewActions { display: flex; flex-wrap: wrap; diff --git a/apps/frontend/src/app/features/copilot/components/sub-document-list.spec.ts b/apps/frontend/src/app/features/copilot/components/sub-document-list.spec.ts index 47fdefa1..0d9a2b60 100644 --- a/apps/frontend/src/app/features/copilot/components/sub-document-list.spec.ts +++ b/apps/frontend/src/app/features/copilot/components/sub-document-list.spec.ts @@ -56,6 +56,8 @@ interface TestableSubDocumentList { confidenceDisplay(document: SubDocument): string; documentDateDisplay(document: SubDocument): string; saveReview(): void; + readonly copiedEmail: Signal; + copyRecipientEmail(email: string): void; } describe("SubDocumentListComponent", () => { @@ -281,6 +283,36 @@ describe("SubDocumentListComponent", () => { expect(component.documentDateDisplay(subDocument({ documentDate: null }))).toBe("Non disponibile"); }); + it("copia l'email destinatario negli appunti e mostra il feedback per 2 secondi", async () => { + jest.useFakeTimers(); + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + const { component } = render(); + + component.copyRecipientEmail("mario.rossi@example.test"); + await Promise.resolve(); + + expect(writeText).toHaveBeenCalledWith("mario.rossi@example.test"); + expect(component.copiedEmail()).toBe(true); + + jest.advanceTimersByTime(2000); + expect(component.copiedEmail()).toBe(false); + + jest.useRealTimers(); + }); + + it("non mostra il feedback di copia se la scrittura negli appunti fallisce", async () => { + const writeText = jest.fn().mockRejectedValue(new Error("clipboard non disponibile")); + Object.assign(navigator, { clipboard: { writeText } }); + const { component } = render(); + + component.copyRecipientEmail("mario.rossi@example.test"); + await Promise.resolve(); + await Promise.resolve(); + + expect(component.copiedEmail()).toBe(false); + }); + it("annulla la sottoscrizione alla preview quando il componente viene distrutto", () => { const teardown = jest.fn(); previewStatus.mockReturnValue( diff --git a/apps/frontend/src/app/features/copilot/components/sub-document-list.ts b/apps/frontend/src/app/features/copilot/components/sub-document-list.ts index 3988d995..caf16806 100644 --- a/apps/frontend/src/app/features/copilot/components/sub-document-list.ts +++ b/apps/frontend/src/app/features/copilot/components/sub-document-list.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; import { DomSanitizer, type SafeResourceUrl } from "@angular/platform-browser"; -import { LucideCheckCircle2, LucidePencil, LucideSave, LucideTrash2, LucideX } from "@lucide/angular"; +import { LucideCheckCircle2, LucideCopy, LucidePencil, LucideSave, LucideTrash2, LucideX } from "@lucide/angular"; import type { SubDocument, UpdateExtractedDataRequest, UpdateSendMessageRequest } from "../../../../api/generated/model"; import { ButtonComponent } from "../../../shared/components/button/button"; import { EmptyStateComponent } from "../../../shared/components/empty-state/empty-state"; @@ -57,6 +57,7 @@ const emptySendMessageForm: SendMessageFormState = { DocumentStatusTimelineComponent, EmptyStateComponent, LucideCheckCircle2, + LucideCopy, LucidePencil, LucideSave, LucideTrash2, @@ -192,6 +193,31 @@ const emptySendMessageForm: SendMessageFormState = { Stato revisione + +