diff --git a/.dockerignore b/.dockerignore index 8b8a1a4..41696bd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -44,3 +44,4 @@ docker-compose.override.yml .idea .vscode .phpunit.result.cache +.phpunit.cache diff --git a/.gitignore b/.gitignore index 0823e78..af876cf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ /public/storage /public/build/ /.phpunit.result.cache +/.phpunit.cache/ /.idea/ /.vscode/ *.swp diff --git a/HANDOFF.md b/HANDOFF.md index 0a82df8..d0f1db2 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto. -Ultimo aggiornamento: **29 luglio 2026**. +Ultimo aggiornamento: **30 luglio 2026**. --- @@ -22,18 +22,36 @@ redeploy, login, tutte le pagine, code e scheduler. | Sviluppo locale | ✅ stesso compose, profilo `dev` | | Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale | | Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) | -| Test automatici | ❌ **assenti** — vedi [Cosa resta aperto](#cosa-resta-aperto) | +| Test automatici | ⚠️ **impalcatura pronta, 57 test** — copertura ancora parziale | -**Punto in cui ci si è fermati (29/07/2026).** Su `develop` è pubblicato tutto: +**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto: backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre -di avanzamento reali e console di log (ultimo commit `f909791`, deploy -verificato confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è -impostato **solo** su develop; la produzione non è stata toccata e resta sul suo -deploy precedente, senza quella variabile. +di avanzamento reali e console di log (commit `f909791`, deploy verificato +confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo** +su develop; la produzione non è stata toccata e resta sul suo deploy +precedente, senza quella variabile. -Il ripristino dall'interfaccia resta però impraticabile per pacchetti grandi -finché non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta -aperto*. +Il 30/07 sono state fatte due cose. + +**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando, +senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione +della logica di business — vedi *Cosa resta aperto*. Non fanno parte +dell'immagine di produzione e non girano su un deploy: sono uno strumento +locale. + +**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con +un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con +gli stati di focus, che prima non c'erano), e **una sola console** per tutte le +operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di +log sparsi. L'azzeramento è diventato un componente a sé per poter stare in +fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel +browser — apertura automatica della console, scorrimento, lampeggio delle zone — +non è stato provato**, perché richiede un browser: è la prima cosa da guardare +aprendo la pagina. + +Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché +non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto* +e l'unica che richiede una decisione, non del lavoro. --- @@ -556,6 +574,228 @@ Tre scelte non ovvie: hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità. +### La pagina "Backup e import": cinque zone e una sola console + +La pagina era cresciuta per accumulo: sette riquadri senza un ordine dichiarato, +la zona pericolosa a metà strada, **cinque** aree di log separate e pulsanti che +ogni sezione ridefiniva per conto proprio. È stata rimessa in ordine attorno a +tre regole. + +**Cinque zone numerate, in ordine di quanto sono definitive.** `1 Backup`, +`2 Ripristino`, `3 Import PDF`, `4 XML`, `5 Zona pericolosa`. Ognuna ha +un'ancora (`id="zona-…"`) e l'indice appiccicato in cima ci salta sopra. Un test +verifica che le cinque ancore esistano *e che siano in quell'ordine nel +sorgente*: l'ordine è la sostanza della sistemazione, non un dettaglio estetico. + +**Il "focus" della pagina.** Premuto un pulsante — o scelta una voce +dell'indice — la pagina si sposta sulla zona di riferimento e la fa lampeggiare +(`window.tmFocusZona`). Il focus da tastiera si sposta **solo** quando la +richiesta è di navigazione: spostarlo anche a ogni azione strapperebbe il +cursore da sotto le dita di chi ha appena premuto. Il primo comando di ogni zona +è marcato `data-zona-focus` ed è quello che lo riceve. + +**Una sola console, agganciata in fondo alla finestra.** Prima la stessa +operazione si raccontava in tre punti diversi: un ripristino cominciava nel +riquadro "Browser", proseguiva in quello "Server" e aveva un terzo "Dettaglio" +richiudibile nel pannello di avanzamento, in cima. Per capire dove si era +fermato bisognava guardarli tutti. Adesso c'è una cronologia sola, in ordine di +tempo, con la sorgente accanto a ogni riga, e la finestra è *fissa*: quasi tutti +i modi in cui un caricamento muore sono muti, e l'unico indizio è dove si è +interrotto il flusso di righe — un log che esce dallo schermo appena si scorre +non serve proprio nel momento in cui servirebbe. + +Dettagli non ovvi della console: + +- **Le righe del server viaggiano già scremate.** Il conto di quante ne sono + state spedite lo tiene il componente Livewire (`logSpediti`), non il browser: + la coda intera dentro ogni risposta significherebbe sessanta righe poco meno + di due volte al secondo, e per l'import PDF — la cui coda non ha tetto — + l'intera cronologia a ogni poll. + +- **Per ogni operazione c'è un solo componente che la racconta** + (`SegueOperazioneBackup::tipiSeguiti()`). Backup e zona pericolosa guardano lo + stesso stato condiviso: senza un proprietario dichiarato spedirebbero entrambi + le stesse righe, e la console le mostrerebbe due volte. + +- **Il download automatico si ascolta in un posto solo.** `scarica-backup` è un + evento su `window`: due componenti che lo ascoltassero entrambi assegnerebbero + `window.location.href` due volte, e il pacchetto verrebbe scaricato due volte. + L'ascolto sta nella console, che è l'unico elemento sempre presente. Per lo + stesso motivo `scaricaOra()` esce subito se non è il proprietario a chiamarlo: + il segno "download già inviato" sta nello stato condiviso, quindi chi segue + l'operazione di riflesso lo consumerebbe per primo — l'archivio sarebbe pronto + e non arriverebbe a nessuno, e per l'azzeramento è pure il passo che sblocca + la cancellazione. + +- **Lo scorrimento automatico si sospende se si torna indietro a leggere.** + Trascinare in fondo mentre si sta guardando una riga di dieci secondi prima è + il modo più rapido di rendere inutile un log che scorre. Compare invece + "Torna in fondo". + +- **Il pannello è a posizione fissa, quindi la pagina ha uno spaziatore in + fondo.** Senza, coprirebbe l'ultima zona — che è quella pericolosa. + +- **Il JavaScript della console sta in un ` + +
+ + {{-- Spaziatore: il pannello è a posizione fissa, quindi senza questo + coprirebbe l'ultima zona della pagina — che è quella pericolosa. --}} + + +
+
+ + + {{-- Stato dell'operazione. aria-live sta solo qui e non sul corpo: + un log che si aggiorna ogni 750ms letto ad alta voce riga per + riga sarebbe inascoltabile. --}} +
+ + + + + +
+
+
+ + +
+ +
+ + +
+
+ +
+ +
+ + +
+
diff --git a/resources/views/livewire/settings/settings-edit.blade.php b/resources/views/livewire/settings/settings-edit.blade.php index cb92bbd..5ebaa28 100644 --- a/resources/views/livewire/settings/settings-edit.blade.php +++ b/resources/views/livewire/settings/settings-edit.blade.php @@ -6,9 +6,9 @@

Sezione Import

- + Apri strumenti di import - +

Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.

@@ -72,7 +72,7 @@
- + Salva impostazioni
diff --git a/resources/views/livewire/settings/tipologie-index.blade.php b/resources/views/livewire/settings/tipologie-index.blade.php index 5c5b322..97dfd70 100644 --- a/resources/views/livewire/settings/tipologie-index.blade.php +++ b/resources/views/livewire/settings/tipologie-index.blade.php @@ -11,7 +11,7 @@ @error('nuovaTipologia')

{{ $message }}

@enderror - + Aggiungi @@ -23,8 +23,8 @@ @if($editingId === $tipo->id)
- - + Salva + Annulla
@else
@@ -35,9 +35,9 @@ {{ $tipo->territori()->count() }} territori
- - - + Rinomina + {{ $tipo->attivo ? 'Disattiva' : 'Attiva' }} + Elimina
@endif diff --git a/resources/views/livewire/settings/users-index.blade.php b/resources/views/livewire/settings/users-index.blade.php index c897b71..fecbe57 100644 --- a/resources/views/livewire/settings/users-index.blade.php +++ b/resources/views/livewire/settings/users-index.blade.php @@ -51,7 +51,7 @@
- + Crea utente
@@ -106,8 +106,8 @@
- - + Salva + Annulla
@else @@ -124,15 +124,14 @@
- - + Modifica + {{-- wire:confirm come nel resto dell'app, al posto di un onclick + che fermava la propagazione a mano. --}} + + Elimina +
@endif diff --git a/resources/views/livewire/settings/xml-exchange.blade.php b/resources/views/livewire/settings/xml-exchange.blade.php index b23e1ed..c15c836 100644 --- a/resources/views/livewire/settings/xml-exchange.blade.php +++ b/resources/views/livewire/settings/xml-exchange.blade.php @@ -1,375 +1,429 @@ +{{-- + Pagina "Backup e import". + + Cinque zone numerate, in ordine di quanto sono definitive: + + 1 Backup completo (BackupManager) + 2 Ripristina da backup (BackupManager) + 3 Import PDF territori (qui) + 4 Dati in formato XML (qui) + 5 Zona pericolosa (ZonaPericolosa) + + Ogni zona ha un'ancora, e l'indice in cima ci porta sopra evidenziandola: + con cinque riquadri lunghi, arrivare "circa dalle parti giuste" scorrendo + non basta a capire dove si e' atterrati. + + Backup, ripristino e azzeramento stanno in componenti a se' perche' hanno + uno stato proprio e un poll fitto che non deve far ri-renderizzare tutta la + pagina — inclusa la tabella degli esiti dell'import, che puo' avere + centinaia di righe. + + L'avanzamento e i log di TUTTE le operazioni finiscono in una sola console, + agganciata in fondo alla finestra. Qui dentro non ci sono piu' riquadri di + log: ce n'erano cinque, e la stessa operazione si raccontava in tre punti + diversi della pagina. +--}}
+

Backup e import

-

Backup completo dell’applicazione, import PDF territori, conversione legacy SQL, import ed export XML.

+

+ Backup completo dell’applicazione, ripristino, import dei PDF dei territori, + scambio dati in XML e azzeramento. +

- {{-- Backup, ripristino e zona pericolosa: componente a sé perché ha uno - stato proprio (l'operazione in corso) e un poll che non deve far - ri-renderizzare tutto il resto della pagina. Sta in cima perché è la - sezione che serve nel 99% dei casi; le sezioni XML più sotto restano - per gli usi parziali e per la conversione dei dump legacy. --}} + {{-- ─── Indice delle zone ────────────────────────────────────────────── + Sposta la pagina e fa lampeggiare la zona, poi porta il focus da + tastiera sul primo comando: chi naviga senza mouse deve trovarsi + dentro la zona, non doverci arrivare a furia di tabulazioni. --}} + {{-- x-data vuoto ma necessario: x-on non funziona senza uno scope Alpine + sopra di sé, e queste voci non ne avrebbero altrimenti nessuno. --}} + + + {{-- ═══ Zone 1 e 2 ════════════════════════════════════════════════════ --}} -
-

Import PDF territori

-

Puoi importare una cartella di PDF oppure, meglio per archivi grandi, un file ZIP contenente i PDF. Il nome file puo variare: basta che contenga il numero di un territorio gia presente nell'app. I PDF verranno associati ai territori esistenti e verra generata anche la thumbnail.

+ let ultimaTacca = -1; + xhr.upload.addEventListener('progress', (e) => { + if (!e.lengthComputable) { + return; + } + this.zipPercento = Math.round((e.loaded / e.total) * 100); + const tacca = Math.floor(this.zipPercento / 10); + if (tacca > ultimaTacca) { + ultimaTacca = tacca; + this.log(`Caricamento ZIP ${this.zipPercento}%`); + } + }); -
-
- - @error('pdfFolder')

{{ $message }}

@enderror - @error('pdfFolder.*')

{{ $message }}

@enderror -
+ xhr.addEventListener('load', () => { + this.zipInCorso = false; -
-
- - -
-
-
-
-
+ let payload = {}; + try { + payload = JSON.parse(xhr.responseText || '{}'); + } catch (errore) { + payload = {}; + } -
- -
Preparazione import in corso...
-
-
+ if (xhr.status >= 200 && xhr.status < 300) { + this.zipPercento = 100; + this.log(`Archivio ricevuto. La pagina si ricarica sulla console dell'import.`, 'ok'); + window.location = payload.redirect_url || window.location.href; + return; + } -
-

Import da archivio ZIP

-

Consigliato per grandi volumi: carichi un solo file e il server estrae automaticamente tutti i PDF.

+ this.zipPercento = 0; + const messaggio = payload.message + || (payload.errors && payload.errors.pdfZip ? payload.errors.pdfZip[0] : `Errore durante il caricamento dello ZIP (HTTP ${xhr.status}).`); + this.log(`ERRORE: ${messaggio}`, 'errore'); + }); -
+ xhr.addEventListener('error', () => { + this.zipInCorso = false; + this.zipPercento = 0; + this.log(`ERRORE DI RETE durante il caricamento dello ZIP.`, 'errore'); + }); + + xhr.send(new FormData(form)); + }, + }" + x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { $dispatch('console-apri'); log('Caricamento della cartella avviato...'); }" + x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { log('Caricamento cartella: ' + $event.detail.progress + '%'); }" + x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { log('Cartella caricata. Preparazione import sul server...', 'ok'); }" + x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { log('ERRORE durante il caricamento temporaneo dei file.', 'errore'); }"> + +
+

3 · Import PDF territori

+

+ Associa i PDF ai territori già presenti e ne genera le miniature. Il nome del file può + variare: basta che contenga il numero di un territorio esistente. +

+
+ + {{-- Due strade per la stessa cosa. La primaria e' una sola — l'archivio + ZIP — perche' e' quella che regge i volumi veri: un solo file da + caricare invece di centinaia di richieste temporanee. --}} +
+ + {{-- Strada consigliata --}} + @csrf
- - @error('pdfZip')

{{ $message }}

@enderror +

Da archivio ZIP — consigliato

+

Un solo file: il server estrae i PDF da sé. È la via da usare per grandi volumi.

-
-
- - -
-
-
-
+
+ + + @error('pdfZip') @enderror
-
- -
Caricamento ZIP diretto in corso...
+
+
+ + + Importa archivio ZIP + + + + {{-- Strada alternativa --}} +
+
+

Da cartella

+

Comoda per pochi file. Ogni PDF viaggia come caricamento temporaneo a sé.

+
+ +
+ + + @error('pdfFolder') @enderror + @error('pdfFolder.*') @enderror +
+ +

+ + + Importa cartella + + Preparazione import...
-
-
Console upload
- -
+ {{-- Esito. I log stanno nella console; qui restano i numeri e l'elenco + dei file che non sono stati associati, che e' l'unica cosa su cui si + deve poi agire a mano. --}} + @if($currentPdfImportId && (!empty($pdfImportStats) || !empty($pdfImportIssues))) +
importPdfInCorso) wire:poll.1000ms="refreshPdfImportStatus" @endif> - @if($currentPdfImportId && (!empty($pdfImportStats) || !empty($pdfImportLogs))) -
-
-
-
Stato import PDF
-
ID import: {{ $currentPdfImportId }}
+
+
Esito dell’import
+ + {{ match($pdfImportStatus) { + 'queued' => 'In coda', + 'running' => 'In esecuzione', + 'completed' => 'Completato', + 'failed' => 'Fallito', + default => 'Inattivo', + } }} +
- - {{ match($pdfImportStatus) { - 'queued' => 'In coda', - 'running' => 'In esecuzione', - 'completed' => 'Completato', - 'failed' => 'Fallito', - default => 'Inattivo', - } }} - -
- @if(!empty($pdfImportStats)) -
-
-
Processati
-
{{ $pdfImportStats['processed'] ?? 0 }} / {{ $pdfImportStats['total'] ?? 0 }}
-
-
-
Aggiornati
-
{{ $pdfImportStats['updated'] ?? 0 }}
-
-
-
Saltati
-
{{ $pdfImportStats['skipped'] ?? 0 }}
-
-
-
Errori
-
{{ $pdfImportStats['errors'] ?? 0 }}
-
-
- @endif + @if(!empty($pdfImportStats)) +
+
+
Processati
+
{{ $pdfImportStats['processed'] ?? 0 }} / {{ $pdfImportStats['total'] ?? 0 }}
+
+
+
Aggiornati
+
{{ $pdfImportStats['updated'] ?? 0 }}
+
+
+
Saltati
+
{{ $pdfImportStats['skipped'] ?? 0 }}
+
+
+
Errori
+
{{ $pdfImportStats['errors'] ?? 0 }}
+
+
+ @endif -
-
-
Log import PDF
- -
- + @if(!empty($pdfImportIssues)) +
+
File non associati o problematici
+
+ + + + + + + + + + @foreach($pdfImportIssues as $issue) + + + + + + @endforeach + +
FileMotivoTerritori rilevati
{{ $issue['file'] ?? '-' }}{{ $issue['message'] ?? '-' }}{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}
+
+
+ @endif
- - @if(!empty($pdfImportIssues)) -
-
Riepilogo file non associati o problematici
-
- - - - - - - - - - @foreach($pdfImportIssues as $issue) - - - - - - @endforeach - -
FileMotivoTerritori rilevati
{{ $issue['file'] ?? '-' }}{{ $issue['message'] ?? '-' }}{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}
-
-
- @endif -
@endif -
+ -
-

Conversione dump SQL legacy

-

Carica il file SQL (es. dump-termanager-202604071526.sql) e genera un XML compatibile con l'import dell'app.

+ {{-- ═══ Zona 4 — Dati in formato XML ══════════════════════════════════ --}} +
+
+

4 · Dati in formato XML

+

+ Scambio dei soli dati gestionali, senza i file caricati. Per un backup davvero completo + usa il pacchetto .zip della : + ripristinando solo l’XML i territori restano senza PDF né miniature. +

+
-
- @csrf -
- - @error('sqlDump')

{{ $message }}

@enderror -
+
-
- -
- -
+ {{-- Export --}} +
+

Esporta

+

Tutti i dati correnti in un file XML, reimportabile qui accanto.

-
-

Import XML nell'app

-

Importa un XML nel formato TerManager2. L'import sostituisce i dati gestionali (zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni e impostazioni).

- -
- @csrf -
- - @error('xmlImport')

{{ $message }}

@enderror -
- -
- -
-
-
- - @if(!empty($importStats)) -
-

Log importazione

-
-
Utenti importati: {{ $importStats['utenti_importati'] ?? 0 }}
-
Zone importate: {{ $importStats['zone_importate'] ?? 0 }}
-
Tipologie importate: {{ $importStats['tipologie_importate'] ?? 0 }}
-
Proclamatori importati: {{ $importStats['proclamatori_importati'] ?? 0 }}
-
Territori importati: {{ $importStats['territori_importati'] ?? 0 }}
-
Anni importati: {{ $importStats['anni_importati'] ?? 0 }}
-
Campagne importate: {{ $importStats['campagne_importate'] ?? 0 }}
-
Assegnazioni importate: {{ $importStats['assegnazioni_importate'] ?? 0 }}
-
Territori duplicati saltati: {{ $importStats['duplicate_territori'] ?? 0 }}
-
Assegnazioni saltate: {{ $importStats['assegnazioni_saltate'] ?? 0 }}
-
- -
- -
- - @if(!empty($importIssues)) -
- - - - - - - - - - @foreach($importIssues as $issue) - - - - - - @endforeach - -
EntitàLegacy IDMotivo
{{ $issue['entity'] }}{{ $issue['legacy_id'] }}{{ $issue['reason'] }}
+
+ Dati sensibili. Il file include gli utenti con le password (hash) e i nomi dei + proclamatori in chiaro: chi lo ottiene può tentare di forzare le password offline.
- @endif -
- @endif -
-

Export XML (solo dati)

-

- Esporta tutti i dati correnti dell'app in XML, reimportabile qui sopra. - Non include i file caricati: ripristinando solo questo, i territori restano - senza PDF né miniature. Per un backup davvero completo usa il pacchetto .zip in cima alla pagina. -

+ + + Esporta XML + +
-
- Dati sensibili. Il file include gli utenti con le password (hash) e i nomi dei proclamatori in chiaro. - Conservalo in un luogo sicuro e non condividerlo: chi lo ottiene può tentare di forzare le password offline. + {{-- Import --}} +
+

Importa

+

+ Sostituisce zone, tipologie, proclamatori, territori, anni, campagne, + assegnazioni e impostazioni. +

+ +
+ @csrf +
+ + + @error('xmlImport') @enderror +
+ + Importa XML +
+
-
- -
-
+ {{-- Conversione legacy: e' una tantum, quindi sta in fondo alla zona e + non si prende una colonna accanto alle due operazioni ricorrenti. --}} +
+ Converti un dump SQL della vecchia applicazione +

+ Carica il file SQL (per esempio dump-termanager-202604071526.sql) + e ottieni un XML compatibile con l’import qui sopra. Non scrive niente nell’applicazione: + produce solo un file da scaricare. +

+
+ @csrf +
+ + + @error('sqlDump') @enderror +
+ + Converti in XML +
+
+ + {{-- Esito dell'import XML. L'import e' sincrono (una POST normale), quindi + non ha un flusso di log: ha un riepilogo, e sta qui accanto al + pulsante che lo ha prodotto. --}} + @if(!empty($importStats)) +
+
Esito dell’ultimo import XML
+ +
+
Utenti importati: {{ $importStats['utenti_importati'] ?? 0 }}
+
Zone importate: {{ $importStats['zone_importate'] ?? 0 }}
+
Tipologie importate: {{ $importStats['tipologie_importate'] ?? 0 }}
+
Proclamatori importati: {{ $importStats['proclamatori_importati'] ?? 0 }}
+
Territori importati: {{ $importStats['territori_importati'] ?? 0 }}
+
Anni importati: {{ $importStats['anni_importati'] ?? 0 }}
+
Campagne importate: {{ $importStats['campagne_importate'] ?? 0 }}
+
Assegnazioni importate: {{ $importStats['assegnazioni_importate'] ?? 0 }}
+
Territori duplicati saltati: {{ $importStats['duplicate_territori'] ?? 0 }}
+
Assegnazioni saltate: {{ $importStats['assegnazioni_saltate'] ?? 0 }}
+
+ + + + Scarica il riepilogo in PDF + + + @if(!empty($importIssues)) +
+ + + + + + + + + + @foreach($importIssues as $issue) + + + + + + @endforeach + +
EntitàLegacy IDMotivo
{{ $issue['entity'] }}{{ $issue['legacy_id'] }}{{ $issue['reason'] }}
+
+ @endif +
+ @endif +
+ + {{-- ═══ Zona 5 ════════════════════════════════════════════════════════ --}} + + + {{-- Console unica, agganciata in fondo alla finestra. --}} + @include('livewire.settings.partials.console-operazioni')
diff --git a/resources/views/livewire/settings/zona-pericolosa.blade.php b/resources/views/livewire/settings/zona-pericolosa.blade.php new file mode 100644 index 0000000..d076752 --- /dev/null +++ b/resources/views/livewire/settings/zona-pericolosa.blade.php @@ -0,0 +1,174 @@ +{{-- + Ultima zona della pagina: l'azzeramento. + + Il secondo tempo (verifica del backup, password, conferma) compare QUI, dove + e' stato premuto il primo. Prima usciva in un pannello di avanzamento in cima + alla pagina, a schermate di distanza dal pulsante che lo aveva avviato: chi + lo cercava non lo trovava, e la conferma di un'operazione irreversibile e' + l'ultimo posto in cui ci si puo' permettere di far cercare qualcosa. + + Regole del blocco x-data qui sotto, come nel resto della pagina: nessuna + virgoletta doppia, da nessuna parte, neanche nei commenti — un attributo HTML + finisce alla prima. Gli apostrofi stanno nei backtick. +--}} +@php + $stato = $operazione['status'] ?? null; + $attesa = $this->attesaDownload; + $mioAzzeramento = $this->azzeramentoInCorso; + + // Il posto puo' essere occupato da un backup o da un ripristino avviati + // nelle zone di sopra: il pulsante si spegne, ma per un motivo diverso. + $occupatoDaAltri = $this->inCorso && ! $mioAzzeramento; +@endphp + +
inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif> + +
+ +
+ +
+

5 · Zona pericolosa

+

Operazioni irreversibili. Leggi prima di premere.

+
+
+ + @error('confermaReset') + + @enderror + + {{-- ─── Secondo tempo: l'archivio c'e', manca la conferma ────────── --}} + @if($attesa) +
+

+ L’archivio è pronto e il download è partito. + Non è stato cancellato ancora nulla: l’azzeramento aspetta la tua conferma. +

+

+ Apri il file scaricato e verifica che sia integro. Il server sa di aver spedito + l’archivio, non può sapere se è arrivato intero: quella verifica tocca a te, + ed è l’ultima possibilità di tornare indietro. +

+ + {{-- La password sta in Alpine, non in una proprietà del + componente: le proprietà pubbliche di Livewire tornano + al browser dentro lo snapshot a ogni risposta, e una + password non ha motivo di fare quel viaggio. Passata + come parametro del metodo, viaggia solo in andata. --}} +
+ + + @error('passwordConferma') @enderror +
+ +
+ + Scarica di nuovo + + + + Ho salvato il backup, azzera tutto + + + + Annulla l’azzeramento + +
+
+ @endif + + {{-- ─── Primo tempo ─────────────────────────────────────────────── --}} +
+

Azzera tutto e riparti da zero

+ +

+ Riporta l’applicazione allo stato di installazione appena eseguita. In ordine: +

+
    +
  1. Viene creato e scaricato un backup completo di tutto ciò che c’è adesso.
  2. +
  3. L’operazione si ferma e aspetta: controlli il file scaricato, digiti la tua password e confermi. Fino a quel clic non viene toccato nulla, e puoi annullare.
  4. +
  5. Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e tutti i file caricati.
  6. +
  7. Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.
  8. +
  9. Tutti gli altri utenti vengono eliminati. Resta solo il tuo account di amministratore, con la password attuale — come un’installazione nuova, che ha un amministratore solo.
  10. +
+ +
+ Il backup scaricato è l’unico modo per tornare indietro. Verifica di averlo + salvato prima di chiudere il browser. +
+ +
+ + +
+ +
+ + + Scarica backup e azzera tutto + + + @if($occupatoDaAltri) + + Un backup o un ripristino sono in corso: l’azzeramento può partire solo a operazione finita. + + @elseif($mioAzzeramento && ! $attesa) + + Azzeramento in corso: l’avanzamento è nella console in fondo alla finestra. + + @endif +
+ + @if($stato === 'failed' && $mioAzzeramento) + + @endif + @if($stato === 'completed' && ($operazione['result']['messaggio'] ?? null) && ($operazione['tipo'] ?? null) === 'reset') +

{{ $operazione['result']['messaggio'] }}

+ @endif +
+
+
diff --git a/resources/views/livewire/settings/zone-index.blade.php b/resources/views/livewire/settings/zone-index.blade.php index 1ec5470..a2b53f7 100644 --- a/resources/views/livewire/settings/zone-index.blade.php +++ b/resources/views/livewire/settings/zone-index.blade.php @@ -11,7 +11,7 @@ @error('nuovaZona')

{{ $message }}

@enderror
- + Aggiungi
@@ -23,8 +23,8 @@ @if($editingId === $zona->id)
- - + Salva + Annulla
@else
@@ -35,9 +35,9 @@ {{ $zona->territori()->count() }} territori
- - - + Rinomina + {{ $zona->attivo ? 'Disattiva' : 'Attiva' }} + Elimina
@endif diff --git a/resources/views/livewire/territori/territorio-cestino.blade.php b/resources/views/livewire/territori/territorio-cestino.blade.php index 714ea00..bb10f12 100644 --- a/resources/views/livewire/territori/territorio-cestino.blade.php +++ b/resources/views/livewire/territori/territorio-cestino.blade.php @@ -24,8 +24,8 @@ {{ $territorio->deleted_at->format('d/m/Y H:i') }}
- - + Ripristina + Elimina
diff --git a/resources/views/livewire/territori/territorio-form.blade.php b/resources/views/livewire/territori/territorio-form.blade.php index ea15f28..d7d42f4 100644 --- a/resources/views/livewire/territori/territorio-form.blade.php +++ b/resources/views/livewire/territori/territorio-form.blade.php @@ -56,16 +56,16 @@ @if($isEdit && $this->territorio->pdf_path)
PDF presente - + Rimuovi PDF
@endif
-
- Annulla - + {{-- Azione principale prima, uscita dopo: stesso ordine in tutti i form + dell'applicazione. Prima cambiava da una pagina all'altra. --}} +
+ {{ $isEdit ? 'Aggiorna' : 'Crea' }} + Annulla
diff --git a/resources/views/livewire/territori/territorio-index.blade.php b/resources/views/livewire/territori/territorio-index.blade.php index 5d4b47a..19ee9ca 100644 --- a/resources/views/livewire/territori/territorio-index.blade.php +++ b/resources/views/livewire/territori/territorio-index.blade.php @@ -6,14 +6,14 @@
@can('territori.manage') @endcan
@@ -143,38 +143,54 @@ {{-- Primary actions: Assegna / Rientra --}} @can('territori.assign') @if(!$territorio->assegnazioneCorrente && $territorio->attivo) - - + + Assegna - + @endif @endcan @can('territori.return') @if($territorio->assegnazioneCorrente) - - + {{-- Rientra non cancella niente: era rosso pieno + quanto un Elimina. È l'azione principale + della riga, alternativa ad Assegna. --}} + + Rientra - + @endif @endcan {{-- Secondary actions --}} - - - + {{-- Solo icona: l'aria-label non è ridondante col + title, è l'unico nome che un lettore di + schermo annuncia in modo affidabile. --}} + + + @can('territori.manage') - - - - - + + + + @endcan
diff --git a/resources/views/livewire/territori/territorio-show.blade.php b/resources/views/livewire/territori/territorio-show.blade.php index b587e3d..bc4b3f6 100644 --- a/resources/views/livewire/territori/territorio-show.blade.php +++ b/resources/views/livewire/territori/territorio-show.blade.php @@ -10,25 +10,28 @@
@can('territori.assign') @if(!$territorio->assegnazioneCorrente && $territorio->attivo) - - + + Assegna - + @endif @endcan @can('territori.return') @if($territorio->assegnazioneCorrente) - - + {{-- Rientra era rosso pieno quanto un Elimina. Non cancella + niente: è l'azione principale di un territorio assegnato, + quindi primary come Assegna, che è la sua alternativa. --}} + + Rientra - + @endif @endcan @can('territori.manage') - - + + Modifica - + @endcan
@@ -96,7 +99,7 @@
@can('territori.return') - Rientra + Rientra @endcan
@@ -108,11 +111,11 @@
Link PDF temporaneo
Valido fino al {{ optional($assignmentScadenza)->format('d/m/Y') ?? '—' }} (soglia rientro) o fino al rientro del territorio.
- Apri viewer + Apri viewer
- + Copia link

Link copiato.

diff --git a/tests/Feature/HomeMediaDurataTest.php b/tests/Feature/HomeMediaDurataTest.php new file mode 100644 index 0000000..cabf115 --- /dev/null +++ b/tests/Feature/HomeMediaDurataTest.php @@ -0,0 +1,149 @@ +withoutVite(); + + Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web'])); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); + + $this->utente = User::create([ + 'name' => 'Amministratore', + 'email' => 'admin@example.test', + 'password' => 'password-di-prova', + ]); + $this->utente->assignRole('amministratore'); + $this->actingAs($this->utente); + + $this->proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]); + $this->anno = AnnoTeocratico::corrente(); + } + + public function test_senza_assegnazioni_chiuse_la_media_e_zero(): void + { + $this->assertSame(0, $this->media()); + } + + public function test_un_assegnazione_ancora_aperta_non_entra_nella_media(): void + { + $this->assegnazione($this->anno->start_date->copy()->addDays(10), null); + + $this->assertSame(0, $this->media()); + } + + public function test_la_media_e_in_mesi_da_trenta_giorni_e_mezzo(): void + { + // 10 e 20 giorni: media 15, cioè 15/30,44 = 0,5 mesi. + $this->assegnazioneDiGiorni(10); + $this->assegnazioneDiGiorni(20); + + $this->assertSame(0.5, $this->media()); + } + + public function test_un_rientro_nello_stesso_giorno_non_abbassa_la_media(): void + { + $this->assegnazioneDiGiorni(30); + $this->assegnazioneDiGiorni(0); + + // Senza il filtro la media sarebbe 15 giorni invece di 30. + $this->assertSame(1.0, $this->media()); + } + + /** + * La conversione del vecchio database ha lasciato qualche data impossibile: + * il filtro esisteva per quelle, e deve continuare a esistere. + */ + public function test_una_data_antecedente_al_1900_viene_ignorata(): void + { + $this->assegnazioneDiGiorni(30); + + $this->assegnazione('1899-01-01', '1899-03-01'); + + $this->assertSame(1.0, $this->media()); + } + + public function test_le_assegnazioni_di_un_altro_anno_non_entrano_nella_media(): void + { + $this->assegnazioneDiGiorni(30); + + $altroAnno = AnnoTeocratico::create([ + 'label' => '2000-2001', + 'start_date' => '2000-09-01', + 'end_date' => '2001-08-31', + ]); + $this->assegnazione('2000-09-01', '2001-03-01', $altroAnno); + + $this->assertSame(1.0, $this->media()); + } + + // ─── helper ───────────────────────────────────────────────── + + private function media(): float|int + { + $valore = Livewire::test(Home::class)->viewData('mediaDurataPercorrenzaMesi'); + + return $valore; + } + + private function assegnazioneDiGiorni(int $giorni): void + { + $inizio = $this->anno->start_date->copy()->addDays(5); + + $this->assegnazione($inizio, $inizio->copy()->addDays($giorni)); + } + + private function assegnazione(mixed $da, mixed $a, ?AnnoTeocratico $anno = null): void + { + $territorio = Territorio::create([ + 'numero' => (string) $this->prossimoTerritorio++, + 'attivo' => true, + ]); + + Assegnazione::create([ + 'territorio_id' => $territorio->id, + 'proclamatore_id' => $this->proclamatore->id, + 'anno_teocratico_id' => ($anno ?? $this->anno)->id, + 'assigned_at' => $da, + 'returned_at' => $a, + 'created_by' => $this->utente->id, + ]); + } +} diff --git a/tests/Feature/PaginaBackupImportTest.php b/tests/Feature/PaginaBackupImportTest.php new file mode 100644 index 0000000..aa5bc27 --- /dev/null +++ b/tests/Feature/PaginaBackupImportTest.php @@ -0,0 +1,195 @@ + non viene + * compilato, il tag resta letterale nel PHP generato e la pagina muore con un + * errore di sintassi. Qui quella classe di errori si vede subito. + * + * Quello che questi test NON possono coprire: Alpine e il comportamento della + * console (apertura automatica, scorrimento, evidenziazione delle zone) girano + * nel browser, e qui non c'è un browser. Vanno provati a mano. + */ +class PaginaBackupImportTest extends TestCase +{ + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + + // L'immagine di test non contiene public/build: gli asset sono + // compilati in uno stage a parte, che serve al runtime e non qui. Senza + // questo, ogni pagina morirebbe su "Vite manifest not found" — un + // fallimento che non parla del markup, cioe' l'unica cosa che questi + // test guardano. Che la build degli asset funzioni lo dice `npm run + // build`, non PHPUnit. + $this->withoutVite(); + } + + private function admin(): User + { + Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web'])); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $utente = User::create([ + 'name' => 'Amministratore', + 'email' => 'admin@example.test', + 'password' => 'password-di-prova', + ]); + $utente->assignRole('amministratore'); + + return $utente; + } + + public function test_la_pagina_si_apre_per_l_amministratore(): void + { + $this->actingAs($this->admin()) + ->get(route('xml.exchange')) + ->assertOk(); + } + + public function test_chi_non_e_amministratore_non_ci_entra(): void + { + $utente = User::create([ + 'name' => 'Assistente', + 'email' => 'assistente@example.test', + 'password' => 'password-di-prova', + ]); + + $this->actingAs($utente) + ->get(route('xml.exchange')) + ->assertForbidden(); + } + + /** + * Le cinque zone esistono e sono raggiungibili per ancora: l'indice in cima + * alla pagina ci salta sopra, e un id sbagliato lo renderebbe un pulsante + * che non fa niente. + */ + public function test_ci_sono_tutte_e_cinque_le_zone_con_la_loro_ancora(): void + { + $risposta = $this->actingAs($this->admin())->get(route('xml.exchange')); + + foreach (['zona-backup', 'zona-ripristino', 'zona-pdf', 'zona-xml', 'zona-azzeramento'] as $zona) { + $risposta->assertSee('id="' . $zona . '"', false); + $risposta->assertSee('tmFocusZona(\'' . $zona . '\'', false); + } + } + + /** + * La zona pericolosa è l'ultima. È il motivo per cui l'azzeramento è stato + * estratto in un componente suo: reso da BackupManager finiva a metà pagina, + * fra il ripristino e l'import, che è l'ultimo posto in cui si vuole trovare + * un pulsante che cancella tutto. + */ + public function test_la_zona_pericolosa_e_l_ultima_della_pagina(): void + { + $html = $this->actingAs($this->admin())->get(route('xml.exchange'))->getContent(); + + $posizioni = []; + foreach (['zona-backup', 'zona-ripristino', 'zona-pdf', 'zona-xml', 'zona-azzeramento'] as $zona) { + $posizioni[$zona] = strpos($html, 'id="' . $zona . '"'); + $this->assertNotFalse($posizioni[$zona], "Zona {$zona} assente dalla pagina."); + } + + $this->assertSame( + ['zona-backup', 'zona-ripristino', 'zona-pdf', 'zona-xml', 'zona-azzeramento'], + array_keys($posizioni), + 'Ordine atteso delle chiavi.' + ); + + $precedente = -1; + foreach ($posizioni as $zona => $posizione) { + $this->assertGreaterThan($precedente, $posizione, "La zona {$zona} è fuori ordine."); + $precedente = $posizione; + } + } + + public function test_la_console_unica_e_nella_pagina(): void + { + $risposta = $this->actingAs($this->admin())->get(route('xml.exchange')); + + $risposta->assertSee('tmConsoleOperazioni', false); + $risposta->assertSee('class="tm-console"', false); + } + + /** + * Ordine di caricamento, che è il motivo per cui quel JavaScript sta in uno + *