++ fix e controlli

This commit is contained in:
2026-07-30 17:05:39 +02:00
parent 712336d652
commit 440d63a6b9
54 changed files with 4921 additions and 1322 deletions

View File

@@ -44,3 +44,4 @@ docker-compose.override.yml
.idea
.vscode
.phpunit.result.cache
.phpunit.cache

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@
/public/storage
/public/build/
/.phpunit.result.cache
/.phpunit.cache/
/.idea/
/.vscode/
*.swp

View File

@@ -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 `<script>`, non dentro `x-data`.** È
la via d'uscita dalla trappola documentata più sotto: dentro un `<script>` le
virgolette doppie e gli apostrofi non hanno alcuna limitazione. Non sta in
`resources/js/app.js` perché quel bundle è un modulo ES, che il browser rimanda
a dopo il parsing del documento, mentre Alpine arriva col pacchetto di Livewire
in fondo al `<body>` ed è già avviato a quel punto: una `Alpine.data()`
registrata dopo l'avvio non viene applicata agli elementi già inizializzati. Un
`<script>` classico nella pagina viene eseguito prima, sempre — e c'è un test
che verifica proprio che la definizione preceda `livewire.js`, perché è un
rapporto che guardando i due file non si vede.
**Un solo sistema di pulsanti** — vedi la sezione dedicata qui sotto.
### Un solo pulsante per tutta l'applicazione
`resources/css/app.css` definisce `.tm-btn`;
`resources/views/components/btn.blade.php` è l'unico posto da cui si usa. Nessuna
vista scrive quelle classi a mano, e nessuna vista definisce più un pulsante per
conto proprio.
Da dove veniamo: **quattro vocabolari in circolazione insieme** — `style=`
inline con i colori scritti a mano (più `onmouseover`/`onmouseout` per simulare
l'hover), catene di utility Tailwind ripetute a memoria, `.btn-action*` in sei
tinte e `.btn-primary-*` in due. La stessa azione cambiava aspetto da una pagina
all'altra, l'ordine di *Salva* e *Annulla* si invertiva fra un form e l'altro, e
**nessuno** di quei pulsanti aveva uno stato di focus: chi naviga da tastiera non
vedeva dove si trovava.
Il sistema ha due assi ortogonali, non una tavolozza:
| Asse | Valori | Regola |
|------|--------|--------|
| `variante` | `primary` | l'azione principale. Una sola per schermata o zona. |
| | `secondary` | tutto il resto: dettaglio, modifica, scarica, filtra. |
| | `danger` | **solo** dove si cancellano o si sovrascrivono dati. |
| | `ghost` | annulla, chiudi, pulisci. |
| `tono` | `solido` | il pieno (default), per le azioni isolate. |
| | `tenue` | la tinta chiara, per le righe di elenco. |
| | `velato` | per i comandi sopra una superficie colorata: solo la testata. |
più `dimensione="sm"` per le righe e `icona` per i pulsanti di sola icona.
Le decisioni non ovvie prese convertendo:
- **`tenue` esiste per le liste.** In una riga i pulsanti sono tanti quanti i
record: cinquanta pulsanti pieni sono rumore, e il rosso pieno smette di
significare "attenzione" quando compare cinquanta volte di fila.
- **Il rosso è tornato a significare una cosa sola.** *Rientra* era rosso pieno
quanto un *Elimina*, in tre pagine diverse; anche *Scarica PDF* nella scheda
"Da rientrare" della dashboard lo era, solo perché lo è la scheda che lo
contiene. Nessuno dei due cancella niente.
- **Ordine unico: azione principale prima, uscita dopo.** Valeva già in metà dei
form, l'opposto nell'altra metà, e nelle modali del Registro c'era la terza
variante. Ora è lo stesso ovunque, form e modali comprese.
- **`icona` richiede `aria-label`.** I pulsanti di sola icona avevano solo un
`title`, che i lettori di schermo annunciano in modo inaffidabile: un pulsante
senza nome accessibile è un pulsante che non si può usare senza vederlo.
- **`velato` non è una quinta variante inventata.** La testata ha il suo
gradiente, e lì un pulsante bianco o indaco sarebbe una toppa: il colore lo dà
lo sfondo. Ha anche un anello di focus bianco, perché quello blu su indaco non
si distingue.
Attenzione a una trappola di Blade: dentro un tag di componente si scrive
`:disabled="$condizione"`, **non** `@disabled($condizione)`. Le direttive non
vengono compilate lì dentro: il tag `<x-btn>` resta letterale nel PHP generato
mentre il `</x-btn>` viene chiuso, e la vista muore con «syntax error, unexpected
token endif».
A tenere insieme tutto questo ci sono due test, perché una convenzione senza
guardia dura fino alla prossima pagina scritta copiando una vecchia:
- `tests/Unit/StileUnificatoTest.php` legge i sorgenti delle viste e rifiuta il
ritorno di `.btn-action*`, `.btn-primary-*`, `onmouseover`/`onmouseout`, degli
sfondi scritti a mano su `<button>`/`<a>`, e delle classi `tm-btn` messe
direttamente in una vista invece che dal componente.
- `tests/Feature/PagineApplicativeTest.php` apre **ogni** pagina e verifica che
non sia rimasto un `<x-btn` da compilare — perché un componente non compilato
non solleva: semplicemente il pulsante non c'è.
### Perché l'azzeramento è un componente a sé
`Settings/ZonaPericolosa` è nato da un problema di sola disposizione: la zona
pericolosa deve stare **in fondo** alla pagina, mentre backup e ripristino stanno
in cima, e i tre erano lo stesso componente. Reso da `BackupManager` l'azzeramento
finiva a metà pagina, fra il ripristino e l'import — l'ultimo posto in cui si
vuole trovare un pulsante che cancella tutto.
La logica condivisa non è stata duplicata: sta nel trait
`Livewire/Concerns/SegueOperazioneBackup` (posto unico, aggancio all'operazione
in corso, download automatico, righe verso la console). Duplicare `postoLibero()`
avrebbe significato duplicare la regola su cui si regge il fatto che due
operazioni non si sovrascrivano: è l'ultimo posto dove ha senso avere due copie.
Conseguenza da conoscere: **due componenti pollano lo stesso stato**. Il
proprietario a 750ms (5s in attesa-download), l'altro a 3s — non ha righe da
raccontare, ma deve accorgersi che il posto si è liberato, altrimenti il suo
pulsante resterebbe disabilitato fino al ricaricamento della pagina, con l'aria
di essersi rotto. Si avvisano anche a evento (`operazione-avviata`), indirizzato
con `->to()` e non gridato: senza, tornerebbe pure al mittente.
Il secondo tempo dell'azzeramento (verifica del backup, password, conferma)
compare adesso **dentro la zona**, dove è stato premuto il primo. Prima usciva
nel pannello di avanzamento in cima, a schermate di distanza dal pulsante che lo
aveva avviato.
### La suite di test ha un'immagine sua
Le dipendenze di sviluppo non stanno nell'immagine di produzione, e non devono
cominciare a starci: `--no-dev` è la ragione per cui il runtime non porta con sé
PHPUnit, Faker e Ignition. Da qui uno stage `test` nel `Dockerfile`, che
condivide con `runtime` lo stage `base` — stesse estensioni, stesso `php.ini` —
e si distingue solo per il `vendor/` completo. Un test che passa lì dentro non
può passare grazie a un ambiente diverso da quello vero.
Le scelte che vale la pena conoscere:
- **`runtime` deve restare l'ultimo stage del file.** `docker build` senza
`--target` costruisce l'ultimo, ed è così che lo invocano sia i tre servizi
del compose (nessuno dichiara `target`) sia Dokploy. Uno stage aggiunto in
fondo diventerebbe **in silenzio** l'immagine di produzione. Per questo `test`
sta *prima* di `runtime` pur essendo stato aggiunto dopo. L'avvertenza è anche
nel Dockerfile, sopra lo stage.
- **La suite non dipende da niente.** `phpunit.xml` impone SQLite in memoria,
cache `array` e code `sync`, e il servizio compose non ha `depends_on` né
`env_file`. Gira a stack spento, non tocca `db_data` né `storage_data`, e
soprattutto non può puntare per sbaglio al database di sviluppo: un `.env`
ereditato dal container è esattamente il modo in cui una suite di test
cancella dati veri.
Il rovescio: **SQLite non è MariaDB**. Resta un solo punto non verificabile
qui, l'ordinamento di `Registro::render()`, che usa `CAST(... AS UNSIGNED)`:
va provato sullo stack vero. Gli altri due sono stati tolti di mezzo perché
costavano poco e in cambio rendevano testabile una pagina intera — la
migrazione `2026_04_08_123500`, il cui `GREATEST(...)` faceva proprio morire
la costruzione dello schema, e la media di percorrenza in `Home`, che usava
`DATEDIFF`/`YEAR` e teneva fuori dalla suite la pagina più visitata
dell'applicazione. Entrambe danno lo stesso risultato di prima.
- **Il sorgente non è montato, e il `--build` non è facoltativo.** Con i bind
mount la stessa suite passa da ~15 secondi a ~2 minuti (ogni bootstrap di
Laravel legge una decina di file attraverso il layer di Docker Desktop), e in
cambio si guadagna solo di non ricostruire. Il comando canonico è
`docker compose --profile test run --build --rm test`: senza `--build` si
eseguono i test dell'immagine precedente, che possono essere di due modifiche
fa. Stesso genere di trappola del "Rebuild deployment" su Dokploy.
- **`APP_KEY` di test è fissa e sta in chiaro in `phpunit.xml`.** Nome e cognome
dei proclamatori hanno il cast `encrypted`: senza chiave nemmeno un test che
non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con
la chiave di produzione, che vive nell'ambiente e nel volume.
- **`righeS13()` si chiama in riflessione.** È privata di proposito (non è
un'azione Livewire) ma è il calcolo, non il PDF, quello che va protetto:
renderla pubblica solo per poterla testare avrebbe allargato la superficie
del componente per comodità del test.
### Un solo container esegue le migrazioni
Solo `app` migra. `queue-worker` e `scheduler` usano `queue-entrypoint.sh`, che
@@ -596,6 +836,10 @@ Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
| Cancellazione file senza verifica | «N file cancellati» con i file ancora sul disco (permessi) | `BackupArchive::svuotaDiscoPubblico()` |
| Eliminazione definitiva territorio | La riga spariva, mappa e miniatura restavano orfane sul volume per sempre | `Territori/TerritorioCestino` |
| Virgolette doppie in `x-data` | Attributo HTML chiuso a metà: il JavaScript finiva a video come testo | `backup-manager.blade.php` |
| Warning di `addFile()` al posto del messaggio | Su file illeggibile l'amministratore leggeva `ZipArchive::addFile(): No such file or directory` invece della frase che dice quale file e cosa controllare | `Services/BackupArchive` |
| `GREATEST()` in migrazione | Sintassi MySQL: lo schema non si costruiva su SQLite, quindi la suite di test non poteva partire | migrazione `2026_04_08_123500` |
| `@disabled` dentro `<x-btn>` | Direttiva non compilata: il tag restava letterale e la pagina moriva con «unexpected token endif» | viste di `settings` |
| Segno del download consumato dal componente sbagliato | L'archivio pronto non sarebbe arrivato a nessuno — e per l'azzeramento è il passo che sblocca la cancellazione | `SegueOperazioneBackup::scaricaOra()` |
---
@@ -623,39 +867,60 @@ Resta da accertare **quanto pesa davvero il pacchetto** che si vuole
ripristinare: sotto i 100 MB il problema non sarebbe la dimensione ma il timeout
di 100 secondi di Cloudflare, e la soluzione sarebbe un'altra.
### 1. Nessun test automatico
### 1. Copertura dei test ancora parziale
`composer.json` richiede PHPUnit ma **la cartella `tests/` non esiste** e non
c'è `phpunit.xml`. Ogni verifica è manuale. È il debito più rilevante: le regole
di business (anno teocratico, stato territorio, priorità automatica, percentuale
campagne) sono esattamente il tipo di logica che va protetta da test — e a
maggior ragione backup, ripristino e azzeramento, dove un errore non si vede
finché non è troppo tardi.
L'impalcatura ora c'è (vedi *La suite di test ha un'immagine sua*) e i tre
blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
`test` nel Dockerfile con le dipendenze di sviluppo, connessione `sqlite` in
`config/database.php`, `phpunit.xml`. Si lancia con:
Tre blocchi concreti da rimuovere prima di poter scrivere il primo test:
```bash
docker compose --profile test run --build --rm test
```
1. **PHPUnit non è nell'immagine.** `docker/php/Dockerfile` installa con
`--no-dev` (giusto, per la produzione), quindi `vendor/phpunit` nel container
non c'è e `docker compose exec app ./vendor/bin/phpunit` non funziona.
Servono o un `composer install` locale sull'host, o uno stage `dev` nel
Dockerfile che tenga le dipendenze di sviluppo.
2. **Manca la connessione SQLite.** `config/database.php` ridefinisce
`connections` e lascia solo `mysql`. Per usare SQLite in memoria va
riaggiunta la connessione `sqlite`.
3. **Manca `phpunit.xml`.** L'autoload `Tests\` in `composer.json` c'è già, il
resto no.
**Coperto oggi — 233 test, tutti verdi:**
Dove conviene cominciare, in ordine di rapporto valore/attrito — i primi due
non hanno bisogno del database e quindi si sbloccano risolvendo solo il punto 1:
| File | Cosa protegge |
|------|---------------|
| `tests/Unit/BackupArchiveTest.php` | Round-trip crea → riestrai, manifest, avanzamento fino al totale, fallimento su file illeggibile con archivio rimosso, rifiuto di manifest mancante/illeggibile/di formato futuro e di `data.xml` mancante o vuoto, zip slip (percorso assoluto, `..`, unità Windows) e normalizzazione dei percorsi validi, staging ripulito dopo un fallimento, sostituzione totale del disco al ripristino, svuotamento con conteggio |
| `tests/Unit/BackupOperationStateTest.php` | Il posto unico: occupazione, rilascio a completamento e a fallimento, puntatore orfano, operazione già chiusa, chiusura tardiva che non deve liberare il posto della nuova, attesa-download che lo tiene occupato, azzeramento della barra al cambio fase, coda dei log limitata |
| `tests/Feature/RegistroS13Test.php` | Il ciclo da quattro gruppi del modulo S-13 (1/3/4/5/8/9 assegnazioni), la colonna "Restituito l'ultima volta il" compreso il caso in cui l'ultima del giro precedente è ancora aperta, esclusione di territori disattivati e cestinati, filtro sull'anno di servizio, ordinamento naturale dei numeri |
| `tests/Feature/ZonaPericolosaTest.php` | I due tempi dell'azzeramento: frase di conferma, backup preventivo senza cancellazione, rifiuto se il posto è occupato, conferma senza archivio servito, senza password, con password sbagliata, blocco dopo cinque tentativi, annullamento che libera il posto lasciando il backup valido. In più: che ogni operazione abbia un solo componente a raccontarla, e che il segno del download non venga consumato da chi non la possiede |
| `tests/Feature/PaginaBackupImportTest.php` | La pagina servita per intero: permessi, le cinque zone con la loro ancora **e nel loro ordine**, la console unica, l'assenza dei vecchi riquadri di log, che nessun tag `<x-btn` sia rimasto da compilare, che i pulsanti usino il sistema unico, e che la console sia definita prima del pacchetto che avvia Alpine |
| `tests/Feature/PagineApplicativeTest.php` | **Ogni** pagina dell'applicazione si apre davvero (200) e non ha markup rimasto da compilare. È la rete che serve quando una modifica tocca tutte le viste in una volta: un errore di markup non rompe niente finché qualcuno non apre quella pagina |
| `tests/Unit/StileUnificatoTest.php` | Che i vocabolari di stile paralleli non rientrino: `.btn-action*`, `.btn-primary-*`, l'hover simulato con `onmouseover`, gli sfondi scritti a mano sui pulsanti, le classi `tm-btn` messe in una vista invece che dal componente |
| `tests/Feature/HomeMediaDurataTest.php` | La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio |
- `Services/BackupArchive` con `Storage::fake('public')`: round-trip
crea → verifica → estrai, rifiuto dello zip slip, rifiuto del formato futuro,
fallimento su archivio incompleto.
- `Services/BackupOperationState` con il driver di cache `array`: il posto
unico, il rilascio alla chiusura, il puntatore orfano, il non-rilascio da
parte di un'operazione vecchia.
- `Livewire/Registro::righeS13()`: il ciclo da quattro e la colonna del
riporto, con i casi 2/3/4/5/8/9 assegnazioni (questi sì, con database).
Il primo giro ha già trovato tre difetti reali, tutti corretti: il warning di
`ZipArchive::addFile()` che copriva il messaggio d'errore vero, il `GREATEST()`
in migrazione, e `@disabled` dentro un tag di componente (vedi *Bug corretti*).
Quello che questi test **non** possono coprire: Alpine e il comportamento della
console — apertura automatica, scorrimento, evidenziazione delle zone — girano
nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
**Scoperto ancora, in ordine di quanto costerebbe sbagliarsi:**
- `Services/BackupArchive::extract()` sui **limiti anti zip-bomb**: numero di
entry, dimensione della singola entry, totale decompresso. Sono tre `if` che
nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio.
- `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**:
l'ordine staging → database → file, il `failed()` che libera il posto, la
sopravvivenza dell'amministratore che ha chiesto l'azzeramento, il
`password_change_required` acceso su tutte le righe. È la logica il cui
errore si scopre solo quando è troppo tardi.
- `Livewire/Settings/XmlExchange`: il **round-trip** export → import senza
perdite, che è il presidio contro il tipo di bug già visto con `legacy_id`.
- Le regole di business: anno teocratico (`AnnoTeocratico::perData` a cavallo
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`,
priorità automatica, percentuale di percorrenza delle campagne.
- Il permesso come contratto: che un `assistente` **non** possa modificare o
eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È
scritto in tre posti (rotta, componente, vista) e un test di richiesta lo
verificherebbe tutto insieme.
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
### 2. Race su doppia assegnazione
@@ -688,6 +953,28 @@ limit. Il controllo d'accesso è solido (confronto in tempo costante su codice a
~62 bit, più controlli di scadenza), ma un tentativo di enumerazione non
verrebbe né rallentato né registrato. Un `throttle:30,1` costerebbe una riga.
### 6. Restano da uniformare i colori che non sono pulsanti
I pulsanti sono a posto in tutta l'applicazione (vedi *Un solo pulsante per
tutta l'applicazione*), ma i **colori decorativi** sono ancora scritti a mano,
esadecimale per esadecimale, dentro gli attributi `style=`: le piastrelle delle
icone e le fasce delle schede nella dashboard, gli sfondi delle intestazioni di
tabella, i pannelli delle modali del Registro, i riquadri dei messaggi nel
layout.
Non sono un problema di accessibilità come lo erano i pulsanti — nessuno di
questi elementi si può mettere a fuoco — ma sono lo stesso genere di debito: lo
stesso verde compare in quattro tonalità leggermente diverse, e cambiare la
tinta dell'applicazione oggi significa cercare a mano tutte le occorrenze.
La via è la stessa già percorsa: dei token in `app.css` (o le variabili di tema
di Tailwind 4) e le viste che li citano invece di ripetere i valori. Il test
`StileUnificatoTest` è il posto dove aggiungere la guardia, quando si farà.
Fuori perimetro per scelta: `resources/views/vendor/pagination/*` sono viste
pubblicate da Laravel, e `resources/views/pdf/*` le impagina dompdf, che di
Tailwind e delle nostre classi non sa niente.
---
## Trappole da conoscere
@@ -718,7 +1005,11 @@ Nota sul `524`: anche restando sotto i 100 MB, Cloudflare chiude dopo 100
secondi se l'origine non ha ancora risposto. Un caricamento lento può quindi
fallire pur essendo dentro il limite di dimensione.
**Il JavaScript di `backup-manager.blade.php` vive dentro un attributo HTML.**
**Il JavaScript che vive dentro un attributo HTML.** Vale ancora per i blocchi
`x-data` di `backup-manager`, `zona-pericolosa` e della zona Import PDF, che
gestiscono i caricamenti. La console, che era il blocco più grosso, è stata
portata fuori in un `<script>` proprio per uscirne (vedi *La pagina "Backup e
import"*); il resto resta soggetto alla regola.
`x-data` finisce alla **prima virgoletta doppia**, comprese quelle dentro un
commento JavaScript: un banale `// un generico "errore"` ha chiuso l'attributo a
metà e riversato tutto il codice restante a video come testo — è successo due
@@ -770,7 +1061,13 @@ cp .env.example .env
# SESSION_SECURE_COOKIE=false, e le tre password
docker network create dokploy-network
# 0. La suite passa (non serve nient'altro acceso, ~15 s)
docker compose --profile test run --build --rm test
# 1. L'immagine builda
# Senza --target si costruisce l'ultimo stage, che e' `runtime`: se questo
# comando producesse un'immagine con phpunit dentro, qualcuno ha aggiunto
# uno stage in fondo al Dockerfile.
docker build -f docker/php/Dockerfile -t termanager2-check .
# 2. Stack pulito da zero
@@ -795,8 +1092,9 @@ docker compose exec app php artisan tinker --execute="
"
```
È lo stesso file che gira in produzione: l'unica differenza è il profilo `dev`
(che aggiunge Mailpit) e i valori nel `.env`.
È lo stesso file che gira in produzione: le uniche differenze sono i profili
`dev` (che aggiunge Mailpit) e `test` (che aggiunge la suite, effimera) e i
valori nel `.env`.
---

View File

@@ -14,7 +14,7 @@ operazione è tracciata in un **audit log** consultabile.
**Per iniziare**
- [Deploy su Dokploy](#deploy-su-dokploy) — la via consigliata per la produzione
- [Deploy su altri sistemi](#deploy-su-altri-sistemi) — Coolify, Portainer, server con Docker
- [Sviluppo locale](#sviluppo-locale)
- [Sviluppo locale](#sviluppo-locale) — compreso come lanciare i [test automatici](#test-automatici)
**Gestione quotidiana**
- [Variabili d'ambiente](#variabili-dambiente)
@@ -302,6 +302,42 @@ npm install
npm run dev
```
### Test automatici
La suite gira in un container dedicato: l'immagine di produzione installa le
dipendenze senza quelle di sviluppo, quindi PHPUnit lì dentro non c'è.
```bash
docker compose --profile test run --build --rm test
# una sola classe
docker compose --profile test run --build --rm test --filter BackupArchiveTest
# un solo gruppo
docker compose --profile test run --build --rm test --testsuite Unit
```
Il container esce col codice di PHPUnit, quindi la riga di comando si incatena
come qualsiasi altro comando.
**`--build` non è facoltativo.** Il sorgente non è montato: senza ricostruire,
si eseguono i test dell'ultima immagine costruita, che possono essere di due
modifiche fa. Ometterlo ha senso solo per rilanciare la stessa suite subito
dopo, quando si sa che nulla è cambiato.
Cosa serve sapere:
- **Non serve nient'altro acceso.** `phpunit.xml` impone SQLite in memoria,
cache `array` e code `sync`: la suite passa a stack spento e non tocca né il
database di sviluppo né il volume `storage_data`.
- **`APP_KEY` di test è fissa e scritta in `phpunit.xml`.** Serve perché nome e
cognome dei proclamatori sono cifrati: senza chiave nemmeno un test che non
parla di cifratura riuscirebbe a scrivere una riga. Non ha alcun rapporto con
la chiave di produzione.
- **SQLite non è MariaDB.** Resta un punto che usa SQL specifico di MySQL —
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
provato sullo stack vero, non qui.
---
## Variabili d'ambiente
@@ -398,6 +434,29 @@ Un aggiornamento ordinario non richiede alcun intervento manuale.
## Backup e ripristino
### Com'è fatta la pagina *Backup e import*
Cinque zone numerate, in ordine di quanto sono definitive. L'indice in cima
salta su ciascuna evidenziandola:
| # | Zona | A cosa serve |
|---|------|--------------|
| 1 | Backup completo | Crea e scarica il pacchetto `.zip` |
| 2 | Ripristina da backup | Ricarica un pacchetto: **sostituisce** dati e file |
| 3 | Import PDF territori | Associa i PDF ai territori (da archivio ZIP o da cartella) |
| 4 | Dati in formato XML | Esporta/importa i soli dati, converte i dump della vecchia app |
| 5 | Zona pericolosa | Azzera l'installazione |
In fondo alla finestra c'è **una sola console**, comune a tutte le operazioni:
si apre da sé quando qualcosa parte, scorre da sola man mano che arrivano le
righe e riporta sia quello che succede nel browser (file scelto, avanzamento del
caricamento, codici HTTP, timeout) sia quello che succede sul server, in ordine
di tempo e con la sorgente accanto a ogni riga. Il pulsante **Copia** serve
proprio a questo: il primo passo di ogni diagnosi è farsi mandare quel testo.
Se torni indietro a leggere, lo scorrimento automatico si sospende e compare
*Torna in fondo*.
### Dall'applicazione: pacchetto `.zip` (consigliato)
*Backup e import* → **Crea e scarica backup**. Produce un unico `.zip` con
@@ -405,10 +464,11 @@ tutti i dati gestionali, gli utenti e tutti i file caricati (PDF dei territori,
miniature, logo). È la via da usare per spostare o rimettere in piedi
un'installazione senza toccare i container.
L'archivio si prepara in background e la pagina mostra l'avanzamento reale
L'archivio si prepara in background e la console mostra l'avanzamento reale
(file compressi / file totali); il download parte da solo appena è pronto.
Stessa cosa per il ripristino: prima la barra di caricamento del pacchetto,
poi quella delle fasi di lavoro (estrazione, database, file).
accanto al pulsante, poi le fasi di lavoro nella console (estrazione, database,
file).
Sulla stessa pagina, **Ripristina backup** ricarica il pacchetto: sostituisce i
dati gestionali correnti *e* i file caricati.
@@ -453,20 +513,21 @@ upload lunghissimo da browser è fragile.
> dell'origine; oppure usare il ripristino dai volumi qui sotto. Il piano
> Business arriva a 200 MB, spesso ancora insufficienti.
>
> La **Console** nella scheda di ripristino mostra il codice HTTP ricevuto: se
> La **console** in fondo alla finestra mostra il codice HTTP ricevuto: se
> leggi `413` con un pacchetto sotto `MAX_UPLOAD_MB`, il tetto è a monte.
### Zona pericolosa: azzera tutto
In fondo alla stessa pagina, **Scarica backup e azzera tutto** riporta
Zona 5, in fondo alla stessa pagina: **Scarica backup e azzera tutto** riporta
l'applicazione allo stato di installazione appena eseguita. Va scritto
`AZZERA TUTTO` per sbloccare il pulsante, e l'operazione procede in due tempi:
1. Viene creato e scaricato un backup completo di tutto ciò che c'è.
2. L'operazione **si ferma e aspetta**: controlli il file scaricato, digiti la
**tua password** e confermi con un secondo clic. Fino a lì non viene toccato
nulla, e puoi annullare. Dopo cinque password sbagliate l'azzeramento resta
bloccato per 5 minuti (l'accesso all'applicazione no).
2. L'operazione **si ferma e aspetta**, e la richiesta di conferma compare lì
nella zona 5, sotto il pulsante che l'ha avviata: controlli il file
scaricato, digiti la **tua password** e confermi con un secondo clic. Fino a
lì non viene toccato nulla, e puoi annullare. Dopo cinque password sbagliate
l'azzeramento resta bloccato per 5 minuti (l'accesso all'applicazione no).
3. Solo dopo la conferma vengono eliminati territori, proclamatori,
assegnazioni, campagne, zone, tipologie, anni di servizio, il registro
attività e tutti i file caricati; le impostazioni tornano ai default e i
@@ -824,14 +885,21 @@ TerManager2/
├── docker/
│ ├── nginx/default.conf # Vhost
│ ├── php/
│ │ ├── Dockerfile # Build multi-stage
│ │ ├── Dockerfile # Build multi-stage (runtime = ultimo stage)
│ │ ├── entrypoint.sh # Avvio: chiave, migrazioni, cache
│ │ ├── queue-entrypoint.sh # Avvio worker e scheduler
│ │ ├── php.ini
│ │ └── www.conf
│ └── supervisor/ # nginx + php-fpm nello stesso container
├── resources/ # CSS, JS, viste Blade
├── resources/
│ ├── css/app.css # Sistema di pulsanti, tabelle responsive, console
│ ├── js/
│ └── views/
│ ├── components/btn.blade.php # L'unico pulsante dell'applicazione
│ └── livewire/ # Una vista per componente
├── routes/web.php # Rotte con i relativi permessi
├── tests/ # Suite PHPUnit (profilo compose "test")
├── phpunit.xml # Configurazione e ambiente dei test
├── docker-compose.yml # Unico, produzione e sviluppo
├── .env.example # Tutta la configurazione
├── HANDOFF.md # Stato del progetto per chi subentra

View File

@@ -0,0 +1,294 @@
<?php
namespace App\Livewire\Concerns;
use App\Services\BackupOperationState;
use Illuminate\Support\Str;
/**
* Parte comune ai componenti che avviano e seguono un'operazione lunga di
* backup: il posto unico, l'aggancio a quella in corso, il download automatico
* dell'archivio appena e' pronto, e le righe di log spedite alla console.
*
* Esiste perche' le operazioni sono avviate da due punti della pagina distanti
* fra loro — il backup dalla sua sezione, l'azzeramento dalla zona pericolosa in
* fondo — ma sono la stessa cosa vista dallo stesso stato condiviso. Duplicare
* postoLibero() significherebbe duplicare la regola su cui si regge il fatto che
* due operazioni non si sovrascrivano: e' l'ultimo posto dove ha senso avere due
* copie.
*
* Il resto dello stato vive in cache (BackupOperationState), non nel componente,
* quindi due componenti che seguono la stessa operazione restano coerenti senza
* doversi parlare.
*/
trait SegueOperazioneBackup
{
/** Operazione seguita in questo momento; null quando non c'e' nulla in corso. */
public ?string $operationId = null;
public array $operazione = [];
/** Messaggio mostrato quando un avvio viene rifiutato. */
public string $erroreOperazione = '';
/**
* Quante righe di log sono gia' state spedite al browser.
*
* La console tiene il conto per conto suo, ma senza questo il componente
* rispedirebbe l'intera coda a ogni poll: la console la scarterebbe, ma
* intanto viaggerebbe dentro ogni risposta, sessanta righe alla volta,
* poco meno di due volte al secondo.
*/
public int $logSpediti = 0;
/**
* Riaggancia l'operazione gia' in corso.
*
* Senza, ricaricare la pagina durante un backup lungo farebbe sparire ogni
* traccia, e un azzeramento fermo in attesa di conferma diventerebbe
* irraggiungibile: nessuno potrebbe piu' ne' confermarlo ne' annullarlo, e
* il posto resterebbe occupato fino alla scadenza.
*/
protected function agganciaOperazioneCorrente(): void
{
$this->operationId = app(BackupOperationState::class)->corrente();
$this->logSpediti = 0;
$this->aggiorna();
}
public function aggiorna(): void
{
if (! $this->operationId) {
return;
}
$stato = app(BackupOperationState::class)->get($this->operationId);
if (! $stato) {
$this->operationId = null;
$this->operazione = [];
$this->logSpediti = 0;
return;
}
$this->operazione = $stato;
$this->spedisciAllaConsole();
// Il download parte da solo appena l'archivio e' pronto: per il backup
// e' una comodita', per l'azzeramento e' il passaggio che sblocca la
// cancellazione, quindi non puo' dipendere da un clic.
if ($this->scaricaOra()) {
$this->dispatch('scarica-backup', url: route('backup.file', $this->operationId));
}
}
/**
* Tipi di operazione che questo componente racconta alla console.
*
* Serve a stabilire un proprietario per ogni operazione. Backup e zona
* pericolosa seguono lo stesso stato condiviso, quindi senza questa
* distinzione entrambi spedirebbero le stesse righe e la console le
* mostrerebbe due volte — e a raddoppiare sarebbe anche il polling.
*
* @return array<int, string>
*/
abstract protected function tipiSeguiti(): array;
protected function seguoQuestaOperazione(): bool
{
return in_array((string) ($this->operazione['tipo'] ?? ''), $this->tipiSeguiti(), true);
}
/**
* Manda alla console le righe nuove e lo stato di avanzamento.
*
* La console e' un componente Alpine fuori dal ciclo di Livewire (i suoi
* contenuti non possono essere ri-renderizzati dal server senza cancellare
* le righe scritte dal browser), quindi la comunicazione e' a eventi.
*/
protected function spedisciAllaConsole(): void
{
// Chi non e' il proprietario tace: si aggiorna solo per sapere che il
// posto e' occupato e disabilitare i propri pulsanti.
if (! $this->seguoQuestaOperazione()) {
return;
}
$logs = $this->operazione['logs'] ?? [];
// Coda accorciata: e' un'operazione nuova, si riparte da capo.
if (count($logs) < $this->logSpediti) {
$this->logSpediti = 0;
}
$nuove = array_slice($logs, $this->logSpediti);
$this->logSpediti = count($logs);
$this->dispatch(
'console-operazione',
sorgente: $this->sorgenteConsole(),
righe: array_values($nuove),
etichetta: $this->etichettaOperazione(),
fase: (string) ($this->operazione['fase'] ?? ''),
percento: $this->percentuale,
stato: (string) ($this->operazione['status'] ?? ''),
);
}
protected function sorgenteConsole(): string
{
return match ($this->operazione['tipo'] ?? null) {
BackupOperationState::TIPO_BACKUP => 'backup',
BackupOperationState::TIPO_RESTORE => 'ripristino',
BackupOperationState::TIPO_RESET => 'azzeramento',
default => 'server',
};
}
protected function etichettaOperazione(): string
{
return match ($this->operazione['tipo'] ?? null) {
BackupOperationState::TIPO_BACKUP => 'Backup',
BackupOperationState::TIPO_RESTORE => 'Ripristino',
BackupOperationState::TIPO_RESET => 'Azzeramento',
default => '',
};
}
/**
* Vero se non c'e' gia' un'operazione in corso.
*
* Backup, ripristino e azzeramento lavorano tutti sullo stesso database e
* sullo stesso disco: farne partire due insieme — due schede, due
* amministratori — non li rallenta soltanto, li fa sovrascrivere a vicenda.
*/
protected function postoLibero(): bool
{
$inCorso = app(BackupOperationState::class)->corrente();
if ($inCorso === null) {
$this->erroreOperazione = '';
return true;
}
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
// premuto il pulsante vuole comunque vedere a che punto e'.
$this->operationId = $inCorso;
$this->logSpediti = 0;
$this->aggiorna();
$this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.';
return false;
}
protected function nuovaOperazione(string $tipo): string
{
$operationId = (string) Str::uuid();
$stato = app(BackupOperationState::class);
$stato->initialize($operationId, $tipo, 'In coda');
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
$this->logSpediti = 0;
return $operationId;
}
public function chiudi(): void
{
$this->operationId = null;
$this->operazione = [];
$this->logSpediti = 0;
}
/**
* True una volta sola per operazione: l'evento di download va emesso quando
* l'archivio compare, non a ogni poll successivo.
*
* Il segno che l'evento e' gia' partito sta nello stato condiviso, ed e'
* proprio per questo che solo il proprietario puo' arrivare fin qui: il
* componente che segue l'operazione di riflesso lo consumerebbe per primo —
* polla anche lui — spegnendo il download prima che il proprietario lo
* emetta. L'archivio sarebbe pronto e non arriverebbe a nessuno, e per
* l'azzeramento sarebbe pure il passo che sblocca la cancellazione.
*/
private function scaricaOra(): bool
{
if (! $this->seguoQuestaOperazione()) {
return false;
}
$pronto = ($this->operazione['result']['file'] ?? null) !== null;
$giaInviato = $this->operazione['result']['download_inviato'] ?? false;
if (! $pronto || $giaInviato) {
return false;
}
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
$state['result']['download_inviato'] = true;
return $state;
});
$this->operazione['result']['download_inviato'] = true;
return true;
}
/** Percentuale della fase corrente, 0-100. */
public function getPercentualeProperty(): int
{
if (($this->operazione['status'] ?? null) === 'completed') {
return 100;
}
$total = (int) ($this->operazione['total'] ?? 0);
if ($total <= 0) {
return 0;
}
return (int) min(100, round((int) ($this->operazione['processed'] ?? 0) / $total * 100));
}
/** Se true c'e' un'operazione aperta: i pulsanti di avvio restano disabilitati. */
public function getInCorsoProperty(): bool
{
return $this->operationId !== null
&& in_array($this->operazione['status'] ?? '', ['queued', 'running'], true);
}
/**
* Ogni quanto reinterrogare lo stato.
*
* Tre regimi, per tre esigenze diverse:
*
* - 750ms per il proprietario mentre un job lavora: la barra deve muoversi e
* le righe di log devono arrivare mentre succedono, non dopo.
* - 5s in attesa-download, dove si aspetta una persona che puo' metterci
* minuti: bussare ogni 750ms vorrebbe dire migliaia di richieste inutili se
* qualcuno lascia la scheda aperta e se ne va.
* - 3s per il componente che NON possiede l'operazione. Non ha righe da
* raccontare, ma deve accorgersi che il posto si e' liberato: senza,
* il suo pulsante resterebbe disabilitato fino al ricaricamento della
* pagina, con l'aria di essersi rotto.
*/
public function getIntervalloPollProperty(): string
{
if (! $this->seguoQuestaOperazione()) {
return '3s';
}
return $this->attesaDownload ? '5s' : '750ms';
}
/** Vero quando l'azzeramento ha l'archivio pronto e aspetta la conferma. */
public function getAttesaDownloadProperty(): bool
{
return ($this->operazione['fase'] ?? null) === BackupOperationState::FASE_ATTESA_DOWNLOAD;
}
}

View File

@@ -115,14 +115,27 @@ class Home extends Component
}
// Average assignment duration in months (current year only, matches old app "Media Percorrenza Congregazione")
//
// Il calcolo sta in PHP e non in SQL. La versione precedente usava
// DATEDIFF() e YEAR(), che sono funzioni MySQL: rendevano questa pagina
// — la piu' visitata dell'applicazione — l'unica non verificabile dalla
// suite di test, che gira su SQLite. Il costo e' due colonne di date per
// le assegnazioni chiuse di un anno di servizio: qualche centinaio di
// righe, non un tavolo intero.
//
// I due filtri sono quelli di prima: le date assurde arrivate dalla
// conversione del vecchio database, e le durate non positive, che
// altrimenti abbasserebbero la media senza significare niente.
$avgGiorni = null;
if ($annoCorrente) {
$avgGiorni = Assegnazione::where('anno_teocratico_id', $annoCorrente->id)
$durate = Assegnazione::where('anno_teocratico_id', $annoCorrente->id)
->whereNotNull('returned_at')
->whereRaw('YEAR(assigned_at) >= 1900')
->whereRaw('DATEDIFF(returned_at, assigned_at) > 0')
->selectRaw('AVG(DATEDIFF(returned_at, assigned_at)) as media_giorni')
->value('media_giorni');
->get(['assigned_at', 'returned_at'])
->filter(fn (Assegnazione $a) => $a->assigned_at?->year >= 1900)
->map(fn (Assegnazione $a) => $a->assigned_at->diffInDays($a->returned_at, false))
->filter(fn (float $giorni) => $giorni > 0);
$avgGiorni = $durate->isEmpty() ? null : $durate->avg();
}
$mediaDurataPercorrenzaMesi = $avgGiorni ? round($avgGiorni / 30.44, 1) : 0;

View File

@@ -3,64 +3,41 @@
namespace App\Livewire\Settings;
use App\Jobs\CreateBackupArchive;
use App\Jobs\ResetApplication;
use App\Livewire\Concerns\SegueOperazioneBackup;
use App\Services\BackupOperationState;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
/**
* Avvio e monitoraggio delle operazioni lunghe: backup, ripristino,
* azzeramento.
* Le due zone che si usano tutti i giorni: creare il pacchetto di backup e
* ripristinarlo.
*
* Il lavoro vero sta nei job; qui si avvia e si interroga lo stato. La barra
* mostra `processed/total` dell'operazione in corso, cioe' unita' di lavoro
* davvero completate — file finiti nell'archivio, file estratti, tabelle
* svuotate — non un'animazione a tempo.
* Il lavoro vero sta nei job; qui si avvia e si interroga lo stato. L'avanzamento
* e le righe di log non compaiono piu' in questa sezione: finiscono nella console
* unica agganciata in fondo alla pagina, che le riceve per eventi (vedi il trait
* SegueOperazioneBackup). Qui restano solo i comandi e l'esito.
*
* L'azzeramento sta in ZonaPericolosa, che e' un componente a se' perche' deve
* comparire in fondo alla pagina, dopo tutte le altre zone: dentro questa
* sezione finiva a meta' pagina, fra il ripristino e l'import.
*/
class BackupManager extends Component
{
/** Operazione seguita in questo momento; null quando non c'e' nulla in corso. */
public ?string $operationId = null;
public array $operazione = [];
/** Frase da digitare per sbloccare l'azzeramento. */
public const CONFERMA_AZZERAMENTO = 'AZZERA TUTTO';
/** Password sbagliate ammesse prima di bloccare l'azzeramento, e per quanto. */
private const TENTATIVI_PASSWORD = 5;
private const FINESTRA_TENTATIVI = 300;
public string $confermaReset = '';
/**
* Esiste solo per dare un nome agli errori del campo password: Livewire
* scarta dal messaggio di risposta ogni errore la cui chiave non
* corrisponda a una proprieta' del componente (SupportValidation::dehydrate),
* quindi senza questa riga "Password non corretta" non arriverebbe mai a
* schermo e il pulsante sembrerebbe non fare nulla.
*
* NON collegarla al campo con wire:model: la password viaggia come
* parametro di confermaAzzeramento() proprio per non finire nello
* snapshot, che Livewire rimanda al browser a ogni risposta. Qui resta
* sempre stringa vuota.
*/
public string $passwordConferma = '';
/** Messaggio mostrato in cima quando un avvio viene rifiutato. */
public string $erroreOperazione = '';
use SegueOperazioneBackup;
public function mount(): void
{
$this->authorize('settings.manage');
// Riaggancia un'operazione gia' in corso. Senza, ricaricare la pagina
// durante un backup lungo farebbe sparire la barra, e un azzeramento
// fermo in attesa di conferma diventerebbe irraggiungibile: nessuno
// potrebbe piu' ne' confermarlo ne' annullarlo.
$this->operationId = app(BackupOperationState::class)->corrente();
$this->aggiorna();
$this->agganciaOperazioneCorrente();
}
/**
* Backup e ripristino si raccontano da qui; l'azzeramento e' della zona
* pericolosa. Vedi SegueOperazioneBackup::tipiSeguiti().
*/
protected function tipiSeguiti(): array
{
return [BackupOperationState::TIPO_BACKUP, BackupOperationState::TIPO_RESTORE];
}
public function avviaBackup(): void
@@ -76,303 +53,55 @@ class BackupManager extends Component
CreateBackupArchive::dispatch($this->operationId, false);
$this->aggiorna();
$this->avvisaZonaPericolosa();
}
/**
* Vero se non c'e' gia' un'operazione in corso.
*
* Backup, ripristino e azzeramento lavorano tutti sullo stesso database e
* sullo stesso disco: farne partire due insieme — due schede, due
* amministratori — non li rallenta soltanto, li fa sovrascrivere a vicenda.
*/
private function postoLibero(): bool
{
$inCorso = app(BackupOperationState::class)->corrente();
if ($inCorso === null) {
$this->erroreOperazione = '';
return true;
}
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
// premuto il pulsante vuole comunque vedere a che punto e'.
$this->operationId = $inCorso;
$this->aggiorna();
$this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.';
return false;
}
/**
* Primo tempo dell'azzeramento: si costruisce il backup e lo si scarica.
* La cancellazione non parte da qui — serve il secondo tempo,
* confermaAzzeramento(), a valle del download.
*/
public function avviaAzzeramento(): void
{
$this->authorize('settings.manage');
$this->validate(
['confermaReset' => 'required|in:' . self::CONFERMA_AZZERAMENTO],
['confermaReset.in' => 'Scrivi esattamente "' . self::CONFERMA_AZZERAMENTO . '" per confermare.',
'confermaReset.required' => 'Scrivi "' . self::CONFERMA_AZZERAMENTO . '" per confermare.']
);
if (! $this->postoLibero()) {
return;
}
$this->confermaReset = '';
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
CreateBackupArchive::dispatch($this->operationId, true);
$this->aggiorna();
}
/**
* Secondo tempo dell'azzeramento: parte solo su conferma esplicita, dopo
* che l'archivio e' stato servito.
*
* La conferma umana non e' un doppione del `wire:confirm` iniziale: e' il
* solo punto in cui si sa che il backup e' davvero sul computer di chi sta
* cancellando. Il server puo' verificare di averlo *spedito*
* (`servito_at`), non che sia arrivato: nginx bufferizza la risposta, il
* processo PHP la considera scritta e un download annullato dal browser
* resterebbe indistinguibile da uno riuscito. Chiedere all'operatore e'
* l'unico controllo che copre anche quel caso.
*/
public function confermaAzzeramento(string $password = ''): void
{
$this->authorize('settings.manage');
$this->aggiorna();
if (($this->operazione['fase'] ?? null) !== BackupOperationState::FASE_ATTESA_DOWNLOAD) {
$this->addError('confermaReset', 'Nessun azzeramento in attesa di conferma.');
return;
}
if (($this->operazione['result']['servito_at'] ?? null) === null) {
$this->addError('confermaReset', 'Scarica prima il backup: senza, l\'azzeramento non parte.');
return;
}
if (! $this->passwordConfermata($password)) {
return;
}
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
$state['fase'] = 'Azzeramento in avvio';
$state['processed'] = 0;
$state['total'] = 0;
return $state;
});
ResetApplication::dispatch($this->operationId, (int) auth()->id());
$this->aggiorna();
}
/**
* Verifica la password dell'amministratore prima della cancellazione.
*
* A questo punto la sessione e' gia' autenticata, quindi la password non
* serve a stabilire *chi* sia: serve a stabilire che davanti allo schermo
* ci sia ancora quella persona. E' l'ultimo passo prima di
* un'operazione irreversibile, e una scheda lasciata aperta su una
* postazione condivisa e' lo scenario esatto contro cui protegge.
*
* La password arriva come parametro del metodo e non come proprieta' del
* componente: le proprieta' pubbliche di Livewire vengono serializzate
* nello snapshot e rispedite al browser a ogni risposta, e una password
* non ha motivo di fare quel viaggio di ritorno.
*/
private function passwordConfermata(string $password): bool
{
$utente = auth()->user();
$chiave = 'azzeramento-password:' . $utente->id;
// Senza un limite, la richiesta di password su una sessione dirottata
// sarebbe solo un rallentamento: si proverebbero le password comuni a
// raffica. Il blocco e' sull'azione, non sull'account, quindi nessuno
// resta chiuso fuori dall'applicazione.
if (RateLimiter::tooManyAttempts($chiave, self::TENTATIVI_PASSWORD)) {
$this->addError('passwordConferma', sprintf(
'Troppi tentativi. Riprova fra %d secondi.',
RateLimiter::availableIn($chiave)
));
return false;
}
if ($password === '') {
$this->addError('passwordConferma', 'Inserisci la tua password per confermare.');
return false;
}
if (! Hash::check($password, $utente->getAuthPassword())) {
RateLimiter::hit($chiave, self::FINESTRA_TENTATIVI);
$this->addError('passwordConferma', 'Password non corretta.');
return false;
}
RateLimiter::clear($chiave);
return true;
}
/**
* Annulla un azzeramento fermo in attesa di conferma. Il backup resta
* scaricabile: e' comunque un backup valido.
*/
public function annullaAzzeramento(): void
{
$this->authorize('settings.manage');
if (($this->operazione['fase'] ?? null) !== BackupOperationState::FASE_ATTESA_DOWNLOAD) {
return;
}
app(BackupOperationState::class)->markCompleted($this->operationId, [
'messaggio' => 'Azzeramento annullato. Il backup appena creato resta scaricabile.',
]);
// Da qui in poi e' un backup come un altro: la scheda mostra il
// pulsante di download, non piu' la conferma di cancellazione.
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
$state['tipo'] = BackupOperationState::TIPO_BACKUP;
return $state;
});
$this->aggiorna();
}
/**
* Aggancia un'operazione avviata fuori da Livewire (il ripristino, che
* parte da un upload XHR e restituisce il suo id in JSON).
* Aggancia un'operazione avviata fuori da Livewire: il ripristino parte da un
* upload XHR e restituisce il proprio id in JSON.
*/
public function seguiOperazione(string $operationId): void
{
$this->authorize('settings.manage');
$this->operationId = $operationId;
$this->logSpediti = 0;
$this->aggiorna();
}
public function aggiorna(): void
{
if (! $this->operationId) {
return;
}
$stato = app(BackupOperationState::class)->get($this->operationId);
if (! $stato) {
$this->operationId = null;
$this->operazione = [];
return;
}
$this->operazione = $stato;
// Il download parte da solo appena l'archivio e' pronto: per il backup
// e' una comodita', per l'azzeramento e' il passaggio che sblocca la
// cancellazione, quindi non puo' dipendere da un clic.
if ($this->scaricaOra()) {
$this->dispatch('scarica-backup', url: route('backup.file', $this->operationId));
}
}
public function chiudi(): void
{
$this->operationId = null;
$this->operazione = [];
$this->avvisaZonaPericolosa();
}
/**
* True una volta sola per operazione: l'evento di download va emesso quando
* l'archivio compare, non a ogni poll successivo.
*/
private function scaricaOra(): bool
{
$pronto = ($this->operazione['result']['file'] ?? null) !== null;
$giaInviato = $this->operazione['result']['download_inviato'] ?? false;
if (! $pronto || $giaInviato) {
return false;
}
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
$state['result']['download_inviato'] = true;
return $state;
});
$this->operazione['result']['download_inviato'] = true;
return true;
}
private function nuovaOperazione(string $tipo): string
{
$operationId = (string) \Illuminate\Support\Str::uuid();
$stato = app(BackupOperationState::class);
$stato->initialize($operationId, $tipo, 'In coda');
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
return $operationId;
}
/** Percentuale della fase corrente, 0-100. */
public function getPercentualeProperty(): int
{
$stato = $this->operazione['status'] ?? null;
if ($stato === 'completed') {
return 100;
}
$total = (int) ($this->operazione['total'] ?? 0);
if ($total <= 0) {
return 0;
}
$processed = (int) ($this->operazione['processed'] ?? 0);
return (int) min(100, round($processed / $total * 100));
}
/** Se true la pagina continua a interrogare lo stato. */
public function getInCorsoProperty(): bool
{
return $this->operationId !== null
&& in_array($this->operazione['status'] ?? '', ['queued', 'running'], true);
}
/**
* Ogni quanto reinterrogare lo stato.
* Il posto e' uno solo: la zona pericolosa deve sapere che e' occupato,
* altrimenti continuerebbe a offrire il proprio pulsante durante un backup o
* un ripristino.
*
* Mentre un job lavora servono aggiornamenti fitti, la barra deve muoversi.
* In attesa-download invece si aspetta una persona, che puo' metterci
* minuti: continuare a bussare ogni 750ms vorrebbe dire migliaia di
* richieste inutili se qualcuno lascia la scheda aperta e se ne va.
* L'evento e' indirizzato, non gridato: senza ->to() tornerebbe anche a
* questo componente, che si riaggancerebbe a un'operazione appena
* agganciata — una richiesta in piu' a ogni avvio, per niente.
*/
public function getIntervalloPollProperty(): string
private function avvisaZonaPericolosa(): void
{
return ($this->operazione['fase'] ?? null) === BackupOperationState::FASE_ATTESA_DOWNLOAD
? '5s'
: '750ms';
$this->dispatch('operazione-avviata')->to(ZonaPericolosa::class);
}
/**
* L'azzeramento avviato dalla zona pericolosa non passa da qui, ma occupa lo
* stesso posto: senza riagganciarsi, questa sezione resterebbe convinta che
* sia libero e offrirebbe il pulsante "Crea backup" durante una
* cancellazione.
*/
#[\Livewire\Attributes\On('operazione-avviata')]
public function agganciaAltrove(): void
{
$this->agganciaOperazioneCorrente();
}
/** Vero quando l'archivio di questa operazione e' scaricabile. */
public function getArchivioProntoProperty(): bool
{
return ($this->operazione['result']['file'] ?? null) !== null;
}
public function render()

View File

@@ -33,7 +33,15 @@ class XmlExchange extends Component
public array $pdfImportIssues = [];
public ?string $currentPdfImportId = null;
public string $pdfImportStatus = 'idle';
public string $pdfImportLogText = '';
/**
* Quante righe di log dell'import sono gia' state spedite alla console.
*
* La coda dell'import non ha tetto (un import di duemila PDF fa duemila
* righe): rispedirla intera a ogni poll significherebbe trascinarsi dietro
* l'intera cronologia dentro ogni risposta, una volta al secondo.
*/
public int $logPdfSpediti = 0;
public function mount(): void
{
@@ -95,7 +103,57 @@ class XmlExchange extends Component
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->pdfImportLogText = implode(PHP_EOL, $this->pdfImportLogs);
$this->spedisciImportAllaConsole();
}
/**
* Manda alla console unica le righe nuove dell'import PDF.
*
* La console e' un componente Alpine fuori dal ciclo di Livewire: i suoi
* contenuti non possono essere ri-renderizzati dal server senza cancellare
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState) e
* non partecipa al posto unico: puo' girare mentre un backup lavora.
*/
protected function spedisciImportAllaConsole(): void
{
// Coda accorciata: e' un import nuovo, si riparte da capo.
if (count($this->pdfImportLogs) < $this->logPdfSpediti) {
$this->logPdfSpediti = 0;
}
$nuove = array_slice($this->pdfImportLogs, $this->logPdfSpediti);
$this->logPdfSpediti = count($this->pdfImportLogs);
$totale = (int) ($this->pdfImportStats['total'] ?? 0);
$fatti = (int) ($this->pdfImportStats['processed'] ?? 0);
$this->dispatch(
'console-operazione',
sorgente: 'import-pdf',
righe: array_values($nuove),
etichetta: 'Import PDF',
fase: $totale > 0 ? $fatti . ' / ' . $totale . ' file' : '',
percento: $this->percentualeImportPdf($fatti, $totale),
stato: $this->pdfImportStatus,
);
}
private function percentualeImportPdf(int $fatti, int $totale): int
{
if ($this->pdfImportStatus === 'completed') {
return 100;
}
return $totale > 0 ? (int) min(100, round($fatti / $totale * 100)) : 0;
}
/** Vero mentre l'import PDF e' in coda o in esecuzione. */
public function getImportPdfInCorsoProperty(): bool
{
return in_array($this->pdfImportStatus, ['queued', 'running'], true);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
@@ -108,9 +166,13 @@ class XmlExchange extends Component
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
// Import nuovo: il conto delle righe gia' raccontate riparte, altrimenti
// le prime righe di questo import verrebbero scambiate per quelle del
// precedente e non comparirebbero.
$this->logPdfSpediti = 0;
$this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
session()->flash('success', 'Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
}
public function downloadImportLogPdf()

View File

@@ -0,0 +1,251 @@
<?php
namespace App\Livewire\Settings;
use App\Jobs\CreateBackupArchive;
use App\Jobs\ResetApplication;
use App\Livewire\Concerns\SegueOperazioneBackup;
use App\Services\BackupOperationState;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
/**
* Azzeramento dell'applicazione: l'unica operazione irreversibile della pagina,
* e per questo l'ultima zona, dopo tutte le altre.
*
* E' un componente separato da BackupManager per una ragione di sola
* disposizione: la zona pericolosa deve stare in fondo alla pagina, mentre le
* sezioni di backup e ripristino stanno in cima. Rendendola dallo stesso
* componente finiva a meta' pagina, in mezzo alle sezioni di import, che e'
* l'ultimo posto in cui si vuole trovare un pulsante che cancella tutto.
*
* Lo stato dell'operazione resta condiviso con BackupManager attraverso
* BackupOperationState (il trait), quindi i due non hanno bisogno di parlarsi per
* restare coerenti: il posto unico e' uno solo per entrambi.
*
* L'azzeramento ha due tempi, separati dal download dell'archivio:
*
* 1. avviaAzzeramento() crea il backup e si ferma in attesa-download
* 2. confermaAzzeramento() cancella, solo su conferma umana e con la password
*
* Il primo tentativo faceva partire la cancellazione dalla coda dello
* streamDownload, "dopo l'ultimo byte": non funziona. Con fastcgi_buffering
* attivo (il default) nginx accetta l'intera risposta dal processo PHP, che
* prosegue anche se il browser ha chiuso la connessione al primo byte, quindi un
* download annullato azzerava lo stesso. Il server puo' verificare di aver
* *spedito* l'archivio, non che sia arrivato: quella verifica e' dell'operatore,
* e per questo la conferma e' umana.
*/
class ZonaPericolosa extends Component
{
use SegueOperazioneBackup;
/** Frase da digitare per sbloccare l'azzeramento. */
public const CONFERMA_AZZERAMENTO = 'AZZERA TUTTO';
/** Password sbagliate ammesse prima di bloccare l'azzeramento, e per quanto. */
private const TENTATIVI_PASSWORD = 5;
private const FINESTRA_TENTATIVI = 300;
public string $confermaReset = '';
/**
* Esiste solo per dare un nome agli errori del campo password: Livewire
* scarta dal messaggio di risposta ogni errore la cui chiave non
* corrisponda a una proprieta' del componente (SupportValidation::dehydrate),
* quindi senza questa riga "Password non corretta" non arriverebbe mai a
* schermo e il pulsante sembrerebbe non fare nulla.
*
* NON collegarla al campo con wire:model: la password viaggia come
* parametro di confermaAzzeramento() proprio per non finire nello
* snapshot, che Livewire rimanda al browser a ogni risposta. Qui resta
* sempre stringa vuota.
*/
public string $passwordConferma = '';
public function mount(): void
{
$this->authorize('settings.manage');
$this->agganciaOperazioneCorrente();
}
/**
* Solo l'azzeramento: backup e ripristino li racconta BackupManager. Vedi
* SegueOperazioneBackup::tipiSeguiti().
*/
protected function tipiSeguiti(): array
{
return [BackupOperationState::TIPO_RESET];
}
/**
* Primo tempo: si costruisce il backup e lo si scarica. La cancellazione non
* parte da qui.
*/
public function avviaAzzeramento(): void
{
$this->authorize('settings.manage');
$this->validate(
['confermaReset' => 'required|in:' . self::CONFERMA_AZZERAMENTO],
['confermaReset.in' => 'Scrivi esattamente "' . self::CONFERMA_AZZERAMENTO . '" per confermare.',
'confermaReset.required' => 'Scrivi "' . self::CONFERMA_AZZERAMENTO . '" per confermare.']
);
if (! $this->postoLibero()) {
return;
}
$this->confermaReset = '';
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
CreateBackupArchive::dispatch($this->operationId, true);
$this->aggiorna();
// La sezione di backup deve sapere che il posto e' occupato, altrimenti
// continuerebbe a offrire il proprio pulsante durante l'azzeramento.
// Indirizzato e non gridato: senza ->to() l'evento tornerebbe anche qui.
$this->dispatch('operazione-avviata')->to(BackupManager::class);
}
/**
* Secondo tempo: parte solo su conferma esplicita, dopo che l'archivio e'
* stato servito.
*/
public function confermaAzzeramento(string $password = ''): void
{
$this->authorize('settings.manage');
$this->aggiorna();
if (! $this->attesaDownload) {
$this->addError('confermaReset', 'Nessun azzeramento in attesa di conferma.');
return;
}
if (($this->operazione['result']['servito_at'] ?? null) === null) {
$this->addError('confermaReset', 'Scarica prima il backup: senza, l\'azzeramento non parte.');
return;
}
if (! $this->passwordConfermata($password)) {
return;
}
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
$state['fase'] = 'Azzeramento in avvio';
$state['processed'] = 0;
$state['total'] = 0;
return $state;
});
ResetApplication::dispatch($this->operationId, (int) auth()->id());
$this->aggiorna();
}
/**
* Verifica la password dell'amministratore prima della cancellazione.
*
* A questo punto la sessione e' gia' autenticata, quindi la password non
* serve a stabilire *chi* sia: serve a stabilire che davanti allo schermo
* ci sia ancora quella persona. E' l'ultimo passo prima di un'operazione
* irreversibile, e una scheda lasciata aperta su una postazione condivisa e'
* lo scenario esatto contro cui protegge.
*/
private function passwordConfermata(string $password): bool
{
$utente = auth()->user();
$chiave = 'azzeramento-password:' . $utente->id;
// Senza un limite, la richiesta di password su una sessione dirottata
// sarebbe solo un rallentamento: si proverebbero le password comuni a
// raffica. Il blocco e' sull'azione, non sull'account, quindi nessuno
// resta chiuso fuori dall'applicazione.
if (RateLimiter::tooManyAttempts($chiave, self::TENTATIVI_PASSWORD)) {
$this->addError('passwordConferma', sprintf(
'Troppi tentativi. Riprova fra %d secondi.',
RateLimiter::availableIn($chiave)
));
return false;
}
if ($password === '') {
$this->addError('passwordConferma', 'Inserisci la tua password per confermare.');
return false;
}
if (! Hash::check($password, $utente->getAuthPassword())) {
RateLimiter::hit($chiave, self::FINESTRA_TENTATIVI);
$this->addError('passwordConferma', 'Password non corretta.');
return false;
}
RateLimiter::clear($chiave);
return true;
}
/**
* Annulla un azzeramento fermo in attesa di conferma. Il backup resta
* scaricabile: e' comunque un backup valido.
*/
public function annullaAzzeramento(): void
{
$this->authorize('settings.manage');
if (! $this->attesaDownload) {
return;
}
app(BackupOperationState::class)->markCompleted($this->operationId, [
'messaggio' => 'Azzeramento annullato. Il backup appena creato resta scaricabile.',
]);
// Da qui in poi e' un backup come un altro: la sezione di backup mostra il
// pulsante di download, non piu' la conferma di cancellazione.
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
$state['tipo'] = BackupOperationState::TIPO_BACKUP;
return $state;
});
$this->aggiorna();
$this->dispatch('operazione-avviata')->to(BackupManager::class);
}
/**
* Un azzeramento avviato altrove non esiste, ma il posto puo' essere occupato
* da un backup o da un ripristino: la zona deve saperlo per non offrire un
* pulsante che verrebbe rifiutato.
*/
#[\Livewire\Attributes\On('operazione-avviata')]
public function agganciaAltrove(): void
{
$this->agganciaOperazioneCorrente();
}
/**
* Vero quando questa zona sta seguendo un azzeramento (non un backup o un
* ripristino avviati altrove).
*/
public function getAzzeramentoInCorsoProperty(): bool
{
return $this->operationId !== null
&& ($this->operazione['tipo'] ?? null) === BackupOperationState::TIPO_RESET;
}
public function render()
{
return view('livewire.settings.zona-pericolosa');
}
}

View File

@@ -121,10 +121,19 @@ class BackupArchive
//
// Il valore di ritorno si controlla: se il file e' sparito
// fra allFiles() e qui — cancellato da un'altra richiesta,
// o illeggibile per i permessi — addFile() torna false
// senza sollevare, e l'archivio finirebbe con un buco di
// cui nessuno si accorge fino al giorno del ripristino.
if (! $zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
// o illeggibile per i permessi — addFile() torna false, e
// l'archivio finirebbe con un buco di cui nessuno si
// accorge fino al giorno del ripristino.
//
// La @ non e' pigrizia. Prima di restituire false,
// addFile() emette un warning di PHP ("No such file or
// directory") che il gestore di Laravel trasforma in
// ErrorException: senza sopprimerlo, il messaggio qui sotto
// — quello che dice *quale* file e *perche'* — non verrebbe
// raggiunto mai, e l'amministratore leggerebbe la stringa
// grezza di PHP. Stesso problema, e stessa scelta, del
// close() avvolto in chiudi().
if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
throw new RuntimeException(
'Impossibile aggiungere "' . $relativePath . '" al backup: '
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'

View File

@@ -24,6 +24,32 @@ return [
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
/*
* Esiste per la suite di test, che gira su ':memory:' (impostato in
* phpunit.xml): ogni classe ricostruisce lo schema da zero senza
* bisogno di un MariaDB in piedi ne' di toccare il volume db_data.
*
* L'applicazione NON gira mai su questo driver: DB_CONNECTION resta
* 'mysql' ovunque. Il divario fra i due motori e' quindi da tenere
* presente quando un test riguarda SQL grezzo — in particolare
* l'ordinamento del Registro usa CAST(... AS UNSIGNED), che SQLite non
* conosce: quel percorso va verificato su MariaDB, non qui.
*
* foreign_key_constraints e' acceso di proposito: senza, SQLite
* ignorerebbe le FK e i test passerebbero anche con dati che MariaDB
* rifiuterebbe.
*/
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
],
],
'migrations' => [

View File

@@ -3,23 +3,45 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* La conversione si fa in PHP e non in SQL.
*
* La versione originale usava GREATEST(1, CEIL(h / 720)), che e' sintassi
* MySQL: SQLite non conosce GREATEST e la migrazione moriva con "no such
* function", rendendo impossibile costruire lo schema per la suite di test
* (che gira su SQLite in memoria — vedi phpunit.xml).
*
* Il risultato e' identico a quello dell'espressione SQL, e la migrazione e'
* gia' applicata ovunque, quindi la riscrittura non tocca nessun database
* esistente: vale solo per le installazioni nuove e per i test. `settings`
* contiene una riga sola, quindi il giro in PHP non costa niente.
*/
return new class extends Migration
{
private const ORE_PER_MESE = 720;
public function up(): void
{
DB::table('settings')
->whereNotNull('assignment_link_ttl_hours')
->update([
'assignment_link_ttl_hours' => DB::raw('GREATEST(1, CEIL(assignment_link_ttl_hours / 720))'),
]);
$this->converti(fn (int $ore) => max(1, (int) ceil($ore / self::ORE_PER_MESE)));
}
public function down(): void
{
DB::table('settings')
$this->converti(fn (int $mesi) => $mesi * self::ORE_PER_MESE);
}
private function converti(callable $conversione): void
{
$righe = DB::table('settings')
->whereNotNull('assignment_link_ttl_hours')
->get(['id', 'assignment_link_ttl_hours']);
foreach ($righe as $riga) {
DB::table('settings')
->where('id', $riga->id)
->update([
'assignment_link_ttl_hours' => DB::raw('assignment_link_ttl_hours * 720'),
'assignment_link_ttl_hours' => $conversione((int) $riga->assignment_link_ttl_hours),
]);
}
}
};

View File

@@ -167,6 +167,35 @@ services:
retries: 10
start_period: 10s
# --- Suite di test (solo sviluppo) ----------------------------------------
# Parte solo con --profile test, mai in produzione, e non resta in piedi:
#
# docker compose --profile test run --build --rm test
# docker compose --profile test run --build --rm test --filter BackupArchiveTest
#
# Il --build serve: il sorgente NON e' montato, quindi senza ricostruzione si
# eseguirebbero i test dell'ultima immagine invece di quelli sul disco. E' una
# scelta di velocita', non di purezza: con il sorgente montato ogni bootstrap
# di Laravel legge una decina di file attraverso il bind mount di Docker
# Desktop, e la stessa suite passa da ~15 secondi a ~2 minuti. La
# ricostruzione incrementale (COPY + dump-autoload) costa una ventina di
# secondi e riparte dalla cache.
#
# Non dipende da mariadb ne' da redis: phpunit.xml impone SQLite in memoria e
# cache 'array', cosi' la suite gira anche a stack spento.
test:
build:
context: .
dockerfile: docker/php/Dockerfile
# Senza questo si costruirebbe l'immagine di produzione, che essendo
# installata senza dipendenze di sviluppo non contiene phpunit.
target: test
profiles: ["test"]
# Niente env_file: la configurazione dei test sta tutta in phpunit.xml, e
# un .env di sviluppo qui dentro punterebbe la suite al database vero.
networks:
- internal
# --- Posta di test (solo sviluppo) ----------------------------------------
# Parte solo con --profile dev, mai in produzione.
mailpit:

View File

@@ -19,7 +19,26 @@ RUN composer install \
--ignore-platform-reqs
# =============================================================================
# Stage 2 — Frontend assets (Vite + Tailwind)
# Stage 2 — PHP dependencies WITH dev (solo per la suite di test)
#
# Stesso composer.lock dello stage sopra, senza --no-dev: e' l'unico posto in
# cui phpunit esiste. L'immagine di runtime non lo contiene di proposito, e non
# deve iniziare a contenerlo — questo stage finisce solo nell'immagine `test`.
# =============================================================================
FROM composer:2 AS vendor-dev
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-interaction \
--prefer-dist \
--no-progress \
--no-scripts \
--ignore-platform-reqs
# =============================================================================
# Stage 3 — Frontend assets (Vite + Tailwind)
# =============================================================================
FROM node:20-alpine AS assets
@@ -40,15 +59,19 @@ COPY config ./config
RUN npm run build
# =============================================================================
# Stage 3 — Runtime
# Stage 4 — Base di sistema (PHP + estensioni + nginx/supervisor)
#
# Condivisa da `runtime` e da `test`, cosi' la suite gira sulle stesse
# estensioni e sullo stesso php.ini della produzione: un test che passa qui
# non puo' passare grazie a un ambiente diverso da quello vero.
# =============================================================================
FROM php:8.3-fpm AS runtime
FROM php:8.3-fpm AS base
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container
# would need its own copy of public/ plus the storage volume just to serve
# static files, which is more moving parts than this app warrants.
# poppler-utils provides pdftoppm/pdfinfo used by TerritorioThumbnailService.
# Node is deliberately absent here: assets are built in stage 2.
# Node is deliberately absent here: assets are built in the `assets` stage.
# The -dev packages are kept rather than purged. Their runtime counterparts
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
# purging means pinning package names that break whenever the php base image
@@ -93,6 +116,56 @@ COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
WORKDIR /var/www/html
# =============================================================================
# Stage 5 — Immagine di test
#
# Uguale al runtime tranne per il vendor, che qui include phpunit. Non monta
# nessun volume: la suite gira su SQLite in memoria e cache `array`, quindi non
# ha bisogno ne' di MariaDB ne' di Redis (vedi phpunit.xml).
#
# Si usa dal compose:
# docker compose --profile test run --rm test
# docker compose --profile test run --rm test --filter BackupArchiveTest
# =============================================================================
FROM base AS test
COPY --chown=www-data:www-data . .
COPY --from=vendor-dev --chown=www-data:www-data /app/vendor ./vendor
RUN mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
storage/app/public \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
# --optimize non e' un vezzo: senza classmap ogni risoluzione di classe diventa
# un file_exists sul sorgente, che nel container di test arriva da un bind mount
# (lento su Docker Desktop, lentissimo se il progetto sta in una cartella
# sincronizzata). Misurato: 74s contro 13s sulla stessa suite. Le classi
# aggiunte dopo il build non finiscono nella mappa ma restano raggiungibili dal
# PSR-4, quindi un test nuovo non obbliga a ricostruire l'immagine.
RUN composer dump-autoload --optimize --no-interaction \
&& php artisan package:discover --ansi
# Sostituisce ENTRYPOINT/CMD di php:8.3-fpm: questo container non serve
# richieste, esegue la suite e esce con il codice di phpunit. Gli argomenti
# passati a `docker compose run test` arrivano direttamente a phpunit.
ENTRYPOINT ["php", "vendor/bin/phpunit"]
# =============================================================================
# Stage 6 — Runtime
#
# DEVE RESTARE L'ULTIMO STAGE DEL FILE. `docker build` senza --target costruisce
# l'ultimo, ed e' cosi' che lo invocano sia docker-compose.yml (i tre servizi
# non dichiarano `target`) sia Dokploy. Uno stage aggiunto qui sotto diventa in
# silenzio l'immagine di produzione.
# =============================================================================
FROM base AS runtime
# Everything the app needs is baked into the image. Runtime containers do NOT
# bind-mount the source, so there is no composer/npm work at boot.
COPY --chown=www-data:www-data . .

66
phpunit.xml Normal file
View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Suite di test di TerManager2.
Si esegue nel container dedicato, l'unico posto dove le dipendenze di
sviluppo esistono (l'immagine di runtime le installa senza). Il comando sta
nel README, sezione "Test automatici".
Attenzione: un commento XML non puo' contenere due trattini di fila, quindi
qui dentro le opzioni da riga di comando non si possono scrivere per esteso.
PHPUnit fallisce con "Double hyphen within comment" e non esegue niente.
force="true" su ogni variabile e' deliberato: Laravel carica il .env con
Dotenv "immutable", quindi non sovrascrive quello che c'e' gia'
nell'ambiente, ma il contrario non vale per PHPUnit, che senza force
lascerebbe vincere una variabile ereditata dal container. Con force la
configurazione dei test e' quella scritta qui e nient'altro, anche se
qualcuno lancia la suite in un ambiente gia' popolato.
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing" force="true"/>
<env name="APP_DEBUG" value="true" force="true"/>
<env name="APP_URL" value="http://localhost" force="true"/>
<!--
Chiave fissa e pubblica, valida solo qui. Serve perche' nome e
cognome dei proclamatori hanno il cast "encrypted": senza APP_KEY
nemmeno un test che non parla di cifratura riuscirebbe a scrivere
una riga. Non ha niente a che vedere con la chiave di produzione,
che vive nell'ambiente e nel volume storage.
-->
<env name="APP_KEY" value="base64:JPM8qoMC8lWnPZ6UUJbVZc3EQQCaZl46ZKfRJJ/Lr6w=" force="true"/>
<env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<!-- Niente Redis, niente database di supporto: la suite gira da sola. -->
<env name="CACHE_STORE" value="array" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<!-- bcrypt a costo minimo: gli hash nei test non proteggono nulla. -->
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
</php>
</phpunit>

View File

@@ -1,6 +1,6 @@
{
"resources/css/app.css": {
"file": "assets/app-DL92hBto.css",
"file": "assets/app-CLtJVVuT.css",
"src": "resources/css/app.css",
"isEntry": true
},

View File

@@ -2,6 +2,12 @@
/* ─── TerManager2 Custom UI ─── */
/* Alpine nasconde con x-cloak solo se il foglio di stile lo dice: la regola non
arriva col pacchetto. Senza, gli elementi marcati x-cloak (le barre di
avanzamento, i messaggi d'errore) lampeggiano a ogni caricamento prima che
Alpine parta. */
[x-cloak] { display: none !important; }
/* Card hover lift effect */
.card-hover {
transition: transform 0.15s ease, box-shadow 0.15s ease;
@@ -23,39 +29,6 @@
background-color: #f9fafb;
}
/* Action buttons */
.btn-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
transition: all 0.15s ease;
border: none;
cursor: pointer;
text-decoration: none;
}
.btn-action-green { background: #dcfce7; color: #166534; }
.btn-action-green:hover { background: #bbf7d0; }
.btn-action-red { background: #fee2e2; color: #991b1b; }
.btn-action-red:hover { background: #fecaca; }
.btn-action-blue { background: #dbeafe; color: #1e40af; }
.btn-action-blue:hover { background: #bfdbfe; }
.btn-action-amber { background: #fef3c7; color: #92400e; }
.btn-action-amber:hover { background: #fde68a; }
.btn-action-gray { background: #f3f4f6; color: #374151; }
.btn-action-gray:hover { background: #e5e7eb; }
.btn-action-indigo { background: #e0e7ff; color: #4338ca; }
.btn-action-indigo:hover { background: #c7d2fe; }
/* Primary action buttons (filled) */
.btn-primary-green { background: #16a34a; color: #fff; }
.btn-primary-green:hover { background: #15803d; }
.btn-primary-red { background: #dc2626; color: #fff; }
.btn-primary-red:hover { background: #b91c1c; }
/* Gradient header */
.header-gradient {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 50%, #818cf8 100%);
@@ -102,6 +75,304 @@
50% { opacity: 0.7; }
}
/* ─── Pulsanti ─────────────────────────────────────────────────────────────
Un solo sistema per tutta l'applicazione, definito qui e in nessun altro
posto. Si usa dal componente <x-btn>, mai scrivendo queste classi a mano.
Da dove veniamo: quattro vocabolari in circolazione contemporaneamente —
style= inline con i colori scritti a mano (piu' onmouseover per l'hover),
catene di utility Tailwind ripetute a memoria, .btn-action* in sei tinte, e
.btn-primary-* in due. La stessa azione cambiava aspetto da una pagina
all'altra, un "Rientra" era rosso quanto un "Elimina", e nessuno di questi
pulsanti aveva uno stato di focus: chi naviga da tastiera non vedeva dove si
trovava.
Due assi, ortogonali:
variante primary l'azione principale. Una sola per schermata o zona.
secondary tutto il resto (dettaglio, modifica, scarica, filtra)
danger SOLO dove si cancellano o si sovrascrivono dati
ghost annulla, chiudi, pulisci
tono solido il pieno. Per le azioni isolate.
tenue la tinta chiara. Per le azioni dentro le righe di un
elenco, dove cinquanta pulsanti pieni urlerebbero.
La dimensione `sm` e' quella delle righe di elenco; `icona` toglie il testo e
lascia il quadrato.
Le regole stanno fuori da @layer, quindi vincono sulle utility di Tailwind:
un pulsante non puo' essere ritoccato per meta' con le classi. */
.tm-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 40px;
padding: 10px 16px;
border-radius: 10px;
border: 1px solid transparent;
font-size: 14px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.tm-btn svg {
width: 16px;
height: 16px;
flex: none;
}
/* Un anello unico per ogni variante: il colore del focus non deve dipendere
da quello del pulsante, altrimenti su rosso o su bianco cambia visibilita'
proprio dove serve di piu'. Il doppio contorno (chiaro + scuro) lo tiene
leggibile sia sulle card bianche sia sui riquadri colorati. */
.tm-btn:focus-visible,
.tm-console-btn:focus-visible,
.tm-nav-zona:focus-visible {
outline: 3px solid #1d4ed8;
outline-offset: 2px;
box-shadow: 0 0 0 5px #fff;
}
.tm-btn:disabled,
.tm-btn[aria-disabled="true"] {
opacity: .45;
cursor: not-allowed;
}
.tm-btn-primary { background: #4f46e5; border-color: #3730a3; color: #fff; }
.tm-btn-primary:hover:not(:disabled) { background: #4338ca; }
.tm-btn-danger { background: #dc2626; border-color: #991b1b; color: #fff; font-weight: 600; }
.tm-btn-danger:hover:not(:disabled) { background: #b91c1c; }
.tm-btn-secondary { background: #fff; border-color: #d1d5db; color: #374151; }
.tm-btn-secondary:hover:not(:disabled) { background: #f9fafb; border-color: #9ca3af; }
.tm-btn-ghost {
background: transparent;
border-color: transparent;
color: #4b5563;
min-height: 32px;
padding: 4px 8px;
font-size: 13px;
text-decoration: underline;
}
.tm-btn-ghost:hover:not(:disabled) { color: #111827; background: #f3f4f6; }
/* Tono tenue: stessa gerarchia, voce piu' bassa. Nelle righe di un elenco i
pulsanti sono tanti quanti i record, e il pieno li trasforma in rumore —
soprattutto il rosso, che smette di significare "attenzione" quando compare
cinquanta volte di fila. */
.tm-btn-tenue { border-color: transparent; font-weight: 600; }
.tm-btn-tenue.tm-btn-primary { background: #e0e7ff; color: #4338ca; }
.tm-btn-tenue.tm-btn-primary:hover:not(:disabled) { background: #c7d2fe; }
.tm-btn-tenue.tm-btn-danger { background: #fee2e2; color: #991b1b; }
.tm-btn-tenue.tm-btn-danger:hover:not(:disabled) { background: #fecaca; }
.tm-btn-tenue.tm-btn-secondary { background: #f3f4f6; color: #374151; }
.tm-btn-tenue.tm-btn-secondary:hover:not(:disabled) { background: #e5e7eb; }
/* Tono velato: per i comandi che stanno SOPRA una superficie colorata — oggi
solo la testata, che ha il suo gradiente. Un pulsante bianco o indaco lì sopra
sarebbe una toppa; qui il colore lo dà lo sfondo e il pulsante si limita a
velarlo. Il focus diventa bianco, perche' l'anello blu su indaco non si
distingue. */
.tm-btn-velato {
background: rgba(255, 255, 255, .12);
border-color: transparent;
color: rgba(255, 255, 255, .92);
}
.tm-btn-velato:hover:not(:disabled) { background: rgba(255, 255, 255, .22); color: #fff; }
.tm-btn-velato:focus-visible {
outline: 3px solid #fff;
outline-offset: 2px;
box-shadow: 0 0 0 5px rgba(0, 0, 0, .35);
}
.tm-btn-sm { min-height: 32px; padding: 6px 12px; font-size: 13px; gap: 4px; }
.tm-btn-sm svg { width: 14px; height: 14px; }
/* Solo icona: quadrato, senza testo. Serve sempre un aria-label, altrimenti chi
usa un lettore di schermo trova un pulsante senza nome. */
.tm-btn-icona { padding-left: 8px; padding-right: 8px; }
.tm-btn-sm.tm-btn-icona { padding-left: 7px; padding-right: 7px; }
/* ─── Zone della pagina ────────────────────────────────────────────────────
Ogni sezione e' un bersaglio di navigazione: premendo un pulsante o una voce
dell'indice la pagina ci si porta sopra e la zona lampeggia, cosi' e' chiaro
*dove* e' finita l'attenzione invece di dover indovinare cosa e' cambiato.
scroll-margin-top tiene la testata della zona sotto la barra dell'indice,
che e' appiccicata in alto: senza, l'ancora finirebbe coperta. */
.tm-zona {
scroll-margin-top: 84px;
}
/* Lampeggio d'arrivo. Non usa `outline` perche' si somma male ai bordi delle
card; box-shadow non occupa spazio e non fa saltare il layout. */
.tm-zona-evidenziata {
animation: tmEvidenzia 1.6s ease-out;
}
@keyframes tmEvidenzia {
0% { box-shadow: 0 0 0 0 rgba(29, 78, 216, .6); }
12% { box-shadow: 0 0 0 7px rgba(29, 78, 216, .35); }
100% { box-shadow: 0 0 0 0 rgba(29, 78, 216, 0); }
}
/* Zona con un'operazione in corso: resta marcata finche' dura, cosi' anche
tornando sulla pagina dopo un ricaricamento si vede subito chi sta lavorando. */
.tm-zona-attiva {
box-shadow: 0 0 0 2px #4f46e5;
}
/* Chi ha chiesto meno animazioni riceve comunque il segnale, fermo. */
@media (prefers-reduced-motion: reduce) {
.tm-zona-evidenziata { animation: none; box-shadow: 0 0 0 3px rgba(29, 78, 216, .5); }
.tm-btn { transition: none; }
}
/* Indice delle zone, appiccicato in alto. */
.tm-nav-zone {
position: sticky;
top: 0;
z-index: 20;
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 12px;
margin: -4px 0 0;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: rgba(255, 255, 255, .92);
backdrop-filter: blur(6px);
}
.tm-nav-zona {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #fff;
color: #374151;
font-size: 12px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
}
.tm-nav-zona:hover { background: #eef2ff; border-color: #c7d2fe; color: #3730a3; }
.tm-nav-zona-pericolo { color: #991b1b; border-color: #fecaca; }
.tm-nav-zona-pericolo:hover { background: #fef2f2; border-color: #fca5a5; color: #7f1d1d; }
/* ─── Console delle operazioni ─────────────────────────────────────────────
Una sola finestra per tutte le operazioni lunghe, agganciata in fondo alla
finestra del browser: quello che sta succedendo non deve uscire dallo schermo
mentre si scorre la pagina. Chiusa e' una striscia alta 44px.
Il posto in cui vive e' il corpo della pagina, ma la posizione e' fissa,
quindi in fondo alla pagina c'e' uno spaziatore della stessa altezza: senza,
coprirebbe l'ultima zona — che e' quella pericolosa. */
.tm-console {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 45;
display: flex;
flex-direction: column;
background: #0b1020;
border-top: 1px solid #1f2937;
box-shadow: 0 -8px 24px -12px rgba(0, 0, 0, .5);
color: #e5e7eb;
}
.tm-console-testata {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
min-height: 44px;
}
.tm-console-titolo {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: 0;
padding: 0;
color: #e5e7eb;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.tm-console-btn {
background: transparent;
border: 1px solid #374151;
border-radius: 8px;
padding: 4px 10px;
color: #d1d5db;
font-size: 12px;
cursor: pointer;
}
.tm-console-btn:hover { background: #1f2937; color: #fff; }
/* Il corpo scorre; l'altezza e' in vh perche' su un portatile basso una
finestra fissa in pixel si mangerebbe mezza pagina. */
.tm-console-corpo {
max-height: min(38vh, 340px);
overflow-y: auto;
padding: 4px 12px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.55;
}
.tm-console-riga { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; }
.tm-console-ora { color: #6b7280; flex: none; }
.tm-console-sorgente { color: #818cf8; flex: none; }
.tm-console-riga-errore .tm-console-testo { color: #fca5a5; font-weight: 600; }
.tm-console-riga-ok .tm-console-testo { color: #6ee7b7; }
.tm-console-riga-attesa .tm-console-testo { color: #fcd34d; }
/* Barra di avanzamento nella testata: sottile, sempre allo stesso posto. */
.tm-console-barra {
flex: 1;
height: 6px;
min-width: 60px;
border-radius: 999px;
background: #1f2937;
overflow: hidden;
}
.tm-console-barra > div {
height: 100%;
border-radius: 999px;
background: #6366f1;
transition: width .3s ease;
}
.tm-console-barra-ok > div { background: #10b981; }
.tm-console-barra-errore > div { background: #ef4444; }
/* "Sei tornato indietro a leggere": lo scorrimento automatico si sospende e
compare il modo per riagganciarsi. */
.tm-console-torna {
align-self: center;
margin: 0 12px 8px;
background: #312e81;
border: 1px solid #4f46e5;
border-radius: 999px;
padding: 3px 12px;
color: #e0e7ff;
font-size: 11px;
cursor: pointer;
}
@media (max-width: 640px) {
.tm-console-corpo { max-height: 45vh; font-size: 11px; }
.tm-console-sorgente { display: none; }
}
/* ─── Responsive list tables ───────────────────────────────────────────────
Under 640px (a phone held in portrait; rotate to landscape and the width
grows past the breakpoint, restoring the normal table) every table with the

View File

@@ -61,22 +61,18 @@
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
</div>
<button type="submit"
class="w-full inline-flex items-center justify-center px-4 py-2.5 text-sm font-medium text-white rounded-lg transition"
style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
Salva la nuova password
</button>
<x-btn variante="primary" type="submit" class="w-full">Salva la nuova password</x-btn>
</form>
<div class="flex justify-between text-xs text-gray-500 pt-1 border-t border-gray-100">
<div class="flex items-center justify-between gap-2 text-xs text-gray-500 pt-1 border-t border-gray-100">
@if($forced)
<span>Non sei tu? Esci e accedi con un altro account.</span>
@else
<a href="{{ route('dashboard') }}" class="hover:text-indigo-600">Torna alla home</a>
<x-btn variante="ghost" dimensione="sm" :href="route('dashboard')">Torna alla home</x-btn>
@endif
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="hover:text-indigo-600">Esci</button>
<x-btn variante="ghost" dimensione="sm" type="submit">Esci</x-btn>
</form>
</div>
</div>

View File

@@ -0,0 +1,61 @@
{{--
Pulsante unico dell'applicazione.
Esiste per un motivo preciso: prima ogni pagina si scriveva i propri
pulsanti, e in quattro modi diversi — style= inline con i colori a mano (piu'
onmouseover per l'hover), catene di utility Tailwind ripetute a memoria,
.btn-action* in sei tinte, .btn-primary-* in due. La stessa azione cambiava
aspetto da una pagina all'altra, e nessuno di quei pulsanti aveva uno stato
di focus. Le classi stanno in resources/css/app.css.
variante:
primary l'azione principale. Una sola per schermata o per zona.
secondary tutto il resto: dettaglio, modifica, scarica, filtra.
danger SOLO dove si cancellano o si sovrascrivono dati.
ghost annulla, chiudi, pulisci.
tono:
solido il pieno (default). Per le azioni isolate.
tenue la tinta chiara. Per le azioni dentro le righe di un elenco,
dove cinquanta pulsanti pieni diventano rumore.
velato per i comandi sopra una superficie colorata (la testata). Il
colore lo dà lo sfondo; qui `variante` non ha effetto.
dimensione: sm per le righe di elenco.
icona: true per i pulsanti di sola icona. Richiede un aria-label,
altrimenti chi usa un lettore di schermo trova un pulsante
senza nome — title non basta.
Con href rende un <a>, altrimenti un <button type=button> — il tipo si puo'
sovrascrivere (type=submit) come qualsiasi altro attributo.
ATTENZIONE, per disabilitarlo: si scrive :disabled="$condizione", NON
@disabled($condizione). Dentro un tag di componente le direttive Blade non
vengono compilate: il tag <x-btn> resta letterale nel PHP generato mentre il
</x-btn> viene invece chiuso, e la vista muore con "syntax error, unexpected
token endif". Il binding funziona come ci si aspetta perche' la borsa degli
attributi salta i valori false e rende disabled="disabled" per i true.
--}}
@props([
'variante' => 'secondary',
'tono' => 'solido',
'dimensione' => null,
'icona' => false,
'href' => null,
])
@php
$classi = implode(' ', array_filter([
'tm-btn',
'tm-btn-' . $variante,
in_array($tono, ['tenue', 'velato'], true) ? 'tm-btn-' . $tono : null,
$dimensione === 'sm' ? 'tm-btn-sm' : null,
$icona ? 'tm-btn-icona' : null,
]));
@endphp
@if ($href)
<a href="{{ $href }}" {{ $attributes->class($classi) }}>{{ $slot }}</a>
@else
<button type="{{ $attributes->get('type', 'button') }}" {{ $attributes->except('type')->class($classi) }}>{{ $slot }}</button>
@endif

View File

@@ -15,12 +15,13 @@
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-3">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2 rounded-lg" style="background:rgba(255,255,255,0.1)"
x-data x-on:click="$dispatch('toggle-sidebar')">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<x-btn tono="velato" icona class="lg:hidden"
x-data x-on:click="$dispatch('toggle-sidebar')"
aria-label="Apri il menu di navigazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:22px;height:22px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</x-btn>
@php $settings = \App\Models\Setting::first(); @endphp
@if($settings && $settings->logo_path)
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
@@ -32,21 +33,20 @@
</span>
</div>
<div class="flex items-center gap-4">
<a href="{{ route('profile') }}" title="Il mio profilo"
class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg transition {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}"
style="background:rgba(255,255,255,0.1)" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'">
<svg class="h-5 w-5 flex-none" style="color:rgba(255,255,255,0.7)" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<x-btn tono="velato" dimensione="sm" :href="route('profile')" title="Il mio profilo"
class="hidden sm:flex {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span class="flex flex-col leading-tight text-left">
<span class="text-sm font-medium" style="color:rgba(255,255,255,0.95)">{{ auth()->user()->name }}</span>
<span class="text-xs" style="color:rgba(255,255,255,0.6)">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
<span class="text-sm font-medium">{{ auth()->user()->name }}</span>
<span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
</span>
</a>
</x-btn>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition" style="color:rgba(255,255,255,0.8);background:rgba(255,255,255,0.1)" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
<x-btn tono="velato" dimensione="sm" type="submit">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
Esci
</button>
</x-btn>
</form>
</div>
</div>
@@ -192,9 +192,10 @@
class="mb-4 rounded-lg p-4 text-sm border flex items-center gap-3" style="background:#fef2f2;border-color:#fecaca;color:#991b1b">
<svg class="h-5 w-5 flex-shrink-0" style="color:#ef4444" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ session('error') }}
<button @click="show = false" class="ml-auto" style="color:#991b1b;opacity:0.5" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
<x-btn variante="ghost" dimensione="sm" icona class="ml-auto"
@click="show = false" aria-label="Chiudi il messaggio">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</x-btn>
</div>
@endif

View File

@@ -11,15 +11,15 @@
<div x-data="{ formOpen: true }">
{{-- Toggle form --}}
<button type="button"
<x-btn variante="primary" class="mb-4"
x-on:click="formOpen = !formOpen"
class="mb-4 inline-flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-800 transition">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
x-bind:aria-expanded="formOpen ? 'true' : 'false'">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path x-show="formOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
<path x-show="!formOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
<span x-text="formOpen ? 'Nascondi form' : 'Nuova assegnazione'"></span>
</button>
</x-btn>
{{-- Form --}}
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
@@ -82,8 +82,8 @@
</div>
<div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Assegna</button>
<button type="button" x-on:click="formOpen = false" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</button>
<x-btn variante="primary" type="submit">Assegna</x-btn>
<x-btn variante="secondary" x-on:click="formOpen = false">Annulla</x-btn>
</div>
</form>
</div>
@@ -151,27 +151,29 @@
@elseif($pdfUrl)
<div x-data="{ copied: false }" class="mt-3 flex flex-wrap items-center gap-2">
<code class="flex-1 min-w-0 text-xs bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-gray-600 break-all select-all cursor-text" x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
<button type="button"
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="flex-none"
x-on:click="navigator.clipboard.writeText($el.closest('div').querySelector('code').textContent).then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
class="flex-none btn-action btn-action-indigo" title="Copia link" style="padding:6px 10px">
<svg x-show="!copied" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
<svg x-show="copied" x-cloak class="h-4 w-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</button>
title="Copia link" aria-label="Copia il link del PDF">
<svg x-show="!copied" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
<svg x-show="copied" x-cloak class="text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</x-btn>
{{-- Flag Link Inviato --}}
<button type="button"
{{-- Interruttore di stato, non un comando: lo stato "inviato"
si legge dalla variante (primary quando è acceso) e
aria-pressed lo dice anche a chi non lo vede. --}}
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" class="flex-none"
wire:click="toggleLinkSent({{ $a->id }})"
title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}"
class="flex-none inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold border transition
{{ $a->link_sent ? 'bg-green-50 border-green-200 text-green-700 hover:bg-green-100' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100' }}">
aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}"
title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}">
@if($a->link_sent)
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Link inviato
@else
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
Non inviato
@endif
</button>
</x-btn>
<span x-show="copied" x-cloak class="flex-none text-xs text-green-600 font-medium">Copiato!</span>
</div>

View File

@@ -54,9 +54,9 @@
</div>
@endif
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition">Registra Rientro</button>
<a href="{{ route('territori.show', $assegnazione->territorio) }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
<div class="flex flex-wrap items-center gap-3 pt-4">
<x-btn variante="primary" type="submit">Registra rientro</x-btn>
<x-btn variante="secondary" :href="route('territori.show', $assegnazione->territorio)">Annulla</x-btn>
</div>
</form>
</div>

View File

@@ -27,9 +27,6 @@
<label for="remember" class="ml-2 text-sm text-gray-600">Ricordami</label>
</div>
<button type="submit"
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 transition" style="background:linear-gradient(135deg,#4338ca,#6366f1)" onmouseover="this.style.background='linear-gradient(135deg,#3730a3,#4f46e5)'" onmouseout="this.style.background='linear-gradient(135deg,#4338ca,#6366f1)'">
<span>Accedi</span>
</button>
<x-btn variante="primary" type="submit" class="w-full">Accedi</x-btn>
</form>
</div>

View File

@@ -29,8 +29,8 @@
</div>
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">{{ $btnLabel }}</button>
<a href="{{ route('campagne.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>
<x-btn variante="secondary" :href="route('campagne.index')">Annulla</x-btn>
</div>
</form>
</div>

View File

@@ -2,10 +2,10 @@
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900">Campagne</h1>
@can('settings.manage')
<a href="{{ route('campagne.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova Campagna
</a>
<x-btn variante="primary" :href="route('campagne.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova campagna
</x-btn>
@endcan
</div>
@@ -47,16 +47,23 @@
</td>
<td class="px-4 py-3 text-sm text-right cell-actions">
<div class="flex flex-wrap items-center justify-end gap-1">
<a href="{{ route('campagne.show', $campagna) }}" class="btn-action btn-action-indigo" title="Dettaglio">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('campagne.show', $campagna)"
title="Dettaglio" aria-label="Dettaglio della campagna {{ $campagna->descrizione }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</x-btn>
@can('settings.manage')
<a href="{{ route('campagne.edit', $campagna) }}" class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>
<button wire:click="deleteCampagna({{ $campagna->id }})" wire:confirm="Eliminare la campagna '{{ $campagna->descrizione }}'?" class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('campagne.edit', $campagna)"
title="Modifica" aria-label="Modifica la campagna {{ $campagna->descrizione }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="deleteCampagna({{ $campagna->id }})"
wire:confirm="Eliminare la campagna '{{ $campagna->descrizione }}'?"
title="Elimina" aria-label="Elimina la campagna {{ $campagna->descrizione }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn>
@endcan
</div>
</td>

View File

@@ -8,10 +8,10 @@
<h1 class="text-2xl font-bold text-gray-900">{{ $campagna->descrizione }}</h1>
</div>
@can('settings.manage')
<a href="{{ route('campagne.edit', $campagna) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
<x-btn variante="secondary" :href="route('campagne.edit', $campagna)">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica
</a>
</x-btn>
@endcan
</div>

View File

@@ -91,9 +91,11 @@
<p class="text-xs" style="color:#15803d">Prima i prioritari, poi i territori con più tempo in reparto</p>
</div>
@hasanyrole('amministratore|assistente')
<button wire:click="downloadPdfDaAssegnare" title="Scarica PDF" class="ml-auto btn-action btn-action-green" style="padding:6px 10px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto"
wire:click="downloadPdfDaAssegnare"
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da assegnare">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</x-btn>
@endhasanyrole
</div>
<ul class="divide-y divide-gray-100">
@@ -114,7 +116,7 @@
</p>
</div>
@can('territori.assign')
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="btn-action btn-primary-green">Assegna →</a>
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $t->id])">Assegna →</x-btn>
@endcan
</li>
@empty
@@ -142,9 +144,13 @@
<p class="text-xs" style="color:#b91c1c">Territori assegnati da più tempo</p>
</div>
@hasanyrole('amministratore|assistente')
<button wire:click="downloadPdfDaRientrare" title="Scarica PDF" class="ml-auto btn-action btn-action-red" style="padding:6px 10px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</button>
{{-- Scaricare un elenco non è distruttivo: era rosso solo perché
lo è la scheda che lo contiene. --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto"
wire:click="downloadPdfDaRientrare"
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da rientrare">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</x-btn>
@endhasanyrole
</div>
<ul class="divide-y divide-gray-100">
@@ -160,7 +166,7 @@
</div>
@can('territori.return')
@if($assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red">Rientra →</a>
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id])">Rientra →</x-btn>
@endif
@endcan
</li>

View File

@@ -25,8 +25,8 @@
<td class="px-4 py-3 text-sm text-right cell-actions">
@can('settings.manage')
<div class="flex flex-wrap items-center justify-end gap-1">
<button wire:click="restore({{ $proclamatore->id }})" class="btn-action btn-action-green">Ripristina</button>
<button wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
<x-btn variante="primary" tono="tenue" dimensione="sm" wire:click="restore({{ $proclamatore->id }})">Ripristina</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile.">Elimina</x-btn>
</div>
@endcan
</td>

View File

@@ -27,8 +27,8 @@
</div>
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">{{ $btnLabel }}</button>
<a href="{{ route('proclamatori.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>
<x-btn variante="secondary" :href="route('proclamatori.index')">Annulla</x-btn>
</div>
</form>
</div>

View File

@@ -2,27 +2,31 @@
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900">Proclamatori</h1>
@can('settings.manage')
<a href="{{ route('proclamatori.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo Proclamatore
</a>
{{-- Il cestino sale nella testata, com'è nei Territori: prima era un
link rosso perso in mezzo ai filtri, in un posto diverso rispetto
all'altra lista che fa esattamente la stessa cosa. --}}
<div class="flex flex-wrap items-center gap-2">
<x-btn variante="primary" :href="route('proclamatori.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo proclamatore
</x-btn>
<x-btn variante="secondary" :href="route('proclamatori.cestino')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Cestino
</x-btn>
</div>
@endcan
</div>
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca nome o cognome..." class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<select wire:model.live="filtroStato" class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutti</option>
<option value="attivo">Attivi</option>
<option value="inattivo">Inattivi</option>
</select>
<div class="text-xs text-gray-500 flex items-center">
@can('settings.manage')
<a href="{{ route('proclamatori.cestino') }}" class="text-red-500 hover:text-red-700">🗑 Cestino</a>
@endcan
</div>
</div>
</div>
@@ -58,23 +62,33 @@
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
<td class="px-4 py-3 text-sm text-right cell-actions">
<div class="flex flex-wrap items-center justify-end gap-1">
<a href="{{ route('proclamatori.show', $proclamatore) }}" class="btn-action btn-action-indigo" title="Dettaglio">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('proclamatori.show', $proclamatore)"
title="Dettaglio" aria-label="Dettaglio del proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</x-btn>
@can('settings.manage')
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>
<button wire:click="toggleActive({{ $proclamatore->id }})" class="btn-action btn-action-amber" title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}">
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('proclamatori.edit', $proclamatore)"
title="Modifica" aria-label="Modifica il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="toggleActive({{ $proclamatore->id }})"
title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}"
aria-label="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }} il proclamatore">
@if($proclamatore->attivo)
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
@else
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
@endif
</button>
<button wire:click="deleteProclamatore({{ $proclamatore->id }})" wire:confirm="Spostare il proclamatore nel cestino?" class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="deleteProclamatore({{ $proclamatore->id }})"
wire:confirm="Spostare il proclamatore nel cestino?"
title="Elimina" aria-label="Sposta nel cestino il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn>
@endcan
</div>
</td>

View File

@@ -8,10 +8,10 @@
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
</div>
@can('settings.manage')
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
<x-btn variante="secondary" :href="route('proclamatori.edit', $proclamatore)">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica
</a>
</x-btn>
@endcan
</div>

View File

@@ -48,9 +48,7 @@
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
Salva modifiche
</button>
<x-btn variante="primary" type="submit">Salva modifiche</x-btn>
</div>
</form>
</div>
@@ -84,9 +82,7 @@
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
Aggiorna password
</button>
<x-btn variante="primary" type="submit">Aggiorna password</x-btn>
</div>
</form>
</div>

View File

@@ -3,16 +3,15 @@
<h1 class="text-2xl font-bold text-gray-900">Registro Assegnazioni</h1>
@can('settings.manage')
<div class="flex flex-wrap items-center gap-2">
<button wire:click="openS13"
class="btn-action btn-action-green" style="padding:8px 18px;font-size:14px;border-radius:8px"
title="Scarica il modulo S-13 compilato con le ultime 4 assegnazioni di un anno di servizio">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
Modulo S-13
</button>
<button wire:click="openCreate" class="btn-action btn-action-indigo" style="padding:8px 18px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<x-btn variante="primary" wire:click="openCreate">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova voce
</button>
</x-btn>
<x-btn variante="secondary" wire:click="openS13"
title="Scarica il modulo S-13 compilato con le ultime 4 assegnazioni di un anno di servizio">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
Modulo S-13
</x-btn>
</div>
@endcan
</div>
@@ -110,21 +109,22 @@
<td class="px-3 py-2 whitespace-nowrap cell-actions">
<div class="flex items-center gap-1 flex-wrap">
@if($temporaryPdfUrl)
<a href="{{ $temporaryPdfUrl }}"
target="_blank"
rel="noopener noreferrer"
class="btn-action btn-action-indigo" title="PDF">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</a>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="$temporaryPdfUrl" target="_blank" rel="noopener noreferrer"
title="PDF" aria-label="Apri il PDF dell assegnazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</x-btn>
@endif
<button wire:click="openEdit({{ $a->id }})"
class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</button>
<button wire:click="askDelete({{ $a->id }})"
class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="openEdit({{ $a->id }})"
title="Modifica" aria-label="Modifica l assegnazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="askDelete({{ $a->id }})"
title="Elimina" aria-label="Elimina l assegnazione">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn>
</div>
</td>
@endcan
@@ -162,18 +162,14 @@
</select>
@error('s13Anno') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
<button wire:click="$set('showS13Modal', false)"
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Annulla
</button>
<button wire:click="downloadS13"
wire:loading.attr="disabled"
wire:target="downloadS13"
style="background:#16a34a;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
{{-- Azione principale prima, uscita dopo: stesso ordine dei form
e delle altre modali dell'applicazione. --}}
<div class="flex flex-wrap justify-end gap-2" style="margin-top:20px;">
<x-btn variante="primary" wire:click="downloadS13" wire:loading.attr="disabled" wire:target="downloadS13">
<span wire:loading.remove wire:target="downloadS13">Scarica PDF</span>
<span wire:loading wire:target="downloadS13">Generazione…</span>
</button>
</x-btn>
<x-btn variante="ghost" wire:click="$set('showS13Modal', false)">Annulla</x-btn>
</div>
</div>
</div>
@@ -265,15 +261,9 @@
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px;">
<button wire:click="$set('showModal', false)"
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Annulla
</button>
<button wire:click="save"
style="background:#4f46e5;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
{{ $editingId ? 'Salva modifiche' : 'Crea voce' }}
</button>
<div class="flex flex-wrap justify-end gap-2" style="margin-top:20px;">
<x-btn variante="primary" wire:click="save">{{ $editingId ? 'Salva modifiche' : 'Crea voce' }}</x-btn>
<x-btn variante="ghost" wire:click="$set('showModal', false)">Annulla</x-btn>
</div>
</div>
</div>
@@ -285,15 +275,9 @@
<div style="background:#fff;border-radius:12px;width:100%;max-width:360px;padding:24px;">
<h2 style="font-size:17px;font-weight:700;color:#111827;margin-bottom:10px;">Elimina voce</h2>
<p style="font-size:14px;color:#6b7280;margin-bottom:20px;">Sei sicuro di voler eliminare questa assegnazione? L'operazione non può essere annullata.</p>
<div style="display:flex;justify-content:flex-end;gap:10px;">
<button wire:click="$set('showDeleteConfirm', false)"
style="background:#f3f4f6;color:#374151;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Annulla
</button>
<button wire:click="deleteConfirmed"
style="background:#dc2626;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;">
Elimina
</button>
<div class="flex flex-wrap justify-end gap-2">
<x-btn variante="danger" wire:click="deleteConfirmed">Elimina</x-btn>
<x-btn variante="ghost" wire:click="$set('showDeleteConfirm', false)">Annulla</x-btn>
</div>
</div>
</div>

View File

@@ -1,85 +1,52 @@
{{--
Backup, ripristino e azzeramento, con avanzamento reale.
Zone 1 e 2 della pagina: creazione del pacchetto e ripristino.
La barra legge processed/total dell'operazione in corso, che i job
aggiornano quando qualcosa e' davvero finito sul disco. Il poll resta
attivo solo mentre l'operazione e' in coda o in esecuzione: appena chiude,
si ferma da solo.
Avanzamento e log non stanno piu' qui: vanno alla console unica agganciata in
fondo alla finestra (partials/console-operazioni.blade.php). In questa
sezione restano i comandi e l'esito, cioe' le uniche cose che riguardano
*questa* zona; prima invece la pagina aveva un pannello di avanzamento in
cima, una console a meta' e un "Dettaglio" richiudibile, e la stessa
operazione si raccontava in tre posti diversi.
Il JavaScript del caricamento sta ancora dentro l'attributo x-data, quindi
valgono le regole di sempre: dentro quel blocco NON possono comparire
virgolette doppie, da nessuna parte, neanche nei commenti — un attributo HTML
finisce alla prima, e questa pagina si e' gia' rotta cosi' due volte. Gli
apostrofi italiani stanno nei backtick, dove non richiedono accorgimenti: la
sequenza \' scritta a mano nel sorgente Blade chiude la stringa invece di
proteggere l'apostrofo.
--}}
@php
$op = $operazione;
$tipo = $op['tipo'] ?? null;
$stato = $op['status'] ?? null;
$attesaDownload = ($op['fase'] ?? null) === \App\Services\BackupOperationState::FASE_ATTESA_DOWNLOAD;
$tipo = $op['tipo'] ?? null;
$etichettaTipo = match ($tipo) {
'backup' => 'Backup in corso',
'restore' => 'Ripristino in corso',
'reset' => 'Azzeramento in corso',
default => 'Operazione in corso',
};
// "Il posto e' occupato da qualcun altro": i pulsanti si spengono, ma il
// messaggio deve dire quale operazione sta lavorando, non un generico
// divieto.
$occupato = $this->inCorso;
@endphp
{{--
ATTENZIONE, prima di toccare l'x-data qui sotto.
Un attributo HTML finisce alla PRIMA virgoletta doppia, comprese quelle
dentro un commento JavaScript. Un banale // un generico "errore" ha gia'
chiuso l'attributo a meta' e riversato tutto il codice rimanente a video
come testo, due volte. Dentro questo blocco NON possono comparire
virgolette doppie, da nessuna parte, neanche nei commenti.
Per lo stesso motivo le stringhe usano apici o backtick, e gli apostrofi
italiani stanno nei backtick, dove non richiedono alcun accorgimento: la
sequenza \' scritta a mano nel sorgente Blade chiude la stringa invece di
proteggere l'apostrofo, ed e' l'altro modo in cui questo blocco si e' gia'
rotto.
Un blocco <script> con @script eviterebbe la questione alla radice, ma
rompe il rilevamento della radice singola di Livewire (errore 500).
--}}
<div class="space-y-6"
x-data="{
{{-- maxUploadKb() e' in kilobyte; file.size e' in byte. --}}
maxByte: {{ \App\Http\Controllers\Settings\BackupController::maxUploadKb() }} * 1000,
zipSelezionato: '',
uploadInCorso: false,
uploadPercento: 0,
erroreUpload: '',
passwordAzzeramento: '',
/*
* Diario di quel che succede nel browser.
*
* Serve perche' i modi in cui un caricamento muore sono spesso muti: il
* server chiude la connessione e nessun evento porta un messaggio, oppure
* la barra smette di avanzare senza che nulla lo dichiari. Qui ogni
* passaggio lascia una riga con l'ora, cosi' il punto esatto in cui si
* ferma e' leggibile invece che da indovinare, e il testo si puo' copiare
* e mandare a chi assiste.
*/
console: [],
ultimoAvanzamento: 0,
battito: null,
log(riga) {
const ora = new Date().toTimeString().slice(0, 8);
this.console.push(`[${ora}] ${riga}`);
// Il pannello e' alto qualche riga: il fondo e' la parte che interessa.
this.$nextTick(() => {
const el = this.$refs.consoleBox;
if (el) el.scrollTop = el.scrollHeight;
});
},
get consoleTesto() {
return this.console.join('\n');
{{-- Ogni riga passa dalla console unica: qui non si tiene piu' un diario
locale, cosi' l'ordine dei fatti resta uno solo. --}}
log(riga, livello) {
this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'ripristino' });
},
/*
* Un caricamento fermo non genera eventi: senza qualcosa che batta il
* tempo, l'ultima riga resterebbe quella di dieci minuti prima e non si
* distinguerebbe 'lento' da 'morto'.
* distinguerebbe lento da morto.
*/
avviaBattito() {
this.fermaBattito();
@@ -87,7 +54,7 @@
this.battito = setInterval(() => {
const fermoDa = Math.round((Date.now() - this.ultimoAvanzamento) / 1000);
if (fermoDa >= 15) {
this.log(`...nessun avanzamento da ${fermoDa}s (${this.uploadPercento}%)`);
this.log(`...nessun avanzamento da ${fermoDa}s (${this.uploadPercento}%)`, 'attesa');
}
}, 15000);
},
@@ -99,23 +66,7 @@
}
},
azzera() {
if (!this.passwordAzzeramento) {
return;
}
if (!confirm('Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente.')) {
return;
}
const password = this.passwordAzzeramento;
// Svuotata subito: se e' sbagliata si ridigita, e intanto non resta
// nel campo di una scheda lasciata aperta.
this.passwordAzzeramento = '';
this.$wire.confermaAzzeramento(password);
},
/**
/*
* Superato il tetto, e' nginx a chiudere la connessione a meta'
* trasferimento: la barra si pianta a una percentuale qualsiasi e non
* arriva nessun evento utile, quindi la pagina resta muta con l'aria di
@@ -153,8 +104,10 @@
this.log(`Limite del server: ${(this.maxByte / 1000000).toFixed(0)} MB`);
if (this.zipTroppoGrande(file)) {
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`);
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`, 'errore');
}
window.tmFocusZona('zona-ripristino', false);
},
inviaRipristino(event) {
@@ -162,7 +115,7 @@
const file = form.querySelector('input[name=backupZip]').files[0];
if (this.zipTroppoGrande(file)) {
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`);
this.log(`RIFIUTATO: supera il limite. Nessun caricamento avviato.`, 'errore');
return;
}
@@ -170,6 +123,7 @@
this.uploadInCorso = true;
this.uploadPercento = 0;
this.$dispatch('console-apri');
this.log(`Invio a ${form.action}`);
const xhr = new XMLHttpRequest();
@@ -201,7 +155,7 @@
});
xhr.upload.addEventListener('load', () => {
this.log(`Corpo della richiesta inviato per intero. In attesa della risposta del server...`);
this.log(`Corpo della richiesta inviato per intero. In attesa della risposta del server...`, 'attesa');
});
xhr.addEventListener('load', () => {
@@ -214,14 +168,14 @@
payload = JSON.parse(xhr.responseText || '{}');
} catch (e) {
payload = {};
this.log(`La risposta non e' JSON. Inizio: ${xhr.responseText.slice(0, 120)}`);
this.log(`La risposta non è JSON. Inizio: ${xhr.responseText.slice(0, 120)}`, 'errore');
}
if (xhr.status >= 200 && xhr.status < 300 && payload.operation_id) {
this.uploadPercento = 100;
this.zipSelezionato = '';
form.reset();
this.log(`Ripristino accodato: operazione ${payload.operation_id}`);
this.log(`Ripristino accodato: operazione ${payload.operation_id}`, 'ok');
this.log(`Da qui l'avanzamento arriva dal server.`);
// Da qui in poi l'avanzamento arriva dal job, non dall'upload.
this.$wire.seguiOperazione(payload.operation_id);
@@ -232,7 +186,7 @@
// il ripristino sia partito.
this.uploadPercento = 0;
this.erroreUpload = this.messaggioErrore(xhr.status, payload);
this.log(`ERRORE: ${this.erroreUpload}`);
this.log(`ERRORE: ${this.erroreUpload}`, 'errore');
});
xhr.addEventListener('error', () => {
@@ -241,7 +195,7 @@
this.fermaBattito();
this.erroreUpload = `Errore di rete durante il caricamento: connessione interrotta dal `
+ `server. Succede tipicamente quando il pacchetto supera il limite accettato.`;
this.log(`ERRORE DI RETE: il server ha chiuso la connessione senza rispondere.`);
this.log(`ERRORE DI RETE: il server ha chiuso la connessione senza rispondere.`, 'errore');
});
xhr.addEventListener('timeout', () => {
@@ -249,7 +203,7 @@
this.uploadPercento = 0;
this.fermaBattito();
this.erroreUpload = `Il caricamento non ha risposto entro 5 minuti ed è stato interrotto.`;
this.log(`TIMEOUT dopo 5 minuti senza risposta.`);
this.log(`TIMEOUT dopo 5 minuti senza risposta.`, 'errore');
});
xhr.addEventListener('abort', () => {
@@ -257,14 +211,14 @@
this.uploadPercento = 0;
this.fermaBattito();
this.erroreUpload = `Caricamento annullato.`;
this.log(`Caricamento annullato.`);
this.log(`Caricamento annullato.`, 'errore');
});
this.avviaBattito();
xhr.send(new FormData(form));
},
/**
/*
* 413 e 419 non hanno un corpo JSON: senza il codice HTTP nel messaggio
* resterebbe un generico errore che non dice cosa fare.
*/
@@ -292,136 +246,30 @@
return `Errore durante il caricamento del pacchetto (HTTP ${status}).`;
},
}"
x-on:scarica-backup.window="window.location.href = $event.detail.url">
{{-- Il poll vive quanto l'operazione: a stato `completed`/`failed` inCorso
diventa falso, l'attributo sparisce dal render successivo e le
richieste si fermano da sole. Vale anche quando l'operazione è di
un'altra zona: qui si polla piano (3s) solo per accorgersi che il
posto si è liberato. --}}
@if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
@if($erroreOperazione !== '')
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900" role="alert">
{{ $erroreOperazione }}
</div>
@endif
{{-- ─── Pannello di avanzamento ────────────────────────────────── --}}
@if($operationId && $op)
<div class="rounded-xl border p-5
{{ $stato === 'failed' ? 'border-red-300 bg-red-50' : ($stato === 'completed' ? 'border-green-300 bg-green-50' : 'border-indigo-200 bg-indigo-50') }}"
@if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-900">
{{ $stato === 'completed' ? 'Operazione completata' : ($stato === 'failed' ? 'Operazione fallita' : $etichettaTipo) }}
</div>
<div class="mt-0.5 text-xs text-gray-600 truncate">
{{ $op['fase'] ?? '' }}
@if(($op['total'] ?? 0) > 0 && $stato !== 'completed')
&mdash; {{ $op['processed'] ?? 0 }} / {{ $op['total'] }}
@endif
</div>
</div>
<div class="text-2xl font-bold tabular-nums {{ $stato === 'failed' ? 'text-red-700' : 'text-indigo-700' }}">
{{ $this->percentuale }}%
</div>
</div>
<div class="mt-3 h-2.5 overflow-hidden rounded-full bg-white/70">
<div class="h-full rounded-full transition-all duration-300
{{ $stato === 'failed' ? 'bg-red-600' : ($stato === 'completed' ? 'bg-green-600' : 'bg-indigo-600') }}"
style="width: {{ $this->percentuale }}%"></div>
</div>
@if($attesaDownload)
<div class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 px-4 py-3 space-y-3">
<p class="text-sm text-amber-900">
<strong>L&rsquo;archivio &egrave; pronto e il download &egrave; partito.</strong>
Non &egrave; stato cancellato ancora nulla: l&rsquo;azzeramento aspetta la tua conferma.
</p>
<p class="text-xs text-amber-800">
Apri il file scaricato e verifica che sia integro. Il server sa di aver <em>spedito</em>
l&rsquo;archivio, non pu&ograve; sapere se &egrave; arrivato intero: quella verifica tocca a te,
ed &egrave; l&rsquo;ultima possibilit&agrave; di tornare indietro.
</p>
{{-- 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. --}}
<div class="max-w-xs">
<label for="passwordConferma" class="block text-xs font-medium text-amber-900">
Conferma con la tua password
</label>
<input type="password" id="passwordConferma" autocomplete="current-password"
x-model="passwordAzzeramento"
x-on:keydown.enter.prevent="azzera()"
class="mt-1 block w-full rounded-lg border-amber-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm">
@error('passwordConferma') <p class="mt-1 text-xs font-medium text-red-700">{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap items-center gap-3">
<a href="{{ route('backup.file', $operationId) }}"
style="display:inline-flex;align-items:center;gap:6px;background:#fff;color:#92400e;border:1px solid #d97706;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
Scarica di nuovo
</a>
<button type="button"
x-on:click="azzera()"
x-bind:disabled="!passwordAzzeramento"
x-bind:style="!passwordAzzeramento ? 'opacity:.5;cursor:not-allowed;' : ''"
style="display:inline-flex;align-items:center;gap:6px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;">
Ho salvato il backup, azzera tutto
</button>
<button type="button" wire:click="annullaAzzeramento" class="text-xs text-gray-700 underline">
Annulla l&rsquo;azzeramento
</button>
</div>
@error('confermaReset') <p class="text-xs font-medium text-red-700">{{ $message }}</p> @enderror
</div>
@endif
@if($stato === 'failed')
<p class="mt-3 text-sm text-red-800">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && ($op['result']['messaggio'] ?? null))
<p class="mt-3 text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
@endif
@if($stato === 'completed' && $tipo === 'backup' && ($op['result']['file'] ?? null))
<div class="mt-3 flex flex-wrap items-center gap-3">
<a href="{{ route('backup.file', $operationId) }}"
style="display:inline-flex;align-items:center;gap:6px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
Scarica di nuovo
</a>
<span class="text-xs text-gray-600">
{{ $op['result']['nome_download'] ?? '' }} &mdash;
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
</span>
</div>
@endif
@if(!empty($op['logs']))
<details class="mt-3">
<summary class="cursor-pointer text-xs font-medium text-gray-700">Dettaglio</summary>
<textarea readonly rows="8" class="mt-2 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100">{{ implode(PHP_EOL, $op['logs']) }}</textarea>
</details>
@endif
@if(in_array($stato, ['completed', 'failed'], true))
<button type="button" wire:click="chiudi" class="mt-3 text-xs text-gray-600 underline">Chiudi</button>
@endif
</div>
@endif
{{-- ─── Backup ─────────────────────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
{{-- ═══ Zona 1 — Backup ═══════════════════════════════════════════ --}}
<section id="zona-backup"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5
{{ $occupato && $tipo === 'backup' ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-backup">
<div>
<h2 class="text-lg font-semibold text-gray-900">Backup completo (.zip)</h2>
<h2 id="titolo-backup" class="text-lg font-semibold text-gray-900">1 &middot; Backup completo (.zip)</h2>
<p class="text-xs text-gray-500 mt-1">
Un unico pacchetto con <strong>tutti</strong> i dati dell&rsquo;applicazione &mdash; utenti compresi &mdash;
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
E&rsquo; l&rsquo;archivio da conservare per rimettere in piedi l&rsquo;installazione da zero.
&Egrave; l&rsquo;archivio da conservare per rimettere in piedi l&rsquo;installazione da zero.
</p>
</div>
@@ -430,21 +278,55 @@
proclamatori in chiaro e le mappe dei territori. Conservalo in un luogo sicuro e non condividerlo.
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button type="button" wire:click="avviaBackup" @disabled($this->inCorso)
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="primary"
wire:click="avviaBackup"
x-on:click="$dispatch('console-apri')"
data-zona-focus
{{-- :disabled e non @disabled: dentro un tag <x-...> le
direttive Blade non vengono compilate, il tag resta
letterale e il PHP prodotto va in errore di sintassi. --}}
:disabled="$occupato">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Crea e scarica backup
</button>
<p class="text-xs text-gray-500">L&rsquo;archivio si genera in background: puoi seguire l&rsquo;avanzamento qui sopra.</p>
</div>
</x-btn>
{{-- L'archivio resta scaricabile finche' l'operazione e' in vista:
un download interrotto non deve costringere a rifare tutto. --}}
@if($this->archivioPronto && $tipo === 'backup')
<x-btn variante="secondary" :href="route('backup.file', $operationId)">
Scarica di nuovo
</x-btn>
<span class="text-xs text-gray-600">
{{ $op['result']['nome_download'] ?? '' }} &mdash;
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
</span>
@endif
@if($occupato)
<span class="text-xs text-gray-500">
Un&rsquo;operazione &egrave; gi&agrave; in corso: l&rsquo;avanzamento &egrave; nella console in fondo.
</span>
@else
<span class="text-xs text-gray-500">L&rsquo;archivio si genera in background e compare nella console in fondo.</span>
@endif
</div>
{{-- ─── Ripristino ─────────────────────────────────────────────── --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
@if($stato === 'failed' && $tipo === 'backup')
<p class="text-sm text-red-800" role="alert">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && $tipo === 'backup' && ($op['result']['messaggio'] ?? null))
<p class="text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
@endif
</section>
{{-- ═══ Zona 2 — Ripristino ═══════════════════════════════════════ --}}
<section id="zona-ripristino"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4
{{ $occupato && $tipo === 'restore' ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-ripristino">
<div>
<h2 class="text-lg font-semibold text-gray-900">Ripristina da backup</h2>
<h2 id="titolo-ripristino" class="text-lg font-semibold text-gray-900">2 &middot; Ripristina da backup</h2>
<p class="text-xs text-gray-500 mt-1">
Carica un pacchetto .zip generato qui sopra. Il ripristino <strong>sostituisce</strong> i dati
gestionali correnti e tutti i file caricati.
@@ -453,7 +335,7 @@
<div class="rounded-lg border border-red-300 bg-red-50 p-3 text-xs text-red-800">
<strong>Al termine tutti dovranno cambiare password</strong>, amministratore compreso: il pacchetto
contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono piu&rsquo; da
contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono pi&ugrave; da
considerarsi segrete. Verrai portato subito sulla schermata di cambio password.
</div>
@@ -464,16 +346,21 @@
{{-- La dimensione si controlla gia' alla selezione: sapere
subito che il file e' fuori limite evita di aspettare un
caricamento destinato a essere tagliato. --}}
<input name="backupZip" type="file" accept=".zip,application/zip"
<label for="backupZip" class="block text-xs font-medium text-gray-700 mb-1">Pacchetto di backup</label>
<input id="backupZip" name="backupZip" type="file" accept=".zip,application/zip"
data-zona-focus
x-on:change="fileScelto($event)"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-red-50 file:text-red-700 hover:file:bg-red-100">
<p class="mt-1 text-xs text-gray-500">
Dimensione massima accettata: {{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB.
Dimensione massima accettata: {{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB
(variabile <code class="rounded bg-gray-100 px-1">MAX_UPLOAD_MB</code>).
</p>
<p x-show="erroreUpload" x-cloak class="text-red-600 text-xs mt-1 font-medium" x-text="erroreUpload"></p>
<p x-show="erroreUpload" x-cloak class="text-red-600 text-xs mt-1 font-medium" role="alert" x-text="erroreUpload"></p>
</div>
{{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}}
{{-- Caricamento: percentuale reale dell'upload, non del ripristino.
Resta qui e non nella console perche' e' l'unica fase che avviene
nel browser: la console racconta, questa barra misura. --}}
<div x-show="uploadInCorso || uploadPercento > 0" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
<span x-text="uploadInCorso ? 'Caricamento del pacchetto...' : 'Pacchetto caricato'"></span>
@@ -484,100 +371,22 @@
</div>
</div>
@php($bloccato = $this->inCorso)
<button type="submit"
x-bind:disabled="!zipSelezionato || uploadInCorso || {{ $bloccato ? 'true' : 'false' }}"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#dc2626;color:#fff;border:1px solid #991b1b;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
x-bind:style="(!zipSelezionato || uploadInCorso || {{ $bloccato ? 'true' : 'false' }}) ? 'opacity:.5;cursor:not-allowed;' : ''">
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="danger" type="submit"
x-bind:disabled="!zipSelezionato || uploadInCorso || {{ $occupato ? 'true' : 'false' }}">
Ripristina backup
</button>
@if($bloccato)
<p class="text-xs text-gray-500">Un&rsquo;altra operazione è in corso: attendi che finisca.</p>
</x-btn>
@if($occupato)
<span class="text-xs text-gray-500">Un&rsquo;altra operazione &egrave; in corso: attendi che finisca.</span>
@endif
</div>
</form>
{{-- Console: il diario del browser e quello del server, uno sotto
l'altro. E' l'unico posto da cui si capisce dove un caricamento si
e' fermato, e il testo si seleziona e si copia per mandarlo a chi
assiste. --}}
<div class="border-t border-gray-200 pt-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-gray-900">Console</h3>
<div class="flex items-center gap-3 text-xs">
<button type="button" x-on:click="console = []" class="text-gray-600 underline">Pulisci</button>
<button type="button"
x-on:click="navigator.clipboard.writeText(consoleTesto + String.fromCharCode(10) + $refs.logServer?.value)"
class="text-gray-600 underline">Copia tutto</button>
</div>
</div>
<div class="mt-2">
<div class="text-xs font-medium text-gray-700">Browser</div>
<textarea readonly rows="8" x-ref="consoleBox" x-text="consoleTesto || 'In attesa. Scegli un pacchetto per iniziare.'"
class="mt-1 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100"></textarea>
</div>
<div class="mt-3">
<div class="text-xs font-medium text-gray-700">Server</div>
<textarea readonly rows="8" x-ref="logServer"
class="mt-1 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100">{{ !empty($op['logs']) ? implode(PHP_EOL, $op['logs']) : 'Nessuna operazione sul server. Compare qui appena il pacchetto viene accettato.' }}</textarea>
</div>
<p class="mt-2 text-xs text-gray-500">
Limite di caricamento configurato:
<strong>{{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB</strong>
(variabile <code class="rounded bg-gray-100 px-1">MAX_UPLOAD_MB</code>).
</p>
</div>
</div>
{{-- ─── Zona pericolosa ────────────────────────────────────────── --}}
<div class="rounded-xl border-2 border-red-300 bg-red-50/40 p-6 space-y-4">
<div class="flex items-start gap-3">
<svg class="h-6 w-6 flex-none text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
<div>
<h2 class="text-lg font-semibold text-red-900">Zona pericolosa</h2>
<p class="text-xs text-red-800 mt-1">Operazioni irreversibili. Leggi prima di premere.</p>
</div>
</div>
<div class="rounded-lg border border-red-300 bg-white p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-900">Azzera tutto e riparti da zero</h3>
<p class="text-xs text-gray-600">
Riporta l&rsquo;applicazione allo stato di installazione appena eseguita. In ordine:
</p>
<ol class="text-xs text-gray-600 list-decimal list-inside space-y-1">
<li><strong>Viene creato e scaricato un backup completo</strong> di tutto ciò che c&rsquo;è adesso.</li>
<li>L&rsquo;operazione si ferma e aspetta: <strong>controlli il file scaricato, digiti la tua password e confermi</strong>. Fino a quel clic non viene toccato nulla, e puoi annullare.</li>
<li>Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e <strong>tutti i file caricati</strong>.</li>
<li>Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.</li>
<li><strong>Tutti gli altri utenti vengono eliminati.</strong> Resta solo il tuo account di amministratore, con la password attuale &mdash; come un&rsquo;installazione nuova, che ha un amministratore solo.</li>
</ol>
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-800">
Il backup scaricato è l&rsquo;<strong>unico</strong> modo per tornare indietro. Verifica di averlo
salvato prima di chiudere il browser.
</div>
<div>
<label for="confermaReset" class="block text-xs font-medium text-gray-700">
Per confermare, scrivi <code class="rounded bg-gray-100 px-1 py-0.5 font-mono">{{ \App\Livewire\Settings\BackupManager::CONFERMA_AZZERAMENTO }}</code>
</label>
<input wire:model="confermaReset" type="text" id="confermaReset" autocomplete="off"
class="mt-1 block w-full max-w-xs rounded-lg border-gray-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm font-mono">
@error('confermaReset') <p class="text-red-600 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="button"
wire:click="avviaAzzeramento"
wire:confirm="Ultima conferma. Verrà scaricato un backup e poi l'applicazione sarà azzerata. L'operazione non è reversibile."
@disabled($this->inCorso)
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:600;"
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.87 12.14A2 2 0 0116.14 21H7.86a2 2 0 01-1.99-1.86L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3M4 7h16"/></svg>
Scarica backup e azzera tutto
</button>
</div>
</div>
@if($stato === 'failed' && $tipo === 'restore')
<p class="text-sm text-red-800" role="alert">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && $tipo === 'restore' && ($op['result']['messaggio'] ?? null))
<p class="text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
@endif
</section>
</div>

View File

@@ -0,0 +1,345 @@
{{--
Console unica delle operazioni: backup, ripristino, azzeramento, import PDF,
import XML. Agganciata in fondo alla finestra, si apre da sé quando qualcosa
parte e scorre da sola mentre arrivano le righe.
Perché una sola. Prima questa pagina aveva cinque riquadri di log — due nel
backup (browser e server), uno per l'upload dei PDF, uno per lo stato
dell'import, più il "Dettaglio" del pannello di avanzamento — e nessuno
diceva la storia per intero: un ripristino cominciava nel riquadro del
browser e finiva in quello del server, in due punti diversi della pagina, e
per capire dove si era fermato bisognava guardarli tutti. Qui la cronologia è
una, in ordine di tempo, con la sorgente accanto a ogni riga.
Perché è 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 la pagina non serve a niente proprio nel
momento in cui servirebbe.
Da dove arrivano le righe:
- `console-riga` eventi del browser (scelta file, avanzamento
dell'upload, codici HTTP, timeout, interruzioni)
- `console-operazione` code di log del server, rimandate per intero a ogni
poll da BackupManager e XmlExchange; qui si tiene il
conto di quante sono già state assorbite, così una
riga non compare due volte
--}}
{{--
ATTENZIONE — il JavaScript sta qui, in un <script>, e NON dentro l'attributo
x-data.
Un attributo HTML finisce alla prima virgoletta doppia, comprese quelle
dentro un commento: questa pagina si è già rotta due volte così, riversando
a video il codice come testo. Dentro un <script> le virgolette e gli
apostrofi non hanno alcuna limitazione.
Non è in resources/js/app.js perché quel bundle è un modulo ES, che il
browser rimanda a dopo il parsing del documento, mentre Alpine arriva col
pacchetto di Livewire in fondo al <body> ed è già avviato a quel punto: una
Alpine.data() registrata dopo l'avvio non viene applicata agli elementi già
inizializzati. Uno <script> classico qui viene eseguito prima, sempre.
Non usa @script (la direttiva di Livewire) perché rompe il rilevamento della
radice singola del componente e restituisce 500 — provato.
--}}
<script>
window.tmConsoleOperazioni = function () {
return {
righe: [],
aperta: false,
/* Lo scorrimento automatico si sospende se si torna indietro a
leggere: trascinare l'utente in fondo mentre sta guardando una
riga di dieci secondi prima è il modo più rapido di rendere
inutile un log che scorre. */
inFondo: true,
operazione: { etichetta: '', fase: '', percento: 0, stato: '' },
/* Tetto alla cronologia: un import di qualche migliaio di PDF
riempirebbe la memoria della scheda con nodi che nessuno legge. */
maxRighe: 600,
init() {
this.log('Console pronta: le operazioni compaiono qui.', 'info', 'app');
},
get haOperazione() {
return this.operazione.etichetta !== '';
},
get etichettaStato() {
if (this.operazione.stato === 'failed') return 'fallita';
if (this.operazione.stato === 'completed') return 'completata';
return 'in corso';
},
apri() {
if (this.aperta) return;
this.aperta = true;
this.inFondo = true;
this.$nextTick(() => this.scorriInFondo());
},
chiudi() {
this.aperta = false;
},
commuta() {
this.aperta ? this.chiudi() : this.apri();
},
log(testo, livello, sorgente, ora) {
this.righe.push({
ora: ora || new Date().toTimeString().slice(0, 8),
testo: testo,
livello: livello || 'info',
sorgente: sorgente || 'browser',
});
if (this.righe.length > this.maxRighe) {
this.righe.splice(0, this.righe.length - this.maxRighe);
}
this.$nextTick(() => this.scorriInFondo());
},
/* Riga singola dal browser. */
aggiungi(dettaglio) {
if (!dettaglio || !dettaglio.testo) return;
this.apri();
this.log(dettaglio.testo, dettaglio.livello, dettaglio.sorgente || 'browser');
},
/* Righe nuove dal server, più lo stato dell'operazione.
Sono già solo le nuove: il conto di quante sono state spedite lo
tiene il componente Livewire (SegueOperazioneBackup::logSpediti),
così la coda intera non viaggia dentro ogni risposta — sessanta
righe, poco meno di due volte al secondo. Per ogni operazione
esiste un solo componente che spedisce, quindi qui non serve
deduplicare. */
assorbi(dettaglio) {
if (!dettaglio) return;
const sorgente = dettaglio.sorgente || 'server';
const nuove = Array.isArray(dettaglio.righe) ? dettaglio.righe : [];
if (nuove.length) {
this.apri();
nuove.forEach((riga) => {
this.log(this.testoDi(riga), this.livelloDi(riga), sorgente, this.oraDi(riga));
});
}
if (dettaglio.etichetta !== undefined) {
this.operazione = {
etichetta: dettaglio.etichetta || '',
fase: dettaglio.fase || '',
percento: Number(dettaglio.percento) || 0,
stato: dettaglio.stato || '',
};
if (this.operazione.etichetta) {
this.apri();
}
}
},
/* Le righe del server arrivano già con "[HH:MM:SS] " davanti: quella
è l'ora del server, che è quella giusta da mostrare — l'ora del
browser sarebbe un'altra macchina. */
oraDi(riga) {
const trovato = String(riga).match(/^\[(\d{2}:\d{2}:\d{2})\]/);
return trovato ? trovato[1] : new Date().toTimeString().slice(0, 8);
},
testoDi(riga) {
return String(riga).replace(/^\[\d{2}:\d{2}:\d{2}\]\s*/, '');
},
livelloDi(riga) {
const testo = String(riga);
if (/ERRORE|FALLIT|non riuscit|rifiutat/i.test(testo)) return 'errore';
if (/complet|riuscit|pronto|creato/i.test(testo)) return 'ok';
if (/attesa|in coda/i.test(testo)) return 'attesa';
return 'info';
},
/* Svuota solo la vista: le righe già scritte se ne vanno, quelle che
arriveranno da qui in poi continuano a comparire. Il conto di cosa
è stato spedito lo tiene il server, quindi pulire non fa
ricomparire il passato né interrompe il flusso. */
pulisci() {
this.righe = [];
this.log('Cronologia svuotata.', 'info', 'app');
},
testoIntero() {
return this.righe
.map((r) => '[' + r.ora + '] ' + r.sorgente + ': ' + r.testo)
.join('\n');
},
copia() {
const testo = this.testoIntero();
if (!navigator.clipboard) {
this.log('Gli appunti non sono disponibili su una connessione non sicura: seleziona il testo a mano.', 'errore', 'app');
return;
}
navigator.clipboard.writeText(testo).then(
() => this.log('Cronologia copiata negli appunti (' + this.righe.length + ' righe).', 'ok', 'app'),
() => this.log('Copia negli appunti rifiutata dal browser.', 'errore', 'app')
);
},
scorriInFondo() {
const corpo = this.$refs.corpo;
if (!corpo || !this.inFondo) return;
corpo.scrollTop = corpo.scrollHeight;
},
aggiornaAncoraggio() {
const corpo = this.$refs.corpo;
if (!corpo) return;
/* Tolleranza di 24px: con lo scorrimento fluido la posizione non
arriva mai esattamente al fondo, e senza margine lo
scorrimento automatico si spegnerebbe da solo. */
this.inFondo = (corpo.scrollHeight - corpo.scrollTop - corpo.clientHeight) < 24;
},
riaggancia() {
this.inFondo = true;
this.scorriInFondo();
},
};
};
/**
* Porta la pagina su una zona e la fa lampeggiare.
*
* È il "focus" della pagina: premuto un pulsante, o scelta una voce
* dell'indice, deve essere evidente *dove* è finita l'attenzione, invece di
* dover cercare cosa è cambiato. Il focus da tastiera si sposta solo quando
* la richiesta è di navigazione (l'indice): spostarlo anche a ogni azione
* strapperebbe il cursore da sotto le dita di chi ha appena premuto.
*/
window.tmFocusZona = function (id, spostaFocus) {
const zona = document.getElementById(id);
if (!zona) return;
zona.scrollIntoView({ behavior: 'smooth', block: 'start' });
zona.classList.remove('tm-zona-evidenziata');
/* Forza un reflow: senza, il browser considera la classe già applicata e
su due clic ravvicinati l'animazione non si ripete. */
void zona.offsetWidth;
zona.classList.add('tm-zona-evidenziata');
window.setTimeout(function () {
zona.classList.remove('tm-zona-evidenziata');
}, 1700);
if (spostaFocus) {
const primo = zona.querySelector('[data-zona-focus]');
if (primo) {
primo.focus({ preventScroll: true });
}
}
};
</script>
<div wire:ignore
x-data="tmConsoleOperazioni()"
x-on:console-riga.window="aggiungi($event.detail)"
x-on:console-operazione.window="assorbi($event.detail)"
x-on:console-apri.window="apri()"
{{-- Il download automatico dell'archivio si ascolta QUI e in nessun altro
posto. È un evento su window: due componenti che lo ascoltassero
entrambi assegnerebbero window.location.href due volte, e il browser
scaricherebbe il pacchetto due volte. La console è l'unico elemento
della pagina che c'è sempre, qualunque zona abbia avviato
l'operazione. --}}
x-on:scarica-backup.window="log('Scaricamento del pacchetto avviato.', 'ok', 'app'); window.location.href = $event.detail.url">
{{-- Spaziatore: il pannello è a posizione fissa, quindi senza questo
coprirebbe l'ultima zona della pagina — che è quella pericolosa. --}}
<div aria-hidden="true" x-bind:style="'height:' + (aperta ? 360 : 60) + 'px'"></div>
<section class="tm-console" aria-label="Console delle operazioni">
<div class="tm-console-testata">
<button type="button" class="tm-console-titolo" x-on:click="commuta()"
aria-controls="tm-console-corpo"
x-bind:aria-expanded="aperta ? 'true' : 'false'">
<span aria-hidden="true" x-text="aperta ? '▾' : '▸'"></span>
<span>Console</span>
<span style="color:#9ca3af;font-weight:400"
x-show="!aperta && righe.length > 0" x-cloak
x-text="'(' + righe.length + ')'"></span>
</button>
{{-- 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. --}}
<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0"
x-show="haOperazione" x-cloak>
<span style="font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
aria-live="polite">
<span style="font-weight:600" x-text="operazione.etichetta"></span>
<span style="color:#9ca3af" x-show="operazione.fase" x-cloak
x-text="' · ' + operazione.fase"></span>
</span>
<div class="tm-console-barra"
x-bind:class="{
'tm-console-barra-ok': operazione.stato === 'completed',
'tm-console-barra-errore': operazione.stato === 'failed'
}"
role="progressbar" aria-label="Avanzamento operazione"
aria-valuemin="0" aria-valuemax="100"
x-bind:aria-valuenow="operazione.percento">
<div x-bind:style="'width:' + operazione.percento + '%'"></div>
</div>
<span style="font-size:12px;font-variant-numeric:tabular-nums;min-width:38px;text-align:right"
x-text="operazione.percento + '%'"></span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-left:auto">
<button type="button" class="tm-console-btn" x-on:click="pulisci()">Pulisci</button>
<button type="button" class="tm-console-btn" x-on:click="copia()">Copia</button>
</div>
</div>
<div id="tm-console-corpo" class="tm-console-corpo" x-ref="corpo"
x-show="aperta" x-cloak
x-on:scroll="aggiornaAncoraggio()">
<template x-for="(riga, indice) in righe" :key="indice">
<div class="tm-console-riga" x-bind:class="'tm-console-riga-' + riga.livello">
<span class="tm-console-ora" x-text="riga.ora"></span>
<span class="tm-console-sorgente" x-text="riga.sorgente"></span>
<span class="tm-console-testo" x-text="riga.testo"></span>
</div>
</template>
</div>
<button type="button" class="tm-console-torna"
x-show="aperta && !inFondo" x-cloak
x-on:click="riaggancia()">
Torna in fondo &darr;
</button>
</section>
</div>

View File

@@ -6,9 +6,9 @@
<div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4 max-w-lg">
<p class="text-sm font-medium text-gray-700 mb-3">Sezione Import</p>
<div class="flex flex-wrap gap-2">
<a href="{{ route('xml.exchange') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
<x-btn variante="primary" :href="route('xml.exchange')">
Apri strumenti di import
</a>
</x-btn>
</div>
<p class="text-xs text-gray-500 mt-2">Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.</p>
</div>
@@ -72,7 +72,7 @@
</div>
<div class="pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Salva Impostazioni</button>
<x-btn variante="primary" type="submit">Salva impostazioni</x-btn>
</div>
</form>
</div>

View File

@@ -11,7 +11,7 @@
<input wire:model="nuovaTipologia" type="text" id="nuovaTipologia" placeholder="Nome tipologia..." class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('nuovaTipologia') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Aggiungi</button>
<x-btn variante="primary" type="submit">Aggiungi</x-btn>
</form>
</div>
@@ -23,8 +23,8 @@
@if($editingId === $tipo->id)
<form wire:submit="saveEdit" class="flex items-center gap-2 flex-1">
<input wire:model="editingNome" type="text" class="flex-1 rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<button type="submit" class="text-green-600 hover:text-green-800 text-xs font-medium">Salva</button>
<button type="button" wire:click="cancelEdit" class="text-gray-500 hover:text-gray-700 text-xs">Annulla</button>
<x-btn variante="primary" dimensione="sm" type="submit">Salva</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="cancelEdit">Annulla</x-btn>
</form>
@else
<div class="flex items-center gap-2">
@@ -35,9 +35,9 @@
<span class="text-xs text-gray-400">{{ $tipo->territori()->count() }} territori</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button wire:click="startEdit({{ $tipo->id }})" class="text-yellow-600 hover:text-yellow-800 text-xs">Rinomina</button>
<button wire:click="toggleActive({{ $tipo->id }})" class="text-xs {{ $tipo->attivo ? 'text-gray-500' : 'text-green-600' }}">{{ $tipo->attivo ? 'Disattiva' : 'Attiva' }}</button>
<button wire:click="deleteTipologia({{ $tipo->id }})" wire:confirm="Eliminare la tipologia '{{ $tipo->nome }}'?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $tipo->id }})">Rinomina</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="toggleActive({{ $tipo->id }})">{{ $tipo->attivo ? 'Disattiva' : 'Attiva' }}</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="deleteTipologia({{ $tipo->id }})" wire:confirm="Eliminare la tipologia '{{ $tipo->nome }}'?">Elimina</x-btn>
</div>
@endif
</li>

View File

@@ -51,7 +51,7 @@
</div>
<div>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Crea Utente</button>
<x-btn variante="primary" type="submit">Crea utente</x-btn>
</div>
</form>
</div>
@@ -106,8 +106,8 @@
</div>
<div class="mt-3 flex gap-2">
<button wire:click="updateUser" type="button" class="px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">Salva</button>
<button wire:click="cancelEdit" type="button" class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200">Annulla</button>
<x-btn variante="primary" dimensione="sm" wire:click="updateUser">Salva</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="cancelEdit">Annulla</x-btn>
</div>
</td>
@else
@@ -124,15 +124,14 @@
</td>
<td class="px-4 py-2 cell-actions">
<div class="flex gap-2">
<button wire:click="startEdit({{ $user->id }})" type="button" class="px-2 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 rounded hover:bg-indigo-100">Modifica</button>
<button
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
{{-- wire:confirm come nel resto dell'app, al posto di un onclick
che fermava la propagazione a mano. --}}
<x-btn variante="danger" tono="tenue" dimensione="sm"
wire:click="deleteUser({{ $user->id }})"
onclick="if(!confirm('Confermi la cancellazione dell\'utente? I log verranno preservati.')) event.stopImmediatePropagation();"
type="button"
class="px-2 py-1 text-xs font-medium text-red-700 bg-red-50 rounded hover:bg-red-100"
>
Cancella
</button>
wire:confirm="Confermi la cancellazione dell'utente? I log verranno preservati.">
Elimina
</x-btn>
</div>
</td>
@endif

View File

@@ -1,176 +1,230 @@
{{--
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.
--}}
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Backup e import</h1>
<p class="text-sm text-gray-500 mt-1">Backup completo dell&rsquo;applicazione, import PDF territori, conversione legacy SQL, import ed export XML.</p>
<p class="text-sm text-gray-500 mt-1">
Backup completo dell&rsquo;applicazione, ripristino, import dei PDF dei territori,
scambio dati in XML e azzeramento.
</p>
</div>
{{-- 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. --}}
<nav class="tm-nav-zone" aria-label="Zone della pagina" x-data="{}">
<span class="text-xs text-gray-500 self-center mr-1">Vai a</span>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-backup', true)">1 &middot; Backup</button>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-ripristino', true)">2 &middot; Ripristino</button>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-pdf', true)">3 &middot; Import PDF</button>
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-xml', true)">4 &middot; XML</button>
<button type="button" class="tm-nav-zona tm-nav-zona-pericolo" x-on:click="window.tmFocusZona('zona-azzeramento', true)">5 &middot; Zona pericolosa</button>
</nav>
{{-- ═══ Zone 1 e 2 ════════════════════════════════════════════════════ --}}
<livewire:settings.backup-manager />
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
{{-- ═══ Zona 3 — Import PDF territori ═════════════════════════════════ --}}
<section id="zona-pdf"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5 {{ $this->importPdfInCorso ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-pdf"
x-data="{
uploadLog: '',
uploadProgress: 0,
uploading: false,
zipUploading: false,
zipProgress: 0,
selectedFiles: 0,
selectedZip: '',
append(message) {
this.uploadLog = this.uploadLog ? this.uploadLog + '\n' + message : message;
},
submitZip(event) {
const form = event.target;
const formData = new FormData(form);
fileScelti: 0,
zipScelto: '',
zipInCorso: false,
zipPercento: 0,
this.uploadLog = '';
this.zipUploading = true;
this.zipProgress = 0;
this.append('Upload ZIP diretto al server avviato...');
log(riga, livello) {
this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'import-pdf' });
},
cartellaScelta(event) {
this.fileScelti = event.target.files.length;
if (this.fileScelti > 0) {
this.log(`Cartella scelta: ${this.fileScelti} file.`);
this.log(`In attesa del caricamento temporaneo di Livewire...`, 'attesa');
}
},
zipSceltoDa(event) {
const file = event.target.files[0];
this.zipScelto = file ? file.name : '';
this.zipPercento = 0;
if (file) {
this.log(`Archivio ZIP scelto: ${file.name} — ${(file.size / 1000000).toFixed(1)} MB`);
}
},
inviaZip(event) {
const form = event.target;
this.zipInCorso = true;
this.zipPercento = 0;
this.$dispatch('console-apri');
this.log(`Invio dell'archivio ZIP al server...`);
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.addEventListener('progress', (uploadEvent) => {
if (!uploadEvent.lengthComputable) {
let ultimaTacca = -1;
xhr.upload.addEventListener('progress', (e) => {
if (!e.lengthComputable) {
return;
}
this.zipProgress = Math.round((uploadEvent.loaded / uploadEvent.total) * 100);
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}%`);
}
});
xhr.addEventListener('load', () => {
this.zipUploading = false;
this.zipInCorso = false;
let payload = {};
try {
payload = JSON.parse(xhr.responseText || '{}');
} catch (error) {
} catch (errore) {
payload = {};
}
if (xhr.status >= 200 && xhr.status < 300) {
this.zipProgress = 100;
this.append('Archivio ricevuto. Reindirizzamento alla console import...');
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;
}
const message = payload.message || (payload.errors && payload.errors.pdfZip ? payload.errors.pdfZip[0] : 'Errore durante il caricamento dello ZIP.');
this.append(message);
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.zipUploading = false;
this.append('Errore di rete durante il caricamento dello ZIP.');
this.zipInCorso = false;
this.zipPercento = 0;
this.log(`ERRORE DI RETE durante il caricamento dello ZIP.`, 'errore');
});
xhr.send(formData);
}
xhr.send(new FormData(form));
},
}"
x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { uploading = true; uploadProgress = 0; append('Upload cartella avviato...'); }"
x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { uploadProgress = $event.detail.progress; append('Upload Livewire in corso: ' + $event.detail.progress + '%'); }"
x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { uploading = false; uploadProgress = 100; append('Upload completato. Avvio preparazione import lato server...'); }"
x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { uploading = false; append('Errore durante l\'upload temporaneo dei file.'); }">
<h2 class="text-lg font-semibold text-gray-900">Import PDF territori</h2>
<p class="text-xs text-gray-500">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.</p>
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'); }">
<form wire:submit.prevent="importTerritoryPdfFolder" class="space-y-4">
<div>
<input wire:model="pdfFolder"
x-on:change="selectedFiles = $event.target.files.length; uploadLog = ''; if (selectedFiles > 0) { append('Cartella selezionata: ' + selectedFiles + ' file.'); append('In attesa dell\'upload temporaneo Livewire...'); }"
type="file"
multiple
webkitdirectory
directory
accept=".pdf,application/pdf"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('pdfFolder') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@error('pdfFolder.*') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
<h2 id="titolo-pdf" class="text-lg font-semibold text-gray-900">3 &middot; Import PDF territori</h2>
<p class="text-xs text-gray-500 mt-1">
Associa i PDF ai territori gi&agrave; presenti e ne genera le miniature. Il nome del file pu&ograve;
variare: basta che contenga il numero di un territorio esistente.
</p>
</div>
<div x-show="selectedFiles > 0 || uploading" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
<span x-text="uploading ? 'Upload file in corso...' : 'Upload file pronto'"></span>
<span x-text="selectedFiles > 0 ? selectedFiles + ' file selezionati' : ''"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-indigo-100">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-300" :style="'width:' + uploadProgress + '%' "></div>
</div>
</div>
{{-- 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. --}}
<div class="grid gap-4 md:grid-cols-2">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
wire:loading.attr="disabled"
@disabled(empty($pdfFolder))
>
Importa PDF territori
</button>
<div wire:loading wire:target="importTerritoryPdfFolder" class="text-sm text-indigo-700">Preparazione import in corso...</div>
</div>
</form>
<div class="border-t border-gray-200 pt-4">
<h3 class="text-sm font-semibold text-gray-900">Import da archivio ZIP</h3>
<p class="mt-1 text-xs text-gray-500">Consigliato per grandi volumi: carichi un solo file e il server estrae automaticamente tutti i PDF.</p>
<form action="{{ route('imports.territori.pdf-zip') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-4" @submit.prevent="submitZip">
{{-- Strada consigliata --}}
<form action="{{ route('imports.territori.pdf-zip') }}" method="POST" enctype="multipart/form-data"
class="rounded-lg border border-gray-200 p-4 space-y-3" @submit.prevent="inviaZip($event)">
@csrf
<div>
<input name="pdfZip"
x-on:change="selectedZip = $event.target.files[0] ? $event.target.files[0].name : ''; uploadLog = ''; zipProgress = 0; if (selectedZip) { append('Archivio ZIP selezionato: ' + selectedZip); append('Pronto per upload diretto al server.'); }"
type="file"
accept=".zip,application/zip"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100">
@error('pdfZip') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
<h3 class="text-sm font-semibold text-gray-900">Da archivio ZIP <span class="text-xs font-normal text-emerald-700">&mdash; consigliato</span></h3>
<p class="mt-1 text-xs text-gray-500">Un solo file: il server estrae i PDF da s&eacute;. &Egrave; la via da usare per grandi volumi.</p>
</div>
<div x-show="selectedZip || zipUploading" x-cloak class="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-emerald-800">
<span x-text="zipUploading ? 'Upload ZIP in corso...' : 'ZIP pronto per l\'invio' "></span>
<span x-text="selectedZip"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-emerald-100">
<div class="h-full rounded-full bg-emerald-600 transition-all duration-300" :style="'width:' + zipProgress + '%' "></div>
</div>
<div>
<label for="pdfZip" class="sr-only">Archivio ZIP dei PDF</label>
<input id="pdfZip" name="pdfZip" type="file" accept=".zip,application/zip"
data-zona-focus
x-on:change="zipSceltoDa($event)"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('pdfZip') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition border border-emerald-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;"
x-bind:disabled="!selectedZip || zipUploading"
>
Importa ZIP PDF
</button>
<div x-show="zipUploading" x-cloak class="text-sm text-emerald-700">Caricamento ZIP diretto in corso...</div>
<div x-show="zipInCorso || zipPercento > 0" x-cloak class="h-2 overflow-hidden rounded-full bg-indigo-100">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-200" x-bind:style="'width:' + zipPercento + '%'"></div>
</div>
<x-btn variante="primary" type="submit" x-bind:disabled="!zipScelto || zipInCorso">
Importa archivio ZIP
</x-btn>
</form>
{{-- Strada alternativa --}}
<form wire:submit.prevent="importTerritoryPdfFolder" class="rounded-lg border border-gray-200 p-4 space-y-3">
<div>
<h3 class="text-sm font-semibold text-gray-900">Da cartella</h3>
<p class="mt-1 text-xs text-gray-500">Comoda per pochi file. Ogni PDF viaggia come caricamento temporaneo a s&eacute;.</p>
</div>
<div>
<label for="pdfFolder" class="sr-only">Cartella di PDF</label>
<input wire:model="pdfFolder" id="pdfFolder" type="file" multiple webkitdirectory directory
accept=".pdf,application/pdf"
x-on:change="cartellaScelta($event)"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
@error('pdfFolder') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
@error('pdfFolder.*') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<p class="text-xs text-gray-500" x-show="fileScelti > 0" x-cloak x-text="fileScelti + ' file selezionati'"></p>
<x-btn variante="secondary" type="submit"
x-on:click="$dispatch('console-apri')"
wire:loading.attr="disabled"
{{-- Vedi la nota in components/btn.blade.php: dentro un
tag di componente serve :disabled, non @disabled. --}}
:disabled="empty($pdfFolder)">
Importa cartella
</x-btn>
<span wire:loading wire:target="importTerritoryPdfFolder" class="text-xs text-indigo-700">Preparazione import...</span>
</form>
</div>
<div x-show="uploadLog || uploading || zipUploading || selectedZip" x-cloak>
<div class="mb-2 text-sm font-medium text-gray-800">Console upload</div>
<textarea readonly rows="8" x-bind:value="uploadLog" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500"></textarea>
</div>
{{-- 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)))
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 space-y-4"
@if($this->importPdfInCorso) wire:poll.1000ms="refreshPdfImportStatus" @endif>
@if($currentPdfImportId && (!empty($pdfImportStats) || !empty($pdfImportLogs)))
<div class="rounded-xl border border-indigo-200 bg-indigo-50/40 p-4" @if(in_array($pdfImportStatus, ['queued', 'running'], true)) wire:poll.1000ms="refreshPdfImportStatus" @endif>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-medium text-gray-800">Stato import PDF</div>
<div class="mt-1 text-xs text-gray-500">ID import: {{ $currentPdfImportId }}</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-medium text-gray-800">Esito dell&rsquo;import</div>
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold
{{ match($pdfImportStatus) {
'queued' => 'bg-amber-100 text-amber-800',
@@ -190,7 +244,7 @@
</div>
@if(!empty($pdfImportStats))
<div class="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div class="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div class="rounded-lg bg-white px-3 py-2 border border-gray-200">
<div class="text-xs text-gray-500">Processati</div>
<div class="font-semibold text-gray-900">{{ $pdfImportStats['processed'] ?? 0 }} / {{ $pdfImportStats['total'] ?? 0 }}</div>
@@ -210,19 +264,9 @@
</div>
@endif
<div class="mt-4">
<div class="mb-2 flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-800">Log import PDF</div>
<button type="button" wire:click="refreshPdfImportStatus" class="inline-flex items-center justify-center rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-white transition">
Aggiorna log
</button>
</div>
<textarea readonly rows="12" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 sm:rows-14">{{ $pdfImportLogText }}</textarea>
</div>
@if(!empty($pdfImportIssues))
<div class="mt-4">
<div class="mb-2 text-sm font-medium text-gray-800">Riepilogo file non associati o problematici</div>
<div>
<div class="mb-2 text-sm font-medium text-gray-800">File non associati o problematici</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
@@ -247,57 +291,92 @@
@endif
</div>
@endif
</section>
{{-- ═══ Zona 4 — Dati in formato XML ══════════════════════════════════ --}}
<section id="zona-xml"
class="tm-zona bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5"
aria-labelledby="titolo-xml">
<div>
<h2 id="titolo-xml" class="text-lg font-semibold text-gray-900">4 &middot; Dati in formato XML</h2>
<p class="text-xs text-gray-500 mt-1">
Scambio dei soli dati gestionali, senza i file caricati. Per un backup davvero completo
usa il pacchetto .zip della <button type="button" class="underline" x-on:click="window.tmFocusZona('zona-backup', true)">zona 1</button>:
ripristinando solo l&rsquo;XML i territori restano senza PDF n&eacute; miniature.
</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Conversione dump SQL legacy</h2>
<p class="text-xs text-gray-500">Carica il file SQL (es. dump-termanager-202604071526.sql) e genera un XML compatibile con l'import dell'app.</p>
<div class="grid gap-4 md:grid-cols-2">
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data">
{{-- Export --}}
<div class="rounded-lg border border-gray-200 p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-900">Esporta</h3>
<p class="text-xs text-gray-500">Tutti i dati correnti in un file XML, reimportabile qui accanto.</p>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> Il file include gli utenti con le password (hash) e i nomi dei
proclamatori in chiaro: chi lo ottiene pu&ograve; tentare di forzare le password offline.
</div>
<x-btn variante="secondary" wire:click="exportCurrentAsXml" data-zona-focus>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Esporta XML
</x-btn>
</div>
{{-- Import --}}
<div class="rounded-lg border border-red-200 p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-900">Importa</h3>
<p class="text-xs text-gray-500">
<strong>Sostituisce</strong> zone, tipologie, proclamatori, territori, anni, campagne,
assegnazioni e impostazioni.
</p>
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" class="space-y-3"
onsubmit="return confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.');">
@csrf
<div>
<input name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
<label for="xmlImport" class="sr-only">File XML da importare</label>
<input id="xmlImport" name="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-red-50 file:text-red-700 hover:file:bg-red-100">
@error('xmlImport') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2 mt-4">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
>
Converti in XML
</button>
</div>
<x-btn variante="danger" type="submit">Importa XML</x-btn>
</form>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Import XML nell'app</h2>
<p class="text-xs text-gray-500">Importa un XML nel formato TerManager2. L'import sostituisce i dati gestionali (zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni e impostazioni).</p>
{{-- Conversione legacy: e' una tantum, quindi sta in fondo alla zona e
non si prende una colonna accanto alle due operazioni ricorrenti. --}}
<details class="rounded-lg border border-gray-200 p-4">
<summary class="cursor-pointer text-sm font-semibold text-gray-900">Converti un dump SQL della vecchia applicazione</summary>
<p class="mt-2 text-xs text-gray-500">
Carica il file SQL (per esempio <code class="rounded bg-gray-100 px-1">dump-termanager-202604071526.sql</code>)
e ottieni un XML compatibile con l&rsquo;import qui sopra. Non scrive niente nell&rsquo;applicazione:
produce solo un file da scaricare.
</p>
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" onsubmit="return confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.');">
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-3">
@csrf
<div>
<input name="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('xmlImport') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
<label for="sqlDump" class="sr-only">Dump SQL da convertire</label>
<input id="sqlDump" name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
@error('sqlDump') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
</div>
<div class="flex gap-2 mt-4">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition border border-amber-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#d97706;color:#fff;border:1px solid #92400e;border-radius:10px;padding:10px 14px;"
>
Importa XML
</button>
</div>
<x-btn variante="secondary" type="submit">Converti in XML</x-btn>
</form>
</div>
</details>
{{-- 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))
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-3">
<h2 class="text-lg font-semibold text-gray-900">Log importazione</h2>
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 space-y-3">
<div class="text-sm font-medium text-gray-800">Esito dell&rsquo;ultimo import XML</div>
<div class="text-sm text-gray-700" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;">
<div>Utenti importati: <strong>{{ $importStats['utenti_importati'] ?? 0 }}</strong></div>
<div>Zone importate: <strong>{{ $importStats['zone_importate'] ?? 0 }}</strong></div>
@@ -311,33 +390,27 @@
<div>Assegnazioni saltate: <strong>{{ $importStats['assegnazioni_saltate'] ?? 0 }}</strong></div>
</div>
<div class="mt-3">
<button
wire:click="downloadImportLogPdf"
type="button"
style="display:inline-flex;align-items:center;gap:6px;background:#1d4ed8;color:#fff;border:1px solid #1e3a8a;border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer;"
>
<svg style="width:15px;height:15px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Scarica log PDF
</button>
</div>
<x-btn variante="secondary" dimensione="sm" wire:click="downloadImportLogPdf">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
Scarica il riepilogo in PDF
</x-btn>
@if(!empty($importIssues))
<div class="mt-2" style="max-height:260px;overflow:auto;border:1px solid #e5e7eb;border-radius:10px;">
<table class="table-cards" style="width:100%;border-collapse:collapse;font-size:13px;">
<thead style="background:#f9fafb;position:sticky;top:0;">
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white" style="max-height:260px;">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Entità</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Legacy ID</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Motivo</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Entit&agrave;</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Legacy ID</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-gray-100">
@foreach($importIssues as $issue)
<tr>
<td data-label="Entità" style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['entity'] }}</td>
<td data-label="Legacy ID" style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['legacy_id'] }}</td>
<td data-label="Motivo" style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['reason'] }}</td>
<td data-label="Entità" class="px-3 py-2 text-gray-900">{{ $issue['entity'] }}</td>
<td data-label="Legacy ID" class="px-3 py-2 text-gray-600">{{ $issue['legacy_id'] }}</td>
<td data-label="Motivo" class="px-3 py-2 text-gray-600">{{ $issue['reason'] }}</td>
</tr>
@endforeach
</tbody>
@@ -346,30 +419,11 @@
@endif
</div>
@endif
</section>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h2 class="text-lg font-semibold text-gray-900">Export XML (solo dati)</h2>
<p class="text-xs text-gray-500">
Esporta <strong>tutti</strong> i dati correnti dell'app in XML, reimportabile qui sopra.
<strong>Non</strong> 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.
</p>
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
<strong>Dati sensibili.</strong> 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.
</div>
<div class="flex gap-2">
<button
wire:click="exportCurrentAsXml"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition border border-emerald-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;"
>
Esporta XML
</button>
</div>
</div>
{{-- ═══ Zona 5 ════════════════════════════════════════════════════════ --}}
<livewire:settings.zona-pericolosa />
{{-- Console unica, agganciata in fondo alla finestra. --}}
@include('livewire.settings.partials.console-operazioni')
</div>

View File

@@ -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
<div x-data="{
password: '',
azzera() {
if (!this.password) {
return;
}
if (!confirm('Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente.')) {
return;
}
const password = this.password;
// Svuotata subito: se e' sbagliata si ridigita, e intanto non resta
// nel campo di una scheda lasciata aperta.
this.password = '';
this.$dispatch('console-apri');
this.$wire.confermaAzzeramento(password);
},
}"
{{-- Vedi la nota gemella in backup-manager: il poll si spegne da solo
quando l'operazione chiude. --}}
@if($this->inCorso) wire:poll.{{ $this->intervalloPoll }}="aggiorna" @endif>
<section id="zona-azzeramento"
class="tm-zona rounded-xl border-2 border-red-300 bg-red-50/40 p-6 space-y-4 {{ $mioAzzeramento ? 'tm-zona-attiva' : '' }}"
aria-labelledby="titolo-azzeramento">
<div class="flex items-start gap-3">
<svg class="h-6 w-6 flex-none text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
<div>
<h2 id="titolo-azzeramento" class="text-lg font-semibold text-red-900">5 &middot; Zona pericolosa</h2>
<p class="text-xs text-red-800 mt-1">Operazioni irreversibili. Leggi prima di premere.</p>
</div>
</div>
@error('confermaReset')
<p class="rounded-lg border border-red-300 bg-white px-3 py-2 text-xs font-medium text-red-700" role="alert">{{ $message }}</p>
@enderror
{{-- ─── Secondo tempo: l'archivio c'e', manca la conferma ────────── --}}
@if($attesa)
<div class="rounded-lg border-2 border-amber-400 bg-amber-50 p-4 space-y-3"
x-init="window.tmFocusZona('zona-azzeramento', false)">
<p class="text-sm text-amber-900">
<strong>L&rsquo;archivio &egrave; pronto e il download &egrave; partito.</strong>
Non &egrave; stato cancellato ancora nulla: l&rsquo;azzeramento aspetta la tua conferma.
</p>
<p class="text-xs text-amber-800">
Apri il file scaricato e verifica che sia integro. Il server sa di aver <em>spedito</em>
l&rsquo;archivio, non pu&ograve; sapere se &egrave; arrivato intero: quella verifica tocca a te,
ed &egrave; l&rsquo;ultima possibilit&agrave; di tornare indietro.
</p>
{{-- 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. --}}
<div class="max-w-xs">
<label for="passwordConferma" class="block text-xs font-medium text-amber-900">
Conferma con la tua password
</label>
<input type="password" id="passwordConferma" autocomplete="current-password"
data-zona-focus
x-model="password"
x-on:keydown.enter.prevent="azzera()"
class="mt-1 block w-full rounded-lg border-amber-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm">
@error('passwordConferma') <p class="mt-1 text-xs font-medium text-red-700" role="alert">{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="secondary" :href="route('backup.file', $operationId)">
Scarica di nuovo
</x-btn>
<x-btn variante="danger"
x-on:click="azzera()"
x-bind:disabled="!password">
Ho salvato il backup, azzera tutto
</x-btn>
<x-btn variante="ghost" wire:click="annullaAzzeramento">
Annulla l&rsquo;azzeramento
</x-btn>
</div>
</div>
@endif
{{-- ─── Primo tempo ─────────────────────────────────────────────── --}}
<div class="rounded-lg border border-red-300 bg-white p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-900">Azzera tutto e riparti da zero</h3>
<p class="text-xs text-gray-600">
Riporta l&rsquo;applicazione allo stato di installazione appena eseguita. In ordine:
</p>
<ol class="text-xs text-gray-600 list-decimal list-inside space-y-1">
<li><strong>Viene creato e scaricato un backup completo</strong> di tutto ci&ograve; che c&rsquo;&egrave; adesso.</li>
<li>L&rsquo;operazione si ferma e aspetta: <strong>controlli il file scaricato, digiti la tua password e confermi</strong>. Fino a quel clic non viene toccato nulla, e puoi annullare.</li>
<li>Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attivit&agrave; e <strong>tutti i file caricati</strong>.</li>
<li>Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.</li>
<li><strong>Tutti gli altri utenti vengono eliminati.</strong> Resta solo il tuo account di amministratore, con la password attuale &mdash; come un&rsquo;installazione nuova, che ha un amministratore solo.</li>
</ol>
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-800">
Il backup scaricato &egrave; l&rsquo;<strong>unico</strong> modo per tornare indietro. Verifica di averlo
salvato prima di chiudere il browser.
</div>
<div>
<label for="confermaReset" class="block text-xs font-medium text-gray-700">
Per confermare, scrivi <code class="rounded bg-gray-100 px-1 py-0.5 font-mono">{{ \App\Livewire\Settings\ZonaPericolosa::CONFERMA_AZZERAMENTO }}</code>
</label>
<input wire:model="confermaReset" type="text" id="confermaReset" autocomplete="off"
data-zona-focus
@disabled($attesa)
class="mt-1 block w-full max-w-xs rounded-lg border-gray-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm font-mono">
</div>
<div class="flex flex-wrap items-center gap-3">
<x-btn variante="danger"
wire:click="avviaAzzeramento"
wire:confirm="Ultima conferma. Verrà scaricato un backup e poi l'applicazione sarà azzerata. L'operazione non è reversibile."
x-on:click="$dispatch('console-apri')"
{{-- Vedi la nota in components/btn.blade.php: dentro un
tag di componente serve :disabled, non @disabled. --}}
:disabled="$this->inCorso">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.87 12.14A2 2 0 0116.14 21H7.86a2 2 0 01-1.99-1.86L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3M4 7h16"/></svg>
Scarica backup e azzera tutto
</x-btn>
@if($occupatoDaAltri)
<span class="text-xs text-gray-600">
Un backup o un ripristino sono in corso: l&rsquo;azzeramento pu&ograve; partire solo a operazione finita.
</span>
@elseif($mioAzzeramento && ! $attesa)
<span class="text-xs text-gray-600">
Azzeramento in corso: l&rsquo;avanzamento &egrave; nella console in fondo alla finestra.
</span>
@endif
</div>
@if($stato === 'failed' && $mioAzzeramento)
<p class="text-sm text-red-800" role="alert">{{ $operazione['error'] ?? 'Errore non specificato.' }}</p>
@endif
@if($stato === 'completed' && ($operazione['result']['messaggio'] ?? null) && ($operazione['tipo'] ?? null) === 'reset')
<p class="text-sm text-green-900">{{ $operazione['result']['messaggio'] }}</p>
@endif
</div>
</section>
</div>

View File

@@ -11,7 +11,7 @@
<input wire:model="nuovaZona" type="text" id="nuovaZona" placeholder="Nome zona..." class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('nuovaZona') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Aggiungi</button>
<x-btn variante="primary" type="submit">Aggiungi</x-btn>
</form>
</div>
@@ -23,8 +23,8 @@
@if($editingId === $zona->id)
<form wire:submit="saveEdit" class="flex items-center gap-2 flex-1">
<input wire:model="editingNome" type="text" class="flex-1 rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<button type="submit" class="text-green-600 hover:text-green-800 text-xs font-medium">Salva</button>
<button type="button" wire:click="cancelEdit" class="text-gray-500 hover:text-gray-700 text-xs">Annulla</button>
<x-btn variante="primary" dimensione="sm" type="submit">Salva</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="cancelEdit">Annulla</x-btn>
</form>
@else
<div class="flex items-center gap-2">
@@ -35,9 +35,9 @@
<span class="text-xs text-gray-400">{{ $zona->territori()->count() }} territori</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<button wire:click="startEdit({{ $zona->id }})" class="text-yellow-600 hover:text-yellow-800 text-xs">Rinomina</button>
<button wire:click="toggleActive({{ $zona->id }})" class="text-xs {{ $zona->attivo ? 'text-gray-500' : 'text-green-600' }}">{{ $zona->attivo ? 'Disattiva' : 'Attiva' }}</button>
<button wire:click="deleteZona({{ $zona->id }})" wire:confirm="Eliminare la zona '{{ $zona->nome }}'?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $zona->id }})">Rinomina</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="toggleActive({{ $zona->id }})">{{ $zona->attivo ? 'Disattiva' : 'Attiva' }}</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="deleteZona({{ $zona->id }})" wire:confirm="Eliminare la zona '{{ $zona->nome }}'?">Elimina</x-btn>
</div>
@endif
</li>

View File

@@ -24,8 +24,8 @@
<td data-label="Eliminato il" class="px-4 py-3 text-sm text-gray-600">{{ $territorio->deleted_at->format('d/m/Y H:i') }}</td>
<td class="px-4 py-3 text-sm text-right cell-actions">
<div class="flex flex-wrap items-center justify-end gap-1">
<button wire:click="restore({{ $territorio->id }})" class="btn-action btn-action-green">Ripristina</button>
<button wire:click="forceDelete({{ $territorio->id }})" wire:confirm="Eliminare DEFINITIVAMENTE il territorio {{ $territorio->numero }}? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
<x-btn variante="primary" tono="tenue" dimensione="sm" wire:click="restore({{ $territorio->id }})">Ripristina</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="forceDelete({{ $territorio->id }})" wire:confirm="Eliminare DEFINITIVAMENTE il territorio {{ $territorio->numero }}? Questa azione è irreversibile.">Elimina</x-btn>
</div>
</td>
</tr>

View File

@@ -56,16 +56,16 @@
@if($isEdit && $this->territorio->pdf_path)
<div class="mt-2 flex items-center gap-2 text-sm">
<span class="text-green-600">PDF presente</span>
<button type="button" wire:click="removePdf" wire:confirm="Rimuovere il PDF?" class="text-red-600 hover:text-red-800 text-xs underline">Rimuovi PDF</button>
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="removePdf" wire:confirm="Rimuovere il PDF?">Rimuovi PDF</x-btn>
</div>
@endif
</div>
<div class="flex gap-3 pt-2">
<a href="{{ route('territori.index') }}" class="px-4 py-2.5 rounded-lg text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 transition">Annulla</a>
<button type="submit" class="px-6 py-2.5 rounded-lg text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 transition">
{{ $isEdit ? 'Aggiorna' : 'Crea' }}
</button>
{{-- Azione principale prima, uscita dopo: stesso ordine in tutti i form
dell'applicazione. Prima cambiava da una pagina all'altra. --}}
<div class="flex flex-wrap gap-3 pt-2">
<x-btn variante="primary" type="submit">{{ $isEdit ? 'Aggiorna' : 'Crea' }}</x-btn>
<x-btn variante="secondary" :href="route('territori.index')">Annulla</x-btn>
</div>
</form>
</div>

View File

@@ -6,14 +6,14 @@
</div>
@can('territori.manage')
<div class="mt-4 sm:mt-0 flex gap-2">
<a href="{{ route('territori.cestino') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">
<svg class="h-4 w-4" style="color:#9ca3af" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Cestino
</a>
<a href="{{ route('territori.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<x-btn variante="primary" :href="route('territori.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo territorio
</a>
</x-btn>
<x-btn variante="secondary" :href="route('territori.cestino')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Cestino
</x-btn>
</div>
@endcan
</div>
@@ -143,38 +143,54 @@
{{-- Primary actions: Assegna / Rientra --}}
@can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="btn-action btn-primary-green">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $territorio->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegna
</a>
</x-btn>
@endif
@endcan
@can('territori.return')
@if($territorio->assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
{{-- Rientra non cancella niente: era rosso pieno
quanto un Elimina. È l'azione principale
della riga, alternativa ad Assegna. --}}
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Rientra
</a>
</x-btn>
@endif
@endcan
{{-- Secondary actions --}}
<a href="{{ route('territori.show', $territorio) }}" class="btn-action btn-action-indigo" title="Dettaglio">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</a>
{{-- Solo icona: l'aria-label non è ridondante col
title, è l'unico nome che un lettore di
schermo annuncia in modo affidabile. --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('territori.show', $territorio)"
title="Dettaglio" aria-label="Dettaglio del territorio {{ $territorio->numero }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</x-btn>
@can('territori.manage')
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-gray" title="Modifica">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</a>
<button wire:click="toggleActive({{ $territorio->id }})" class="btn-action btn-action-amber" title="{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }}">
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('territori.edit', $territorio)"
title="Modifica" aria-label="Modifica il territorio {{ $territorio->numero }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="toggleActive({{ $territorio->id }})"
title="{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }}"
aria-label="{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }} il territorio {{ $territorio->numero }}">
@if($territorio->attivo)
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
@else
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
@endif
</button>
<button wire:click="deleteTerritorio({{ $territorio->id }})" wire:confirm="Spostare il territorio {{ $territorio->numero }} nel cestino?" class="btn-action btn-action-red" title="Elimina">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</x-btn>
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="deleteTerritorio({{ $territorio->id }})"
wire:confirm="Spostare il territorio {{ $territorio->numero }} nel cestino?"
title="Elimina" aria-label="Sposta nel cestino il territorio {{ $territorio->numero }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn>
@endcan
</div>
</td>

View File

@@ -10,25 +10,28 @@
<div class="flex items-center gap-2 flex-wrap">
@can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="btn-action btn-primary-green" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
<x-btn variante="primary" :href="route('assegnazioni.assegna', ['territorioId' => $territorio->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegna
</a>
</x-btn>
@endif
@endcan
@can('territori.return')
@if($territorio->assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="btn-action btn-primary-red" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
{{-- 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. --}}
<x-btn variante="primary" :href="route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Rientra
</a>
</x-btn>
@endif
@endcan
@can('territori.manage')
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
<x-btn variante="secondary" :href="route('territori.edit', $territorio)">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica
</a>
</x-btn>
@endcan
</div>
</div>
@@ -96,7 +99,7 @@
</div>
<div class="flex flex-wrap gap-2">
@can('territori.return')
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $activeAssignment->id]) }}" class="inline-flex items-center rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition">Rientra</a>
<x-btn variante="primary" :href="route('assegnazioni.rientra', ['assegnazione' => $activeAssignment->id])">Rientra</x-btn>
@endcan
</div>
</div>
@@ -108,11 +111,11 @@
<div class="text-sm font-medium text-indigo-900">Link PDF temporaneo</div>
<div class="mt-1 text-xs text-indigo-700">Valido fino al {{ optional($assignmentScadenza)->format('d/m/Y') ?? '—' }} (soglia rientro) o fino al rientro del territorio.</div>
</div>
<a href="{{ $temporaryPdfUrl }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100 transition">Apri viewer</a>
<x-btn variante="secondary" :href="$temporaryPdfUrl" target="_blank" rel="noopener noreferrer">Apri viewer</x-btn>
</div>
<div class="mt-3 flex flex-col gap-2 sm:flex-row">
<input x-ref="assignmentPdfLink" type="text" readonly value="{{ $temporaryPdfUrl }}" class="block w-full rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm text-gray-700 focus:border-indigo-500 focus:ring-indigo-500">
<button type="button" @click="navigator.clipboard.writeText($refs.assignmentPdfLink.value); copied = true; setTimeout(() => copied = false, 1800);" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition">Copia link</button>
<x-btn variante="primary" @click="navigator.clipboard.writeText($refs.assignmentPdfLink.value); copied = true; setTimeout(() => copied = false, 1800);">Copia link</x-btn>
</div>
<p x-show="copied" x-cloak class="mt-2 text-xs font-medium text-green-700">Link copiato.</p>
</div>

View File

@@ -0,0 +1,149 @@
<?php
namespace Tests\Feature;
use App\Livewire\Home;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* "Media percorrenza congregazione": la durata media di un'assegnazione chiusa,
* in mesi, sull'anno di servizio corrente.
*
* Il calcolo è stato spostato da SQL a PHP perché usava DATEDIFF() e YEAR(),
* funzioni MySQL che rendevano la dashboard l'unica pagina non verificabile
* dalla suite. Questi test fissano il comportamento che lo spostamento doveva
* lasciare intatto — compresi i due filtri, che sono la parte facile da perdere
* riscrivendo: senza, una data assurda ereditata dalla vecchia applicazione o
* un rientro nello stesso giorno spostano la media senza volerlo dire.
*/
class HomeMediaDurataTest extends TestCase
{
use RefreshDatabase;
private User $utente;
private Proclamatore $proclamatore;
private AnnoTeocratico $anno;
private int $prossimoTerritorio = 1;
protected function setUp(): void
{
parent::setUp();
$this->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,
]);
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* La pagina "Backup e import" servita per intero: rotta, permessi, layout e i
* tre componenti Livewire annidati.
*
* Serve perché il grosso di questa pagina è markup, e il markup non fallisce
* come il codice: una vista che non compila non rompe niente finché qualcuno non
* la apre. È già successo — `@disabled(...)` dentro un tag <x-btn> 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
* <script> nella pagina e non in resources/js/app.js.
*
* Alpine arriva col pacchetto di Livewire, in fondo al body, e valuta
* x-data="tmConsoleOperazioni()" quando parte: se la funzione non fosse
* ancora definita, la console sarebbe un riquadro morto. Il bundle di app.js
* non andrebbe bene perché è un modulo ES, che il browser rimanda a dopo il
* parsing del documento — cioè dopo Alpine.
*
* Il rapporto fra i due è invisibile guardando i file: si vede solo qui.
*/
public function test_la_console_e_definita_prima_che_alpine_parta(): void
{
$html = $this->actingAs($this->admin())->get(route('xml.exchange'))->getContent();
$definizione = strpos($html, 'window.tmConsoleOperazioni =');
$livewire = strpos($html, 'livewire.js');
$this->assertNotFalse($definizione, 'La definizione della console non è nella pagina.');
$this->assertNotFalse($livewire, 'Il pacchetto di Livewire non è nella pagina.');
$this->assertLessThan(
$livewire,
$definizione,
'La console va definita prima del pacchetto che avvia Alpine, altrimenti x-data non la trova.'
);
}
/**
* I cinque riquadri di log separati sono spariti: se ne torna uno, la pagina
* ricomincia a raccontare la stessa operazione in due punti diversi.
*/
public function test_non_sono_rimasti_riquadri_di_log_separati(): void
{
$risposta = $this->actingAs($this->admin())->get(route('xml.exchange'));
$risposta->assertDontSee('Console upload');
$risposta->assertDontSee('Log import PDF');
}
/**
* Il difetto che questo test presidia: se un tag di componente non viene
* compilato, resta nella pagina come testo. Non solleva, non rompe il
* layout in modo evidente — semplicemente il pulsante non c'è.
*/
public function test_nessun_tag_di_componente_e_rimasto_da_compilare(): void
{
$html = $this->actingAs($this->admin())->get(route('xml.exchange'))->getContent();
$this->assertStringNotContainsString('<x-btn', $html);
$this->assertStringNotContainsString('</x-btn>', $html);
}
/**
* I pulsanti passano tutti dal sistema unico. Un `style=` inline su un
* pulsante è il segnale che qualcuno è tornato a definirne uno per conto suo,
* senza stato di focus.
*/
public function test_i_pulsanti_usano_le_classi_del_sistema_unico(): void
{
$html = $this->actingAs($this->admin())->get(route('xml.exchange'))->getContent();
foreach (['tm-btn tm-btn-primary', 'tm-btn tm-btn-danger', 'tm-btn tm-btn-secondary'] as $classe) {
$this->assertStringContainsString($classe, $html, "Manca un pulsante {$classe}.");
}
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Tests\Feature;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* Ogni pagina dell'applicazione si apre davvero.
*
* Nasce dall'unificazione dello stile, che ha toccato tutte le viste: un errore
* di markup non fallisce come un errore di codice — non rompe niente finche'
* qualcuno non apre quella pagina, e con venti viste modificate in una volta
* "qualcuno" e' un utente, non chi ha fatto la modifica.
*
* Il controllo su `<x-` non e' teorico: un tag di componente che il compilatore
* non riconosce resta nella pagina come testo. Non solleva, non rompe il
* layout in modo evidente — semplicemente il pulsante non c'e'.
*/
class PagineApplicativeTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Gli asset compilati non stanno nell'immagine di test: vedi la nota in
// PaginaBackupImportTest.
$this->withoutVite();
}
/**
* Rotte senza parametri. Quelle con un modello nell'URL hanno un test a
* parte, perche' hanno bisogno che il modello esista.
*
* @return array<string, array{string}>
*/
public static function rotteSempliciProvider(): array
{
$rotte = [
'dashboard',
'profile',
'territori.index',
'territori.create',
'territori.cestino',
'proclamatori.index',
'proclamatori.create',
'proclamatori.cestino',
'campagne.index',
'campagne.create',
'registro.index',
'audit.index',
'settings.edit',
'users.index',
'zone.index',
'tipologie.index',
'xml.exchange',
'privacy',
'assegnazioni.assegna',
];
return array_combine(
$rotte,
array_map(fn (string $rotta) => [$rotta], $rotte)
);
}
#[DataProvider('rotteSempliciProvider')]
public function test_la_pagina_si_apre_senza_markup_rimasto_da_compilare(string $rotta): void
{
$risposta = $this->actingAs($this->admin())->get(route($rotta));
$risposta->assertOk();
$this->assertNessunTagDaCompilare($risposta->getContent(), $rotta);
}
public function test_le_pagine_legate_a_un_record_si_aprono(): void
{
$this->actingAs($this->admin());
$territorio = Territorio::create(['numero' => '1', 'attivo' => true]);
$proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
$campagna = Campagna::create([
'descrizione' => 'Campagna di prova',
'start_date' => '2026-01-01',
'end_date' => '2026-12-31',
]);
$assegnazione = Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $proclamatore->id,
'anno_teocratico_id' => AnnoTeocratico::corrente()->id,
'assigned_at' => '2026-02-01',
'created_by' => auth()->id(),
]);
$pagine = [
'territori.show' => route('territori.show', $territorio),
'territori.edit' => route('territori.edit', $territorio),
'proclamatori.show' => route('proclamatori.show', $proclamatore),
'proclamatori.edit' => route('proclamatori.edit', $proclamatore),
'campagne.show' => route('campagne.show', $campagna),
'campagne.edit' => route('campagne.edit', $campagna),
'assegnazioni.rientra' => route('assegnazioni.rientra', $assegnazione),
];
foreach ($pagine as $nome => $url) {
$risposta = $this->get($url);
$risposta->assertOk();
$this->assertNessunTagDaCompilare($risposta->getContent(), $nome);
}
}
public function test_la_schermata_di_cambio_password_si_apre(): void
{
$risposta = $this->actingAs($this->admin())->get(route('password.change'));
$risposta->assertOk();
$this->assertNessunTagDaCompilare($risposta->getContent(), 'password.change');
}
public function test_la_pagina_di_accesso_si_apre(): void
{
$risposta = $this->get(route('login'));
$risposta->assertOk();
$this->assertNessunTagDaCompilare($risposta->getContent(), 'login');
}
private function assertNessunTagDaCompilare(string $html, string $dove): void
{
$this->assertStringNotContainsString('<x-btn', $html, "{$dove}: tag <x-btn> rimasto da compilare.");
$this->assertStringNotContainsString('</x-btn>', $html, "{$dove}: tag </x-btn> rimasto da compilare.");
$this->assertStringNotContainsString('btn-action', $html, "{$dove}: e' rimasta una classe .btn-action, che non esiste piu' nel foglio di stile.");
}
private function admin(): User
{
Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web']));
app(PermissionRegistrar::class)->forgetCachedPermissions();
// La testata legge le impostazioni: senza riga, il nome della
// congregazione ricade sul default e va bene lo stesso, ma le pagine
// che leggono le soglie vogliono trovarla.
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
$utente = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password-di-prova',
]);
$utente->assignRole('amministratore');
return $utente;
}
}

View File

@@ -0,0 +1,333 @@
<?php
namespace Tests\Feature;
use App\Livewire\Registro;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use ReflectionMethod;
use Tests\TestCase;
/**
* Modulo S-13-I: il ciclo da quattro gruppi "Assegnato a" e la colonna
* "Restituito l'ultima volta il".
*
* E' la regola meno intuitiva del Registro — la quinta assegnazione non si
* aggiunge alle quattro precedenti, le sostituisce — e finisce su un modulo che
* viene firmato e archiviato: se sbaglia, sbaglia su carta.
*
* righeS13() e' privata di proposito (non e' un'azione Livewire); qui la si
* chiama in riflessione perche' e' il calcolo, non il PDF, quello da proteggere.
*/
class RegistroS13Test extends TestCase
{
use RefreshDatabase;
private User $utente;
private Proclamatore $proclamatore;
private AnnoTeocratico $anno;
protected function setUp(): void
{
parent::setUp();
$this->utente = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => 'password',
]);
$this->proclamatore = Proclamatore::create([
'nome' => 'Mario',
'cognome' => 'Rossi',
'attivo' => true,
]);
$this->anno = AnnoTeocratico::create([
'label' => '2025-2026',
'start_date' => '2025-09-01',
'end_date' => '2026-08-31',
]);
}
// ─── Il primo ciclo ─────────────────────────────────────────
public function test_una_sola_assegnazione_riempie_la_prima_colonna_e_lascia_le_altre_in_bianco(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-10', '2025-10-15');
$riga = $this->righe()->sole();
$this->assertSame('1', $riga['numero']);
$this->assertSame('', $riga['riporto']);
$this->assertCount(4, $riga['assegnazioni']);
$this->assertSame(
['nome' => 'Rossi Mario', 'assegnato_il' => '10/09/2025', 'restituito_il' => '15/10/2025'],
$riga['assegnazioni'][0]
);
// Le colonne libere restano stampate: sul modulo cartaceo si compilano
// a mano.
foreach ([1, 2, 3] as $indice) {
$this->assertSame(
['nome' => '', 'assegnato_il' => '', 'restituito_il' => ''],
$riga['assegnazioni'][$indice]
);
}
}
public function test_un_assegnazione_ancora_aperta_ha_la_data_di_rientro_vuota(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-10');
$riga = $this->righe()->sole();
$this->assertSame('10/09/2025', $riga['assegnazioni'][0]['assegnato_il']);
$this->assertSame('', $riga['assegnazioni'][0]['restituito_il']);
}
public function test_tre_assegnazioni_occupano_le_prime_tre_colonne(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-10-01', '2025-10-20');
$this->assegna($territorio, '2025-11-01', '2025-11-20');
$riga = $this->righe()->sole();
$this->assertSame(
['01/09/2025', '01/10/2025', '01/11/2025', ''],
array_column($riga['assegnazioni'], 'assegnato_il')
);
$this->assertSame('', $riga['riporto']);
}
public function test_quattro_assegnazioni_riempiono_la_riga_senza_riporto(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-10-01', '2025-10-20');
$this->assegna($territorio, '2025-11-01', '2025-11-20');
$this->assegna($territorio, '2025-12-01', '2025-12-20');
$riga = $this->righe()->sole();
$this->assertSame(
['01/09/2025', '01/10/2025', '01/11/2025', '01/12/2025'],
array_column($riga['assegnazioni'], 'assegnato_il')
);
// Finche' il primo ciclo non e' esaurito non c'e' niente da riportare.
$this->assertSame('', $riga['riporto']);
}
// ─── Il ciclo che ricomincia ────────────────────────────────
/**
* Il caso che da' il nome alla regola: riempito il quarto gruppo si
* ricomincia da capo, come sul modulo cartaceo si aprirebbe una riga nuova.
* Le prime quattro assegnazioni escono dal modulo e restano solo nel
* riporto.
*/
public function test_la_quinta_assegnazione_apre_un_ciclo_nuovo_e_lascia_il_riporto(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-10-01', '2025-10-20');
$this->assegna($territorio, '2025-11-01', '2025-11-20');
$this->assegna($territorio, '2025-12-01', '2025-12-24');
$this->assegna($territorio, '2026-01-05', '2026-01-25');
$riga = $this->righe()->sole();
$this->assertSame(
['05/01/2026', '', '', ''],
array_column($riga['assegnazioni'], 'assegnato_il')
);
// La colonna riporta la data di rientro della quarta, cioe' quella che
// chiudeva il giro precedente — senza il nome di chi lo aveva.
$this->assertSame('24/12/2025', $riga['riporto']);
}
public function test_otto_assegnazioni_mostrano_il_secondo_ciclo_per_intero(): void
{
$territorio = $this->territorio('1');
$date = [
['2025-09-01', '2025-09-20'],
['2025-10-01', '2025-10-20'],
['2025-11-01', '2025-11-20'],
['2025-12-01', '2025-12-24'],
['2026-01-05', '2026-01-25'],
['2026-02-05', '2026-02-25'],
['2026-03-05', '2026-03-25'],
['2026-04-05', '2026-04-25'],
];
foreach ($date as [$da, $a]) {
$this->assegna($territorio, $da, $a);
}
$riga = $this->righe()->sole();
$this->assertSame(
['05/01/2026', '05/02/2026', '05/03/2026', '05/04/2026'],
array_column($riga['assegnazioni'], 'assegnato_il')
);
$this->assertSame('24/12/2025', $riga['riporto']);
}
public function test_alla_nona_assegnazione_il_riporto_e_quello_dell_ottava(): void
{
$territorio = $this->territorio('1');
$date = [
['2025-09-01', '2025-09-20'],
['2025-10-01', '2025-10-20'],
['2025-11-01', '2025-11-20'],
['2025-12-01', '2025-12-24'],
['2026-01-05', '2026-01-25'],
['2026-02-05', '2026-02-25'],
['2026-03-05', '2026-03-25'],
['2026-04-05', '2026-04-28'],
['2026-05-05', null],
];
foreach ($date as [$da, $a]) {
$this->assegna($territorio, $da, $a);
}
$riga = $this->righe()->sole();
$this->assertSame(
['05/05/2026', '', '', ''],
array_column($riga['assegnazioni'], 'assegnato_il')
);
$this->assertSame('28/04/2026', $riga['riporto']);
}
/**
* Il riporto legge la data di rientro dell'ultima assegnazione del giro
* precedente: se quella e' ancora aperta la colonna resta vuota invece di
* inventarsi una data.
*/
public function test_il_riporto_resta_vuoto_se_l_ultima_del_giro_precedente_non_e_rientrata(): void
{
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-10-01', '2025-10-20');
$this->assegna($territorio, '2025-11-01', '2025-11-20');
$this->assegna($territorio, '2025-12-01', null);
$this->assegna($territorio, '2026-01-05', '2026-01-25');
$riga = $this->righe()->sole();
$this->assertSame('05/01/2026', $riga['assegnazioni'][0]['assegnato_il']);
$this->assertSame('', $riga['riporto']);
}
// ─── Cosa entra nel modulo ──────────────────────────────────
public function test_i_territori_disattivati_restano_fuori_dal_modulo(): void
{
$attivo = $this->territorio('1');
$disattivato = $this->territorio('2', attivo: false);
$this->assegna($attivo, '2025-09-01');
$this->assegna($disattivato, '2025-09-01');
$this->assertSame(['1'], $this->righe()->pluck('numero')->all());
}
public function test_i_territori_nel_cestino_restano_fuori_dal_modulo(): void
{
$vivo = $this->territorio('1');
$cestinato = $this->territorio('2');
$this->assegna($vivo, '2025-09-01');
$this->assegna($cestinato, '2025-09-01');
$cestinato->delete();
$this->assertSame(['1'], $this->righe()->pluck('numero')->all());
}
public function test_le_assegnazioni_di_un_altro_anno_di_servizio_non_compaiono(): void
{
$altroAnno = AnnoTeocratico::create([
'label' => '2024-2025',
'start_date' => '2024-09-01',
'end_date' => '2025-08-31',
]);
$territorio = $this->territorio('1');
$this->assegna($territorio, '2025-09-01', '2025-09-20');
$this->assegna($territorio, '2025-01-10', '2025-02-10', $altroAnno);
$riga = $this->righe()->sole();
$this->assertSame(
['01/09/2025', '', '', ''],
array_column($riga['assegnazioni'], 'assegnato_il')
);
}
public function test_un_territorio_senza_assegnazioni_nell_anno_non_produce_una_riga(): void
{
$this->territorio('1');
$this->assertTrue($this->righe()->isEmpty());
}
/**
* I numeri di territorio sono stringhe ("1", "1 A", "10"): l'ordinamento e'
* naturale, non alfabetico, altrimenti il 10 finirebbe fra l'1 e il 2.
*/
public function test_le_righe_sono_in_ordine_naturale_di_numero(): void
{
foreach (['10', '2', '1 A', '1'] as $numero) {
$this->assegna($this->territorio($numero), '2025-09-01');
}
$this->assertSame(['1', '1 A', '2', '10'], $this->righe()->pluck('numero')->all());
}
// ─── helper ─────────────────────────────────────────────────
private function territorio(string $numero, bool $attivo = true): Territorio
{
return Territorio::create([
'numero' => $numero,
'attivo' => $attivo,
]);
}
private function assegna(
Territorio $territorio,
string $assegnatoIl,
?string $rientratoIl = null,
?AnnoTeocratico $anno = null
): Assegnazione {
return Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $this->proclamatore->id,
'anno_teocratico_id' => ($anno ?? $this->anno)->id,
'assigned_at' => $assegnatoIl,
'returned_at' => $rientratoIl,
'created_by' => $this->utente->id,
]);
}
/**
* @return Collection<int, array>
*/
private function righe(?AnnoTeocratico $anno = null): Collection
{
$metodo = new ReflectionMethod(Registro::class, 'righeS13');
return $metodo->invoke(new Registro(), $anno ?? $this->anno);
}
}

View File

@@ -0,0 +1,264 @@
<?php
namespace Tests\Feature;
use App\Jobs\CreateBackupArchive;
use App\Jobs\ResetApplication;
use App\Livewire\Settings\BackupManager;
use App\Livewire\Settings\ZonaPericolosa;
use App\Models\User;
use App\Services\BackupOperationState;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* L'azzeramento: l'unica operazione irreversibile dell'applicazione.
*
* Questi test esistono perché il flusso è stato spostato da BackupManager a un
* componente suo, per poterlo mettere in fondo alla pagina invece che a metà. Lo
* spostamento non doveva cambiare niente di quello che protegge: i due tempi
* separati dal download, la password richiesta al secondo, il limite ai
* tentativi, il posto unico. Sono esattamente i controlli di cui non ci si
* accorge che si sono rotti finché non servono.
*/
class ZonaPericolosaTest extends TestCase
{
use RefreshDatabase;
private const PASSWORD = 'password-di-prova';
private User $admin;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->admin = User::create([
'name' => 'Amministratore',
'email' => 'admin@example.test',
'password' => self::PASSWORD,
]);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
// ─── Primo tempo ────────────────────────────────────────────
public function test_senza_la_frase_esatta_non_parte_niente(): void
{
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', 'azzera')
->call('avviaAzzeramento')
->assertHasErrors('confermaReset');
Queue::assertNothingPushed();
$this->assertNull(app(BackupOperationState::class)->corrente());
}
public function test_la_frase_esatta_avvia_il_backup_preventivo_e_niente_altro(): void
{
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', ZonaPericolosa::CONFERMA_AZZERAMENTO)
->call('avviaAzzeramento')
->assertHasNoErrors();
// Il primo tempo crea solo l'archivio: la cancellazione arriva dopo, e
// solo su conferma umana.
Queue::assertPushed(CreateBackupArchive::class);
Queue::assertNotPushed(ResetApplication::class);
$stato = app(BackupOperationState::class);
$corrente = $stato->corrente();
$this->assertNotNull($corrente, 'L\'azzeramento deve occupare il posto unico.');
$this->assertSame(BackupOperationState::TIPO_RESET, $stato->get($corrente)['tipo']);
}
public function test_la_frase_di_conferma_si_svuota_dopo_l_avvio(): void
{
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', ZonaPericolosa::CONFERMA_AZZERAMENTO)
->call('avviaAzzeramento')
->assertSet('confermaReset', '');
}
public function test_un_backup_gia_in_corso_impedisce_l_azzeramento(): void
{
app(BackupOperationState::class)->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 10);
Livewire::test(ZonaPericolosa::class)
->set('confermaReset', ZonaPericolosa::CONFERMA_AZZERAMENTO)
->call('avviaAzzeramento');
Queue::assertNotPushed(CreateBackupArchive::class);
$this->assertSame('op-backup', app(BackupOperationState::class)->corrente());
}
// ─── Secondo tempo ──────────────────────────────────────────
/**
* Il controllo che regge tutto il disegno a due tempi: senza la prova che
* l'archivio è stato spedito, la cancellazione non parte. Il server non può
* sapere se il file è *arrivato* — nginx bufferizza la risposta e un
* download annullato è indistinguibile da uno riuscito — ma può almeno
* sapere di averlo mandato.
*/
public function test_senza_archivio_servito_la_conferma_non_cancella_niente(): void
{
$this->operazioneInAttesa(servito: false);
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', self::PASSWORD)
->assertHasErrors('confermaReset');
Queue::assertNotPushed(ResetApplication::class);
}
public function test_senza_password_la_conferma_non_cancella_niente(): void
{
$this->operazioneInAttesa();
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', '')
->assertHasErrors('passwordConferma');
Queue::assertNotPushed(ResetApplication::class);
}
public function test_con_la_password_sbagliata_la_conferma_non_cancella_niente(): void
{
$this->operazioneInAttesa();
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', 'non-e-la-mia-password')
->assertHasErrors('passwordConferma');
Queue::assertNotPushed(ResetApplication::class);
}
public function test_con_la_password_giusta_l_azzeramento_parte(): void
{
$this->operazioneInAttesa();
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', self::PASSWORD)
->assertHasNoErrors();
Queue::assertPushed(ResetApplication::class);
}
/**
* Senza limite ai tentativi, chiedere la password su una sessione dirottata
* sarebbe solo un rallentamento: si proverebbero le password comuni a
* raffica, e il resto della pagina è già raggiungibile.
*/
public function test_cinque_tentativi_sbagliati_bloccano_l_operazione(): void
{
$this->operazioneInAttesa();
for ($tentativo = 1; $tentativo <= 5; $tentativo++) {
Livewire::test(ZonaPericolosa::class)->call('confermaAzzeramento', 'sbagliata');
}
// Il sesto tentativo è rifiutato anche se la password è quella giusta.
Livewire::test(ZonaPericolosa::class)
->call('confermaAzzeramento', self::PASSWORD)
->assertHasErrors('passwordConferma');
Queue::assertNotPushed(ResetApplication::class);
}
public function test_annullare_libera_il_posto_e_lascia_il_backup_scaricabile(): void
{
$this->operazioneInAttesa();
Livewire::test(ZonaPericolosa::class)->call('annullaAzzeramento');
$stato = app(BackupOperationState::class);
$this->assertNull($stato->corrente(), 'Annullando, il posto deve tornare libero.');
Queue::assertNotPushed(ResetApplication::class);
// Da qui in poi è un backup come un altro: l'archivio resta valido, e la
// zona di backup deve poterlo offrire in download.
$finale = $stato->get('op-reset');
$this->assertSame(BackupOperationState::TIPO_BACKUP, $finale['tipo']);
$this->assertSame('completed', $finale['status']);
}
// ─── Console e proprietà dell'operazione ────────────────────
/**
* Ogni operazione ha un solo componente che la racconta. Senza, backup e
* zona pericolosa spedirebbero entrambi le stesse righe e la console le
* mostrerebbe due volte.
*/
public function test_la_zona_pericolosa_racconta_solo_i_propri_azzeramenti(): void
{
$stato = app(BackupOperationState::class);
$stato->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 4);
$stato->log('op-backup', 'Riga di un backup, non di un azzeramento.');
Livewire::test(ZonaPericolosa::class)->assertNotDispatched('console-operazione');
Livewire::test(BackupManager::class)->assertDispatched('console-operazione');
}
/**
* Il difetto che questo test presidia: chi non possiede l'operazione polla
* comunque, e consumerebbe per primo il segno "download già inviato" —
* spegnendo l'evento prima che il proprietario lo emetta. L'archivio
* sarebbe pronto e non arriverebbe a nessuno, e per l'azzeramento è pure il
* passo che sblocca la cancellazione.
*/
public function test_il_download_lo_emette_solo_chi_possiede_l_operazione(): void
{
$this->operazioneInAttesa(servito: false, downloadInviato: false);
Livewire::test(BackupManager::class)->assertNotDispatched('scarica-backup');
$this->assertFalse(
app(BackupOperationState::class)->get('op-reset')['result']['download_inviato'] ?? false,
'Il componente che non possiede l\'operazione non deve consumare il segno del download.'
);
Livewire::test(ZonaPericolosa::class)->assertDispatched('scarica-backup');
}
public function test_il_download_si_emette_una_volta_sola(): void
{
$this->operazioneInAttesa(servito: false, downloadInviato: false);
Livewire::test(ZonaPericolosa::class)->assertDispatched('scarica-backup');
Livewire::test(ZonaPericolosa::class)->assertNotDispatched('scarica-backup');
}
// ─── helper ─────────────────────────────────────────────────
/**
* Azzeramento fermo al secondo tempo: l'archivio esiste e, salvo diversa
* indicazione, risulta spedito.
*/
private function operazioneInAttesa(bool $servito = true, bool $downloadInviato = true): void
{
$stato = app(BackupOperationState::class);
$stato->initialize('op-reset', BackupOperationState::TIPO_RESET, 'Compressione', 3);
$stato->markAttesaDownload('op-reset', [
'file' => '/tmp/backup.zip',
'nome_download' => 'backup.zip',
'dimensione' => 1024,
'servito_at' => $servito ? now()->toDateTimeString() : null,
'download_inviato' => $downloadInviato,
]);
}
}

24
tests/TestCase.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
/**
* L'applicazione si costruisce dallo stesso bootstrap/app.php che usa il
* runtime: middleware, provider e rotte sono quelli veri, cosi' un test non
* puo' passare grazie a una configurazione che esiste solo nei test.
*/
public function createApplication(): Application
{
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
}

View File

@@ -0,0 +1,423 @@
<?php
namespace Tests\Unit;
use App\Services\BackupArchive;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Mockery;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionMethod;
use RuntimeException;
use Tests\TestCase;
use ZipArchive;
/**
* Il pacchetto di backup e' l'unico file di cui non ci si accorge che era rotto
* finche' non serve: se sbaglia, sbaglia in silenzio e lo si scopre il giorno
* del ripristino. Da qui la scelta di cominciare i test da qui.
*
* Non serve database: tutto gira su Storage::fake('public').
*/
class BackupArchiveTest extends TestCase
{
private BackupArchive $archivio;
/** Cartella dei file .zip prodotti dai test, ripulita a ogni giro. */
private string $lavoro;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
$this->archivio = new BackupArchive();
$this->lavoro = storage_path('app/testing-backup');
$this->archivio->deleteDirectory($this->lavoro);
$this->archivio->deleteDirectory(storage_path('app/backup-restore'));
mkdir($this->lavoro, 0775, true);
}
protected function tearDown(): void
{
// extract() estrae in storage/app/backup-restore/<uuid>: e' un percorso
// reale, non il disco finto, quindi va ripulito a mano.
$this->archivio->deleteDirectory($this->lavoro);
$this->archivio->deleteDirectory(storage_path('app/backup-restore'));
parent::tearDown();
}
// ─── create() ───────────────────────────────────────────────
public function test_il_pacchetto_creato_si_riestrae_identico(): void
{
Storage::disk('public')->put('territori-pdf/1.pdf', 'contenuto-uno');
Storage::disk('public')->put('territori-thumbnails/1.png', 'miniatura-uno');
Storage::disk('public')->put('logo.svg', 'logo');
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $destinazione);
$this->assertFileExists($destinazione);
$esito = $this->archivio->extract($destinazione);
$this->assertSame('<dataset/>', $esito['xml']);
$this->assertSame(3, $esito['files']);
$this->assertStringEqualsFile($esito['staging'] . '/territori-pdf/1.pdf', 'contenuto-uno');
$this->assertStringEqualsFile($esito['staging'] . '/territori-thumbnails/1.png', 'miniatura-uno');
$this->assertStringEqualsFile($esito['staging'] . '/logo.svg', 'logo');
}
public function test_il_manifest_dichiara_versione_di_formato_e_conteggio(): void
{
Storage::disk('public')->put('territori-pdf/1.pdf', 'x');
Storage::disk('public')->put('territori-pdf/2.pdf', 'y');
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $destinazione);
$zip = new ZipArchive();
$this->assertTrue($zip->open($destinazione) === true);
$manifest = json_decode($zip->getFromName('manifest.json'), true);
$zip->close();
$this->assertSame(BackupArchive::FORMAT_VERSION, $manifest['format_version']);
$this->assertSame('TerManager2', $manifest['application']);
$this->assertSame(2, $manifest['files_count']);
}
public function test_il_pacchetto_di_un_installazione_senza_file_contiene_comunque_i_dati(): void
{
$destinazione = $this->lavoro . '/backup.zip';
$this->archivio->create('<dataset/>', $destinazione);
$esito = $this->archivio->extract($destinazione);
$this->assertSame('<dataset/>', $esito['xml']);
$this->assertSame(0, $esito['files']);
}
/**
* La barra conta unita' di lavoro finite, non tempo: l'ultimo avanzamento
* deve coincidere col totale, altrimenti resterebbe sotto il 100% a
* operazione conclusa.
*/
public function test_l_avanzamento_arriva_al_totale(): void
{
for ($i = 1; $i <= 25; $i++) {
Storage::disk('public')->put("territori-pdf/{$i}.pdf", "file-{$i}");
}
$avanzamenti = [];
$this->archivio->create(
'<dataset/>',
$this->lavoro . '/backup.zip',
function (int $fatti, int $totale) use (&$avanzamenti) {
$avanzamenti[] = [$fatti, $totale];
}
);
$this->assertNotEmpty($avanzamenti);
$this->assertSame([25, 25], end($avanzamenti));
$precedente = 0;
foreach ($avanzamenti as [$fatti, $totale]) {
$this->assertSame(25, $totale);
$this->assertGreaterThanOrEqual($precedente, $fatti);
$precedente = $fatti;
}
}
/**
* Il bug che questo test presidia: addFile() torna false se il file e'
* sparito o e' illeggibile, e l'archivio finirebbe con un buco di cui
* nessuno si accorge fino al ripristino. Meglio nessun backup che un backup
* che sembra buono, quindi il file va anche rimosso.
*
* Il messaggio si verifica per esteso perche' e' la parte che si perde per
* prima: addFile() emette un warning di PHP prima di restituire false, e
* senza la soppressione in BackupArchive l'amministratore leggerebbe
* "ZipArchive::addFile(): No such file or directory" al posto della frase
* che dice quale file e cosa controllare.
*/
public function test_un_file_illeggibile_fa_fallire_il_backup_senza_lasciare_l_archivio(): void
{
$disco = Mockery::mock(FilesystemAdapter::class);
$disco->shouldReceive('allFiles')->andReturn(['territori-pdf/sparito.pdf']);
$disco->shouldReceive('path')->andReturn($this->lavoro . '/questo-file-non-esiste.pdf');
Storage::set('public', $disco);
$destinazione = $this->lavoro . '/backup.zip';
try {
$this->archivio->create('<dataset/>', $destinazione);
$this->fail('create() avrebbe dovuto sollevare su un file non leggibile.');
} catch (RuntimeException $e) {
$this->assertStringContainsString('territori-pdf/sparito.pdf', $e->getMessage());
$this->assertStringContainsString('non e\' leggibile', $e->getMessage());
}
$this->assertFileDoesNotExist(
$destinazione,
'Un backup incompleto non deve restare sul disco: sembrerebbe utilizzabile.'
);
}
// ─── extract(): rifiuti ─────────────────────────────────────
public function test_l_archivio_senza_manifest_non_e_riconosciuto(): void
{
$zip = $this->zipCon(['data.xml' => '<dataset/>']);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/manifest\.json/');
$this->archivio->extract($zip);
}
public function test_l_archivio_senza_data_xml_e_incompleto(): void
{
$zip = $this->zipCon(['manifest.json' => $this->manifest()]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/data\.xml/');
$this->archivio->extract($zip);
}
public function test_un_data_xml_vuoto_vale_come_mancante(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(),
'data.xml' => " \n ",
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/data\.xml/');
$this->archivio->extract($zip);
}
/**
* Un pacchetto prodotto da una versione futura si rifiuta invece di
* ripristinarlo a meta': i campi che non si sanno leggere andrebbero persi
* in silenzio.
*/
public function test_un_formato_piu_recente_viene_rifiutato(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(BackupArchive::FORMAT_VERSION + 1),
'data.xml' => '<dataset/>',
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/versione piu\'? recente/');
$this->archivio->extract($zip);
}
public function test_un_manifest_illeggibile_viene_rifiutato(): void
{
$zip = $this->zipCon([
'manifest.json' => 'non-e-json',
'data.xml' => '<dataset/>',
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/manifest/');
$this->archivio->extract($zip);
}
public function test_un_entry_con_percorso_assoluto_viene_rifiutata(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(),
'data.xml' => '<dataset/>',
'storage//tmp/malevolo.txt' => 'fuori',
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/non ammesso/');
$this->archivio->extract($zip);
}
/**
* Lo staging va rimosso anche quando l'estrazione fallisce a meta': i file
* gia' estratti resterebbero altrimenti sul volume, senza che nulla li
* mostri e senza che nulla li cancelli.
*/
public function test_lo_staging_non_sopravvive_a_un_estrazione_fallita(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(),
'data.xml' => '<dataset/>',
'storage/territori-pdf/buono.pdf' => 'ok',
'storage//tmp/malevolo.txt' => 'fuori',
]);
try {
$this->archivio->extract($zip);
$this->fail('extract() avrebbe dovuto sollevare sul percorso non ammesso.');
} catch (RuntimeException) {
// atteso
}
$radice = storage_path('app/backup-restore');
$this->assertTrue(
! is_dir($radice) || glob($radice . '/*') === [],
'La cartella di staging e\' rimasta popolata dopo un ripristino fallito.'
);
}
public function test_le_entry_fuori_da_storage_vengono_ignorate(): void
{
$zip = $this->zipCon([
'manifest.json' => $this->manifest(),
'data.xml' => '<dataset/>',
'storage/territori-pdf/1.pdf' => 'buono',
'appunti/README.txt' => 'roba estranea al pacchetto',
]);
$esito = $this->archivio->extract($zip);
$this->assertSame(1, $esito['files']);
$this->assertFileExists($esito['staging'] . '/territori-pdf/1.pdf');
$this->assertFileDoesNotExist($esito['staging'] . '/README.txt');
$this->assertDirectoryDoesNotExist($esito['staging'] . '/appunti');
}
// ─── safeRelativePath(): la regola dello zip slip ───────────
#[DataProvider('percorsiNonAmmessi')]
public function test_i_percorsi_che_escono_dalla_destinazione_sono_rifiutati(string $percorso): void
{
$this->assertNull($this->safeRelativePath($percorso));
}
public static function percorsiNonAmmessi(): array
{
return [
'risalita semplice' => ['../fuori.txt'],
'risalita in mezzo' => ['territori-pdf/../../fuori.txt'],
'risalita con backslash' => ['..\\fuori.txt'],
'percorso assoluto' => ['/etc/passwd'],
'unita windows' => ['C:/Windows/system.ini'],
'vuoto' => [''],
'solo spazi' => [' '],
'solo separatori' => ['///'],
'solo punto' => ['.'],
];
}
#[DataProvider('percorsiAmmessi')]
public function test_i_percorsi_interni_vengono_normalizzati(string $percorso, string $atteso): void
{
$this->assertSame($atteso, $this->safeRelativePath($percorso));
}
public static function percorsiAmmessi(): array
{
return [
'semplice' => ['logo.svg', 'logo.svg'],
'annidato' => ['territori-pdf/1.pdf', 'territori-pdf/1.pdf'],
'backslash convertiti' => ['territori-pdf\\1.pdf', 'territori-pdf/1.pdf'],
'separatori doppi' => ['territori-pdf//1.pdf', 'territori-pdf/1.pdf'],
'punto corrente' => ['./territori-pdf/./1.pdf', 'territori-pdf/1.pdf'],
];
}
// ─── restoreFiles() / svuotaDiscoPubblico() ─────────────────
/**
* La sostituzione del disco e' totale, non un merge: i percorsi vivono
* nelle righe che il ripristino ha appena riscritto, quindi un file
* precedente non piu' citato resterebbe orfano per sempre.
*/
public function test_il_ripristino_sostituisce_il_disco_invece_di_fondersi(): void
{
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'da buttare');
Storage::disk('public')->put('logo.svg', 'logo vecchio');
$staging = $this->lavoro . '/staging';
mkdir($staging . '/territori-pdf', 0775, true);
file_put_contents($staging . '/territori-pdf/nuovo.pdf', 'da tenere');
file_put_contents($staging . '/logo.svg', 'logo nuovo');
$ripristinati = $this->archivio->restoreFiles($staging);
$this->assertSame(2, $ripristinati);
$this->assertSame(
['logo.svg', 'territori-pdf/nuovo.pdf'],
collect(Storage::disk('public')->allFiles())->sort()->values()->all()
);
$this->assertSame('logo nuovo', Storage::disk('public')->get('logo.svg'));
}
public function test_lo_svuotamento_conta_i_file_rimossi_e_lascia_il_disco_vuoto(): void
{
Storage::disk('public')->put('territori-pdf/1.pdf', 'a');
Storage::disk('public')->put('territori-pdf/2.pdf', 'b');
Storage::disk('public')->put('logo.svg', 'c');
$rimossi = $this->archivio->svuotaDiscoPubblico();
$this->assertSame(3, $rimossi);
$this->assertSame([], Storage::disk('public')->allFiles());
}
public function test_lo_svuotamento_di_un_disco_gia_vuoto_non_e_un_errore(): void
{
$this->assertSame(0, $this->archivio->svuotaDiscoPubblico());
}
// ─── helper ─────────────────────────────────────────────────
/**
* @param array<string, string> $entries
*/
private function zipCon(array $entries): string
{
$path = $this->lavoro . '/costruito-' . count($entries) . '-' . md5(serialize($entries)) . '.zip';
$zip = new ZipArchive();
$this->assertTrue(
$zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true,
'Impossibile preparare lo zip di prova.'
);
foreach ($entries as $nome => $contenuto) {
$zip->addFromString($nome, $contenuto);
}
$zip->close();
return $path;
}
private function manifest(?int $formatVersion = null): string
{
return json_encode([
'format_version' => $formatVersion ?? BackupArchive::FORMAT_VERSION,
'application' => 'TerManager2',
'generated_at' => '2026-07-30T12:00:00+02:00',
'app_url' => 'http://localhost',
'files_count' => 0,
]);
}
private function safeRelativePath(string $percorso): ?string
{
$metodo = new ReflectionMethod(BackupArchive::class, 'safeRelativePath');
return $metodo->invoke($this->archivio, $percorso);
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace Tests\Unit;
use App\Services\BackupOperationState;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* Il "posto unico": backup, ripristino e azzeramento lavorano tutti sullo
* stesso database e sullo stesso disco, quindi due in parallelo non si
* rallentano soltanto, si sovrascrivono. Qui si verifica che il posto venga
* occupato, rilasciato e — soprattutto — che non resti occupato per sempre.
*
* Gira su cache 'array' (phpunit.xml): nessun Redis in mezzo.
*/
class BackupOperationStateTest extends TestCase
{
private const CHIAVE_CORRENTE = 'backup-op:corrente';
private BackupOperationState $stato;
protected function setUp(): void
{
parent::setUp();
$this->stato = new BackupOperationState();
}
// ─── Il posto unico ─────────────────────────────────────────
public function test_senza_operazioni_il_posto_e_libero(): void
{
$this->assertNull($this->stato->corrente());
}
public function test_avviare_un_operazione_occupa_il_posto(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 10);
$this->assertSame('op-1', $this->stato->corrente());
}
public function test_il_completamento_libera_il_posto(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 10);
$this->stato->markCompleted('op-1', ['file' => 'backup.zip']);
$this->assertNull($this->stato->corrente());
$this->assertSame('completed', $this->stato->get('op-1')['status']);
}
public function test_il_fallimento_libera_il_posto(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESTORE, 'Estrazione', 10);
$this->stato->markFailed('op-1', 'disco pieno');
$this->assertNull($this->stato->corrente());
$finale = $this->stato->get('op-1');
$this->assertSame('failed', $finale['status']);
$this->assertSame('disco pieno', $finale['error']);
$this->assertStringContainsString('ERRORE: disco pieno', end($finale['logs']));
}
/**
* Il caso che il TTL da solo non copre: lo stato dell'operazione e' scaduto
* ma il puntatore no. Senza questa pulizia il posto resterebbe occupato da
* un'operazione che non esiste piu'.
*/
public function test_un_puntatore_orfano_non_tiene_occupato_il_posto(): void
{
Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600);
$this->assertNull($this->stato->corrente());
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE), 'Il puntatore orfano va anche rimosso.');
}
public function test_un_puntatore_a_un_operazione_gia_chiusa_non_tiene_occupato_il_posto(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESET, 'Backup preventivo', 5);
// Chiusura "a mano", come se il worker fosse morto dopo aver scritto lo
// stato ma prima di rilasciare il posto.
$this->stato->update('op-1', function (array $s) {
$s['status'] = 'completed';
return $s;
});
Cache::put(self::CHIAVE_CORRENTE, 'op-1', 3600);
$this->assertNull($this->stato->corrente());
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE));
}
/**
* Senza il confronto sull'id, la chiusura tardiva di un'operazione vecchia
* sbloccherebbe quella appena avviata da qualcun altro — cioe' proprio il
* parallelismo che il posto unico esiste per impedire.
*/
public function test_un_operazione_vecchia_che_si_chiude_non_libera_il_posto_della_nuova(): void
{
$this->stato->initialize('op-vecchia', BackupOperationState::TIPO_BACKUP, 'Preparazione', 3);
$this->stato->initialize('op-nuova', BackupOperationState::TIPO_RESTORE, 'Estrazione', 3);
$this->stato->markCompleted('op-vecchia');
$this->assertSame('op-nuova', $this->stato->corrente());
}
/**
* L'azzeramento si ferma in attesa del download e riparte da un secondo
* clic: in quella pausa il posto deve restare occupato, altrimenti un'altra
* operazione potrebbe infilarsi fra il backup preventivo e la cancellazione.
*/
public function test_l_attesa_del_download_tiene_il_posto_occupato(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESET, 'Backup preventivo', 4);
$this->stato->markAttesaDownload('op-1', ['file' => 'backup.zip']);
$this->assertSame('op-1', $this->stato->corrente());
$corrente = $this->stato->get('op-1');
$this->assertSame(BackupOperationState::FASE_ATTESA_DOWNLOAD, $corrente['fase']);
$this->assertSame('running', $corrente['status']);
$this->assertSame($corrente['total'], $corrente['processed'], 'La barra deve risultare piena.');
}
// ─── Avanzamento ────────────────────────────────────────────
public function test_il_cambio_di_fase_riazzera_l_avanzamento_sul_nuovo_totale(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESTORE, 'Estrazione', 100);
$this->stato->avanza('op-1', 80);
$this->stato->fase('op-1', 'Scrittura file', 7);
$corrente = $this->stato->get('op-1');
$this->assertSame('Scrittura file', $corrente['fase']);
$this->assertSame(7, $corrente['total']);
$this->assertSame(0, $corrente['processed']);
}
public function test_il_completamento_porta_la_barra_al_totale(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Compressione', 12);
$this->stato->avanza('op-1', 9);
$this->stato->markCompleted('op-1');
$this->assertSame(12, $this->stato->get('op-1')['processed']);
}
public function test_il_risultato_del_completamento_non_cancella_quello_gia_registrato(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_RESET, 'Backup preventivo', 1);
$this->stato->markAttesaDownload('op-1', ['file' => 'backup.zip']);
$this->stato->markCompleted('op-1', ['tabelle' => 9]);
$risultato = $this->stato->get('op-1')['result'];
$this->assertSame('backup.zip', $risultato['file']);
$this->assertSame(9, $risultato['tabelle']);
}
// ─── Log ────────────────────────────────────────────────────
public function test_la_coda_dei_log_tiene_solo_le_ultime_righe(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 0);
for ($i = 1; $i <= 75; $i++) {
$this->stato->log('op-1', "riga {$i}");
}
$logs = $this->stato->get('op-1')['logs'];
// Lo stato viaggia per intero a ogni poll: una coda illimitata
// gonfierebbe ogni risposta di un'operazione lunga.
$this->assertCount(60, $logs);
$this->assertStringContainsString('riga 75', end($logs));
$this->assertStringContainsString('riga 16', $logs[0]);
}
// ─── Bordi ──────────────────────────────────────────────────
public function test_aggiornare_un_operazione_inesistente_non_la_crea(): void
{
$this->assertNull($this->stato->update('mai-esistita', fn (array $s) => $s));
$this->assertNull($this->stato->get('mai-esistita'));
}
public function test_forget_rimuove_lo_stato(): void
{
$this->stato->initialize('op-1', BackupOperationState::TIPO_BACKUP, 'Preparazione', 1);
$this->stato->forget('op-1');
$this->assertNull($this->stato->get('op-1'));
$this->assertNull($this->stato->corrente(), 'Senza stato il posto risulta libero.');
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Guardia contro il ritorno dei vocabolari di stile paralleli.
*
* L'applicazione aveva quattro modi di scrivere un pulsante: style= inline con
* i colori a mano (piu' onmouseover per l'hover), catene di utility Tailwind
* ripetute a memoria, .btn-action* in sei tinte e .btn-primary-* in due. La
* stessa azione cambiava aspetto da una pagina all'altra e nessuno di quei
* pulsanti aveva uno stato di focus: chi naviga da tastiera non vedeva dove si
* trovava.
*
* Adesso c'e' <x-btn> e basta. Questo test non giudica l'estetica: verifica che
* i modi vecchi non rientrino dalla finestra, cosa che succede da sola alla
* prossima pagina scritta copiando una vecchia.
*
* Legge i sorgenti, quindi non serve ne' applicazione ne' database.
*/
class StileUnificatoTest extends TestCase
{
/** Viste dell'applicazione: quelle pubblicate da pacchetti terzi non ci riguardano. */
private const ESCLUSI = [
'resources/views/vendor/',
// I template dei PDF non sono pagine: li impagina dompdf, che di
// Tailwind e delle nostre classi non sa niente.
'resources/views/pdf/',
];
/**
* @return array<int, string> percorsi relativi delle viste da controllare
*/
private static function viste(): array
{
$radice = dirname(__DIR__, 2);
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($radice . '/resources/views', \FilesystemIterator::SKIP_DOTS)
);
$viste = [];
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile() || ! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
$relativo = str_replace('\\', '/', substr($file->getPathname(), strlen($radice) + 1));
foreach (self::ESCLUSI as $escluso) {
if (str_starts_with($relativo, $escluso)) {
continue 2;
}
}
$viste[] = $relativo;
}
sort($viste);
return $viste;
}
public static function vistePerProvider(): array
{
return array_map(fn (string $vista) => [$vista], self::viste());
}
private function contenuto(string $vista): string
{
return (string) file_get_contents(dirname(__DIR__, 2) . '/' . $vista);
}
#[DataProvider('vistePerProvider')]
public function test_nessuna_vista_usa_le_vecchie_classi_dei_pulsanti(string $vista): void
{
$html = $this->rimuoviCommenti($this->contenuto($vista));
$this->assertStringNotContainsString('btn-action', $html, "{$vista}: .btn-action non esiste piu', usa <x-btn>.");
$this->assertStringNotContainsString('btn-primary-', $html, "{$vista}: .btn-primary-* non esiste piu', usa <x-btn>.");
}
/**
* onmouseover/onmouseout servivano a simulare :hover su pulsanti con lo
* stile inline. Non funzionano da tastiera e non lasciano traccia nel
* foglio di stile: se ricompaiono, e' ricomparso anche il pulsante scritto
* a mano.
*/
#[DataProvider('vistePerProvider')]
public function test_nessuna_vista_simula_l_hover_con_javascript_inline(string $vista): void
{
$html = $this->rimuoviCommenti($this->contenuto($vista));
$this->assertStringNotContainsString('onmouseover', $html, "{$vista}: l'hover si scrive nel CSS, non in un attributo.");
$this->assertStringNotContainsString('onmouseout', $html, "{$vista}: l'hover si scrive nel CSS, non in un attributo.");
}
/**
* Un <button> o un <a> con le utility di riempimento addosso e' un pulsante
* scritto a mano: gli manca lo stato di focus e prima o poi diverge dagli
* altri. Il controllo guarda solo il tag di apertura, quindi non inciampa
* nelle celle di tabella o nei contenitori che usano le stesse utility.
*/
#[DataProvider('vistePerProvider')]
public function test_nessun_pulsante_e_riscritto_a_mano(string $vista): void
{
$html = $this->rimuoviCommenti($this->contenuto($vista));
preg_match_all('/<(?:button|a)\s[^>]*>/i', $html, $trovati);
// Molte viste non hanno pulsanti: senza questa riga il test risulta
// "rischioso" (nessuna asserzione) invece che superato.
$this->assertIsArray($trovati[0]);
foreach ($trovati[0] as $tag) {
$riempimento = preg_match('/\bbg-(?:indigo|red|green|emerald|amber|blue|gray)-\d{3}\b/', $tag) === 1;
$sfondoInline = preg_match('/style="[^"]*background\s*:/i', $tag) === 1;
$this->assertFalse(
$riempimento || $sfondoInline,
"{$vista}: pulsante con lo sfondo scritto a mano, usa <x-btn>.\n" . trim($tag)
);
}
}
/**
* Il componente e' l'unico posto in cui le classi .tm-btn si scrivono: se
* compaiono in una vista, qualcuno ha saltato <x-btn> e si e' portato
* dietro solo l'aspetto, non le regole.
*/
#[DataProvider('vistePerProvider')]
public function test_le_classi_del_sistema_non_si_scrivono_a_mano(string $vista): void
{
if ($vista === 'resources/views/components/btn.blade.php') {
$this->assertTrue(true, 'E\' il componente stesso.');
return;
}
$html = $this->rimuoviCommenti($this->contenuto($vista));
$this->assertDoesNotMatchRegularExpression(
'/class="[^"]*\btm-btn\b/',
$html,
"{$vista}: le classi tm-btn le mette <x-btn>, non la vista."
);
}
/**
* I commenti Blade spiegano proprio le regole che questi test verificano, e
* contengono quindi i nomi vietati. Toglierli evita di dover scrivere le
* spiegazioni a mezze parole per non far fallire il controllo.
*/
private function rimuoviCommenti(string $html): string
{
return (string) preg_replace('/\{\{--.*?--\}\}/s', '', $html);
}
}