++ fix e controlli
This commit is contained in:
@@ -44,3 +44,4 @@ docker-compose.override.yml
|
||||
.idea
|
||||
.vscode
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@
|
||||
/public/storage
|
||||
/public/build/
|
||||
/.phpunit.result.cache
|
||||
/.phpunit.cache/
|
||||
/.idea/
|
||||
/.vscode/
|
||||
*.swp
|
||||
|
||||
380
HANDOFF.md
380
HANDOFF.md
@@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
|
||||
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
||||
può dare: perché le cose sono come sono, e cosa resta aperto.
|
||||
|
||||
Ultimo aggiornamento: **29 luglio 2026**.
|
||||
Ultimo aggiornamento: **30 luglio 2026**.
|
||||
|
||||
---
|
||||
|
||||
@@ -22,18 +22,36 @@ redeploy, login, tutte le pagine, code e scheduler.
|
||||
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
|
||||
| Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale |
|
||||
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
|
||||
| Test automatici | ❌ **assenti** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
|
||||
| Test automatici | ⚠️ **impalcatura pronta, 57 test** — copertura ancora parziale |
|
||||
|
||||
**Punto in cui ci si è fermati (29/07/2026).** Su `develop` è pubblicato tutto:
|
||||
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto:
|
||||
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre
|
||||
di avanzamento reali e console di log (ultimo commit `f909791`, deploy
|
||||
verificato confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è
|
||||
impostato **solo** su develop; la produzione non è stata toccata e resta sul suo
|
||||
deploy precedente, senza quella variabile.
|
||||
di avanzamento reali e console di log (commit `f909791`, deploy verificato
|
||||
confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo**
|
||||
su develop; la produzione non è stata toccata e resta sul suo deploy
|
||||
precedente, senza quella variabile.
|
||||
|
||||
Il ripristino dall'interfaccia resta però impraticabile per pacchetti grandi
|
||||
finché non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta
|
||||
aperto*.
|
||||
Il 30/07 sono state fatte due cose.
|
||||
|
||||
**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando,
|
||||
senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione
|
||||
della logica di business — vedi *Cosa resta aperto*. Non fanno parte
|
||||
dell'immagine di produzione e non girano su un deploy: sono uno strumento
|
||||
locale.
|
||||
|
||||
**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con
|
||||
un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con
|
||||
gli stati di focus, che prima non c'erano), e **una sola console** per tutte le
|
||||
operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di
|
||||
log sparsi. L'azzeramento è diventato un componente a sé per poter stare in
|
||||
fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel
|
||||
browser — apertura automatica della console, scorrimento, lampeggio delle zone —
|
||||
non è stato provato**, perché richiede un browser: è la prima cosa da guardare
|
||||
aprendo la pagina.
|
||||
|
||||
Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché
|
||||
non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto*
|
||||
e l'unica che richiede una decisione, non del lavoro.
|
||||
|
||||
---
|
||||
|
||||
@@ -556,6 +574,228 @@ Tre scelte non ovvie:
|
||||
hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i
|
||||
timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità.
|
||||
|
||||
### La pagina "Backup e import": cinque zone e una sola console
|
||||
|
||||
La pagina era cresciuta per accumulo: sette riquadri senza un ordine dichiarato,
|
||||
la zona pericolosa a metà strada, **cinque** aree di log separate e pulsanti che
|
||||
ogni sezione ridefiniva per conto proprio. È stata rimessa in ordine attorno a
|
||||
tre regole.
|
||||
|
||||
**Cinque zone numerate, in ordine di quanto sono definitive.** `1 Backup`,
|
||||
`2 Ripristino`, `3 Import PDF`, `4 XML`, `5 Zona pericolosa`. Ognuna ha
|
||||
un'ancora (`id="zona-…"`) e l'indice appiccicato in cima ci salta sopra. Un test
|
||||
verifica che le cinque ancore esistano *e che siano in quell'ordine nel
|
||||
sorgente*: l'ordine è la sostanza della sistemazione, non un dettaglio estetico.
|
||||
|
||||
**Il "focus" della pagina.** Premuto un pulsante — o scelta una voce
|
||||
dell'indice — la pagina si sposta sulla zona di riferimento e la fa lampeggiare
|
||||
(`window.tmFocusZona`). Il focus da tastiera si sposta **solo** quando la
|
||||
richiesta è di navigazione: spostarlo anche a ogni azione strapperebbe il
|
||||
cursore da sotto le dita di chi ha appena premuto. Il primo comando di ogni zona
|
||||
è marcato `data-zona-focus` ed è quello che lo riceve.
|
||||
|
||||
**Una sola console, agganciata in fondo alla finestra.** Prima la stessa
|
||||
operazione si raccontava in tre punti diversi: un ripristino cominciava nel
|
||||
riquadro "Browser", proseguiva in quello "Server" e aveva un terzo "Dettaglio"
|
||||
richiudibile nel pannello di avanzamento, in cima. Per capire dove si era
|
||||
fermato bisognava guardarli tutti. Adesso c'è una cronologia sola, in ordine di
|
||||
tempo, con la sorgente accanto a ogni riga, e la finestra è *fissa*: quasi tutti
|
||||
i modi in cui un caricamento muore sono muti, e l'unico indizio è dove si è
|
||||
interrotto il flusso di righe — un log che esce dallo schermo appena si scorre
|
||||
non serve proprio nel momento in cui servirebbe.
|
||||
|
||||
Dettagli non ovvi della console:
|
||||
|
||||
- **Le righe del server viaggiano già scremate.** Il conto di quante ne sono
|
||||
state spedite lo tiene il componente Livewire (`logSpediti`), non il browser:
|
||||
la coda intera dentro ogni risposta significherebbe sessanta righe poco meno
|
||||
di due volte al secondo, e per l'import PDF — la cui coda non ha tetto —
|
||||
l'intera cronologia a ogni poll.
|
||||
|
||||
- **Per ogni operazione c'è un solo componente che la racconta**
|
||||
(`SegueOperazioneBackup::tipiSeguiti()`). Backup e zona pericolosa guardano lo
|
||||
stesso stato condiviso: senza un proprietario dichiarato spedirebbero entrambi
|
||||
le stesse righe, e la console le mostrerebbe due volte.
|
||||
|
||||
- **Il download automatico si ascolta in un posto solo.** `scarica-backup` è un
|
||||
evento su `window`: due componenti che lo ascoltassero entrambi assegnerebbero
|
||||
`window.location.href` due volte, e il pacchetto verrebbe scaricato due volte.
|
||||
L'ascolto sta nella console, che è l'unico elemento sempre presente. Per lo
|
||||
stesso motivo `scaricaOra()` esce subito se non è il proprietario a chiamarlo:
|
||||
il segno "download già inviato" sta nello stato condiviso, quindi chi segue
|
||||
l'operazione di riflesso lo consumerebbe per primo — l'archivio sarebbe pronto
|
||||
e non arriverebbe a nessuno, e per l'azzeramento è pure il passo che sblocca
|
||||
la cancellazione.
|
||||
|
||||
- **Lo scorrimento automatico si sospende se si torna indietro a leggere.**
|
||||
Trascinare in fondo mentre si sta guardando una riga di dieci secondi prima è
|
||||
il modo più rapido di rendere inutile un log che scorre. Compare invece
|
||||
"Torna in fondo".
|
||||
|
||||
- **Il pannello è a posizione fissa, quindi la pagina ha uno spaziatore in
|
||||
fondo.** Senza, coprirebbe l'ultima zona — che è quella pericolosa.
|
||||
|
||||
- **Il JavaScript della console sta in un `<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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
90
README.md
90
README.md
@@ -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
|
||||
|
||||
294
app/Livewire/Concerns/SegueOperazioneBackup.php
Normal file
294
app/Livewire/Concerns/SegueOperazioneBackup.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
251
app/Livewire/Settings/ZonaPericolosa.php
Normal file
251
app/Livewire/Settings/ZonaPericolosa.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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')
|
||||
->whereNotNull('assignment_link_ttl_hours')
|
||||
->update([
|
||||
'assignment_link_ttl_hours' => DB::raw('assignment_link_ttl_hours * 720'),
|
||||
]);
|
||||
$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' => $conversione((int) $riga->assignment_link_ttl_hours),
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
66
phpunit.xml
Normal 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>
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
61
resources/views/components/btn.blade.php
Normal file
61
resources/views/components/btn.blade.php
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
<div x-data="{ formOpen: true }">
|
||||
|
||||
{{-- Toggle form --}}
|
||||
<button type="button"
|
||||
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-btn variante="primary" class="mb-4"
|
||||
x-on:click="formOpen = !formOpen"
|
||||
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-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>
|
||||
<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) })"
|
||||
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"
|
||||
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' }}">
|
||||
{{-- 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 }})"
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
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')
|
||||
— {{ $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’archivio è pronto e il download è partito.</strong>
|
||||
Non è stato cancellato ancora nulla: l’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’archivio, non può sapere se è arrivato intero: quella verifica tocca a te,
|
||||
ed è l’ultima possibilità 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’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'] ?? '' }} —
|
||||
{{ 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 · Backup completo (.zip)</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Un unico pacchetto con <strong>tutti</strong> i dati dell’applicazione — utenti compresi —
|
||||
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
|
||||
E’ l’archivio da conservare per rimettere in piedi l’installazione da zero.
|
||||
È l’archivio da conservare per rimettere in piedi l’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’archivio si genera in background: puoi seguire l’avanzamento qui sopra.</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-btn>
|
||||
|
||||
{{-- ─── Ripristino ─────────────────────────────────────────────── --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
{{-- 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'] ?? '' }} —
|
||||
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($occupato)
|
||||
<span class="text-xs text-gray-500">
|
||||
Un’operazione è già in corso: l’avanzamento è nella console in fondo.
|
||||
</span>
|
||||
@else
|
||||
<span class="text-xs text-gray-500">L’archivio si genera in background e compare nella console in fondo.</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@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 · 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’ da
|
||||
contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono più 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;' : ''">
|
||||
Ripristina backup
|
||||
</button>
|
||||
@if($bloccato)
|
||||
<p class="text-xs text-gray-500">Un’altra operazione è in corso: attendi che finisca.</p>
|
||||
@endif
|
||||
<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
|
||||
</x-btn>
|
||||
@if($occupato)
|
||||
<span class="text-xs text-gray-500">Un’altra operazione è 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’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’è adesso.</li>
|
||||
<li>L’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 — come un’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’<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>
|
||||
|
||||
@@ -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 ↓
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
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>
|
||||
<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 }})"
|
||||
wire:confirm="Confermi la cancellazione dell'utente? I log verranno preservati.">
|
||||
Elimina
|
||||
</x-btn>
|
||||
</div>
|
||||
</td>
|
||||
@endif
|
||||
|
||||
@@ -1,375 +1,429 @@
|
||||
{{--
|
||||
Pagina "Backup e import".
|
||||
|
||||
Cinque zone numerate, in ordine di quanto sono definitive:
|
||||
|
||||
1 Backup completo (BackupManager)
|
||||
2 Ripristina da backup (BackupManager)
|
||||
3 Import PDF territori (qui)
|
||||
4 Dati in formato XML (qui)
|
||||
5 Zona pericolosa (ZonaPericolosa)
|
||||
|
||||
Ogni zona ha un'ancora, e l'indice in cima ci porta sopra evidenziandola:
|
||||
con cinque riquadri lunghi, arrivare "circa dalle parti giuste" scorrendo
|
||||
non basta a capire dove si e' atterrati.
|
||||
|
||||
Backup, ripristino e azzeramento stanno in componenti a se' perche' hanno
|
||||
uno stato proprio e un poll fitto che non deve far ri-renderizzare tutta la
|
||||
pagina — inclusa la tabella degli esiti dell'import, che puo' avere
|
||||
centinaia di righe.
|
||||
|
||||
L'avanzamento e i log di TUTTE le operazioni finiscono in una sola console,
|
||||
agganciata in fondo alla finestra. Qui dentro non ci sono piu' riquadri di
|
||||
log: ce n'erano cinque, e la stessa operazione si raccontava in tre punti
|
||||
diversi della pagina.
|
||||
--}}
|
||||
<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’applicazione, import PDF territori, conversione legacy SQL, import ed export XML.</p>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Backup completo dell’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 · Backup</button>
|
||||
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-ripristino', true)">2 · Ripristino</button>
|
||||
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-pdf', true)">3 · Import PDF</button>
|
||||
<button type="button" class="tm-nav-zona" x-on:click="window.tmFocusZona('zona-xml', true)">4 · XML</button>
|
||||
<button type="button" class="tm-nav-zona tm-nav-zona-pericolo" x-on:click="window.tmFocusZona('zona-azzeramento', true)">5 · 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"
|
||||
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);
|
||||
{{-- ═══ 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="{
|
||||
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' });
|
||||
},
|
||||
|
||||
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) {
|
||||
return;
|
||||
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');
|
||||
}
|
||||
},
|
||||
|
||||
this.zipProgress = Math.round((uploadEvent.loaded / uploadEvent.total) * 100);
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
this.zipUploading = false;
|
||||
|
||||
let payload = {};
|
||||
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText || '{}');
|
||||
} catch (error) {
|
||||
payload = {};
|
||||
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`);
|
||||
}
|
||||
},
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
this.zipProgress = 100;
|
||||
this.append('Archivio ricevuto. Reindirizzamento alla console import...');
|
||||
window.location = payload.redirect_url || window.location.href;
|
||||
return;
|
||||
}
|
||||
inviaZip(event) {
|
||||
const form = event.target;
|
||||
|
||||
const message = payload.message || (payload.errors && payload.errors.pdfZip ? payload.errors.pdfZip[0] : 'Errore durante il caricamento dello ZIP.');
|
||||
this.append(message);
|
||||
});
|
||||
this.zipInCorso = true;
|
||||
this.zipPercento = 0;
|
||||
this.$dispatch('console-apri');
|
||||
this.log(`Invio dell'archivio ZIP al server...`);
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
this.zipUploading = false;
|
||||
this.append('Errore di rete durante il caricamento dello ZIP.');
|
||||
});
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', form.action);
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
}"
|
||||
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>
|
||||
let ultimaTacca = -1;
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (!e.lengthComputable) {
|
||||
return;
|
||||
}
|
||||
this.zipPercento = Math.round((e.loaded / e.total) * 100);
|
||||
const tacca = Math.floor(this.zipPercento / 10);
|
||||
if (tacca > ultimaTacca) {
|
||||
ultimaTacca = tacca;
|
||||
this.log(`Caricamento ZIP ${this.zipPercento}%`);
|
||||
}
|
||||
});
|
||||
|
||||
<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
|
||||
</div>
|
||||
xhr.addEventListener('load', () => {
|
||||
this.zipInCorso = false;
|
||||
|
||||
<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>
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText || '{}');
|
||||
} catch (errore) {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
<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>
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
this.zipPercento = 100;
|
||||
this.log(`Archivio ricevuto. La pagina si ricarica sulla console dell'import.`, 'ok');
|
||||
window.location = payload.redirect_url || window.location.href;
|
||||
return;
|
||||
}
|
||||
|
||||
<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>
|
||||
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');
|
||||
});
|
||||
|
||||
<form action="{{ route('imports.territori.pdf-zip') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-4" @submit.prevent="submitZip">
|
||||
xhr.addEventListener('error', () => {
|
||||
this.zipInCorso = false;
|
||||
this.zipPercento = 0;
|
||||
this.log(`ERRORE DI RETE durante il caricamento dello ZIP.`, 'errore');
|
||||
});
|
||||
|
||||
xhr.send(new FormData(form));
|
||||
},
|
||||
}"
|
||||
x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { $dispatch('console-apri'); log('Caricamento della cartella avviato...'); }"
|
||||
x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { log('Caricamento cartella: ' + $event.detail.progress + '%'); }"
|
||||
x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { log('Cartella caricata. Preparazione import sul server...', 'ok'); }"
|
||||
x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { log('ERRORE durante il caricamento temporaneo dei file.', 'errore'); }">
|
||||
|
||||
<div>
|
||||
<h2 id="titolo-pdf" class="text-lg font-semibold text-gray-900">3 · Import PDF territori</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Associa i PDF ai territori già presenti e ne genera le miniature. Il nome del file può
|
||||
variare: basta che contenga il numero di un territorio esistente.
|
||||
</p>
|
||||
</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">
|
||||
|
||||
{{-- 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">— consigliato</span></h3>
|
||||
<p class="mt-1 text-xs text-gray-500">Un solo file: il server estrae i PDF da sé. È 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é.</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 class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="text-sm font-medium text-gray-800">Esito dell’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',
|
||||
'running' => 'bg-blue-100 text-blue-800',
|
||||
'completed' => 'bg-green-100 text-green-800',
|
||||
'failed' => 'bg-red-100 text-red-800',
|
||||
default => 'bg-gray-100 text-gray-700',
|
||||
} }}">
|
||||
{{ match($pdfImportStatus) {
|
||||
'queued' => 'In coda',
|
||||
'running' => 'In esecuzione',
|
||||
'completed' => 'Completato',
|
||||
'failed' => 'Fallito',
|
||||
default => 'Inattivo',
|
||||
} }}
|
||||
</span>
|
||||
</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',
|
||||
'running' => 'bg-blue-100 text-blue-800',
|
||||
'completed' => 'bg-green-100 text-green-800',
|
||||
'failed' => 'bg-red-100 text-red-800',
|
||||
default => 'bg-gray-100 text-gray-700',
|
||||
} }}">
|
||||
{{ match($pdfImportStatus) {
|
||||
'queued' => 'In coda',
|
||||
'running' => 'In esecuzione',
|
||||
'completed' => 'Completato',
|
||||
'failed' => 'Fallito',
|
||||
default => 'Inattivo',
|
||||
} }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if(!empty($pdfImportStats))
|
||||
<div class="mt-4 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>
|
||||
</div>
|
||||
<div class="rounded-lg bg-green-50 px-3 py-2 border border-green-100">
|
||||
<div class="text-xs text-green-700">Aggiornati</div>
|
||||
<div class="font-semibold text-green-900">{{ $pdfImportStats['updated'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-amber-50 px-3 py-2 border border-amber-100">
|
||||
<div class="text-xs text-amber-700">Saltati</div>
|
||||
<div class="font-semibold text-amber-900">{{ $pdfImportStats['skipped'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-red-50 px-3 py-2 border border-red-100">
|
||||
<div class="text-xs text-red-700">Errori</div>
|
||||
<div class="font-semibold text-red-900">{{ $pdfImportStats['errors'] ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($pdfImportStats))
|
||||
<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>
|
||||
</div>
|
||||
<div class="rounded-lg bg-green-50 px-3 py-2 border border-green-100">
|
||||
<div class="text-xs text-green-700">Aggiornati</div>
|
||||
<div class="font-semibold text-green-900">{{ $pdfImportStats['updated'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-amber-50 px-3 py-2 border border-amber-100">
|
||||
<div class="text-xs text-amber-700">Saltati</div>
|
||||
<div class="font-semibold text-amber-900">{{ $pdfImportStats['skipped'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-red-50 px-3 py-2 border border-red-100">
|
||||
<div class="text-xs text-red-700">Errori</div>
|
||||
<div class="font-semibold text-red-900">{{ $pdfImportStats['errors'] ?? 0 }}</div>
|
||||
</div>
|
||||
</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>
|
||||
@if(!empty($pdfImportIssues))
|
||||
<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">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">File</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Territori rilevati</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($pdfImportIssues as $issue)
|
||||
<tr>
|
||||
<td data-label="File" class="px-3 py-2 text-gray-900">{{ $issue['file'] ?? '-' }}</td>
|
||||
<td data-label="Motivo" class="px-3 py-2 text-gray-600">{{ $issue['message'] ?? '-' }}</td>
|
||||
<td data-label="Territori rilevati" class="px-3 py-2 text-gray-600">{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</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 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">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">File</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Territori rilevati</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($pdfImportIssues as $issue)
|
||||
<tr>
|
||||
<td data-label="File" class="px-3 py-2 text-gray-900">{{ $issue['file'] ?? '-' }}</td>
|
||||
<td data-label="Motivo" class="px-3 py-2 text-gray-600">{{ $issue['message'] ?? '-' }}</td>
|
||||
<td data-label="Territori rilevati" class="px-3 py-2 text-gray-600">{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</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">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>
|
||||
{{-- ═══ 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 · 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’XML i territori restano senza PDF né miniature.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data">
|
||||
@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
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
{{-- 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="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>
|
||||
|
||||
<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.');">
|
||||
@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
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@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="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>
|
||||
<div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div>
|
||||
<div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div>
|
||||
<div>Territori importati: <strong>{{ $importStats['territori_importati'] ?? 0 }}</strong></div>
|
||||
<div>Anni importati: <strong>{{ $importStats['anni_importati'] ?? 0 }}</strong></div>
|
||||
<div>Campagne importate: <strong>{{ $importStats['campagne_importate'] ?? 0 }}</strong></div>
|
||||
<div>Assegnazioni importate: <strong>{{ $importStats['assegnazioni_importate'] ?? 0 }}</strong></div>
|
||||
<div>Territori duplicati saltati: <strong>{{ $importStats['duplicate_territori'] ?? 0 }}</strong></div>
|
||||
<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>
|
||||
|
||||
@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;">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@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>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<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ò tentare di forzare le password offline.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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.
|
||||
{{-- 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>
|
||||
<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>
|
||||
|
||||
<x-btn variante="danger" type="submit">Importa XML</x-btn>
|
||||
</form>
|
||||
</div>
|
||||
</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>
|
||||
{{-- 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’import qui sopra. Non scrive niente nell’applicazione:
|
||||
produce solo un file da scaricare.
|
||||
</p>
|
||||
|
||||
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-3">
|
||||
@csrf
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<x-btn variante="secondary" type="submit">Converti in XML</x-btn>
|
||||
</form>
|
||||
</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="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’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>
|
||||
<div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div>
|
||||
<div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div>
|
||||
<div>Territori importati: <strong>{{ $importStats['territori_importati'] ?? 0 }}</strong></div>
|
||||
<div>Anni importati: <strong>{{ $importStats['anni_importati'] ?? 0 }}</strong></div>
|
||||
<div>Campagne importate: <strong>{{ $importStats['campagne_importate'] ?? 0 }}</strong></div>
|
||||
<div>Assegnazioni importate: <strong>{{ $importStats['assegnazioni_importate'] ?? 0 }}</strong></div>
|
||||
<div>Territori duplicati saltati: <strong>{{ $importStats['duplicate_territori'] ?? 0 }}</strong></div>
|
||||
<div>Assegnazioni saltate: <strong>{{ $importStats['assegnazioni_saltate'] ?? 0 }}</strong></div>
|
||||
</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="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 class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Entità</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 class="divide-y divide-gray-100">
|
||||
@foreach($importIssues as $issue)
|
||||
<tr>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
{{-- ═══ Zona 5 ════════════════════════════════════════════════════════ --}}
|
||||
<livewire:settings.zona-pericolosa />
|
||||
|
||||
{{-- Console unica, agganciata in fondo alla finestra. --}}
|
||||
@include('livewire.settings.partials.console-operazioni')
|
||||
</div>
|
||||
|
||||
174
resources/views/livewire/settings/zona-pericolosa.blade.php
Normal file
174
resources/views/livewire/settings/zona-pericolosa.blade.php
Normal 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 · 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’archivio è pronto e il download è partito.</strong>
|
||||
Non è stato cancellato ancora nulla: l’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’archivio, non può sapere se è arrivato intero: quella verifica tocca a te,
|
||||
ed è l’ultima possibilità 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’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’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’è adesso.</li>
|
||||
<li>L’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 — come un’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’<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’azzeramento può partire solo a operazione finita.
|
||||
</span>
|
||||
@elseif($mioAzzeramento && ! $attesa)
|
||||
<span class="text-xs text-gray-600">
|
||||
Azzeramento in corso: l’avanzamento è 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
149
tests/Feature/HomeMediaDurataTest.php
Normal file
149
tests/Feature/HomeMediaDurataTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
195
tests/Feature/PaginaBackupImportTest.php
Normal file
195
tests/Feature/PaginaBackupImportTest.php
Normal 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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
169
tests/Feature/PagineApplicativeTest.php
Normal file
169
tests/Feature/PagineApplicativeTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
333
tests/Feature/RegistroS13Test.php
Normal file
333
tests/Feature/RegistroS13Test.php
Normal 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);
|
||||
}
|
||||
}
|
||||
264
tests/Feature/ZonaPericolosaTest.php
Normal file
264
tests/Feature/ZonaPericolosaTest.php
Normal 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
24
tests/TestCase.php
Normal 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;
|
||||
}
|
||||
}
|
||||
423
tests/Unit/BackupArchiveTest.php
Normal file
423
tests/Unit/BackupArchiveTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
202
tests/Unit/BackupOperationStateTest.php
Normal file
202
tests/Unit/BackupOperationStateTest.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
163
tests/Unit/StileUnificatoTest.php
Normal file
163
tests/Unit/StileUnificatoTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user