Compare commits

...

5 Commits

Author SHA1 Message Date
fc8623f7db Posta configurabile dall'app, contatti dei proclamatori, conferma email
- Server di posta in Impostazioni (password cifrata, email di prova): se
  compilato prende il posto del .env; non si esporta e l'import XML non lo
  cancella. MAIL_SCHEME=tls nel .env.example non era valido per Laravel 11.
- Proclamatori con email e telefono facoltativi, cifrati, nell'XML e
  cancellati dall'anonimizzazione; il numero apre WhatsApp.
- Dopo l'assegnazione un riquadro in evidenza con il link del PDF,
  "Invia su WhatsApp" con il messaggio pronto e la scelta "inviato /
  non ancora", che resta finché non si sceglie.
- Conferma dell'email obbligatoria per tutti gli utenti, anche esistenti:
  email in italiano, link valido senza login, nuova conferma a ogni cambio
  di indirizzo, "Reinvia conferma" in Utenti. Senza conferma restano
  raggiungibili profilo, cambio password e, per l'admin, il server di
  posta; comando utenti:conferma-email per l'emergenza.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:12:31 +02:00
e7148c7b32 Piantine pubblicate anche dopo un ripristino; posto unico senza gare
- Lo stato "Pubblicata" non dipende più dall'id del territorio, che il
  ripristino ricrea: l'impronta usa il numero, e la piantina ricorda il
  percorso del PDF pubblicato (pdf_pubblicato, con migrazione che riconosce
  le piantine già pubblicate).
- BackupOperationState: corrente() in sola lettura e liberaCorrente() sotto
  lo stesso mutex di occupa(), così una lettura non cancella il puntatore
  di un'operazione appena avviata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:33:07 +02:00
426a95211d Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri
Blocco 2
- Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash
  che comparivano alla pagina dopo.
- PDF, miniature e versioni dei territori non più serviti da nginx su
  /storage: TerritorioFileController con login, permessi e cache privata.
- Ripristino tutto o niente: verifica dell'archivio, una transazione con
  import, piantine, blocco account e scambio dei file annullabile.
- Backup formato 2 con piantine, storico PDF e area KML.
- Posto unico preso in un passo (Cache::lock) e rispettato anche da
  import XML e PDF.
- Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui),
  caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico.
- L'import XML elimina i file dei territori che non usa più; comando
  territori:file-orfani per quelli accumulati prima.

Blocco 3
- Password attuale richiesta al cambio volontario; AuthenticateSession
  chiude le sessioni aperte altrove.
- Niente nomi dei proclamatori in chiaro nel registro attività.
- Export XML senza remember_token; chi riceve l'hash dal file deve
  cambiare password.
- Campagne: ultimo giorno incluso, percentuale sui territori distinti.
- Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru,
  impostazioni rilette a ogni job.
- Azzeramento che cancella anche piantine, area KML e anteprime.
- Pulsanti profilo e menu che non si nascondevano; icona del sito.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:01:41 +02:00
056d95cfcb Editor piantine: bozza automatica e pubblicazione con anteprima
- Il disegno è bozza e si salva da solo; i dati del territorio si
  modificano dall'editor e valgono subito nel gestionale.
- Un solo "Pubblica" (immagine + PDF), uguale in editor e gestione:
  anteprima preparata in coda su disco privato, conferma obbligatoria,
  rifiutata se nel frattempo è cambiato qualcosa di stampato.
- Stati Bozza / Pubblicata / Modifiche da pubblicare, calcolati
  sull'impronta di confine e dati stampati e sul PDF in uso.
- Gestione riscritta: stato, ricerca, filtri, storico paginato.
- Editor ridisegnato a tre zone, utilizzabile anche su tablet e telefono.
- Avvisi via evento (trait Avvisa + <x-avvisi>).
- .gitattributes: script shell sempre LF, altrimenti il container
  locale su Windows non parte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:08:34 +02:00
d433c9392d S-13 sul modulo ufficiale e correzioni all'integrità dei dati
Modulo S-13
- Il PDF scrive sopra il modulo ufficiale (FPDI, matrice convertita in PDF 1.4)
  invece di ricostruirlo in HTML: 20 righe per foglio come l'originale.
- Una riga per ogni territorio attivo; riporto dell'ultimo rientro prima
  dell'anno; assegnazioni a cavallo d'anno su entrambi i fogli; scelta per
  data e non per anno registrato; nomi come "Cognome I.".
- Logica spostata in App\Services\Registro\ModuloS13.

Integrità dei dati
- Export/import XML (e quindi backup/ripristino): le date non slittano più di
  un giorno; i file vecchi in UTC tornano al giorno giusto.
- Import XML: rifiuta file estranei o incompleti senza toccare i dati, errore
  con riga e motivo, territori senza zona importati con il loro storico,
  caratteri di controllo rimossi in export, chi importa conserva password e
  ruolo di amministratore.
- Assegnazioni: assegnazioneCorrente corretta (filtro dentro ofMany), periodi
  sovrapposti rifiutati in Assegna e Registro sotto lock, anno di servizio
  ricavato dalla data, niente date future nel Registro.
- Utenti: non si declassa sé stessi né l'ultimo amministratore.
- Entrypoint: non sovrascrive più la copia persistente di APP_KEY con un
  valore diverso; serve APP_KEY_ROTATE=1 per un cambio voluto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:35:51 +02:00
129 changed files with 10218 additions and 1824 deletions

View File

@@ -34,6 +34,10 @@ APP_URL=http://192.0.2.10:8080
# I dati personali dei proclamatori sono cifrati con questa chiave: se la # I dati personali dei proclamatori sono cifrati con questa chiave: se la
# perdi non sono piu' leggibili nemmeno avendo il backup del database. # perdi non sono piu' leggibili nemmeno avendo il backup del database.
# Conservala in un password manager. # Conservala in un password manager.
# Una copia sta nel volume dello storage: se questo valore cambia e non
# coincide con la copia, il container si rifiuta di partire invece di rendere
# illeggibili i dati. Per un cambio voluto aggiungi APP_KEY_ROTATE=1 (la
# chiave vecchia viene conservata accanto alla copia), poi toglila.
APP_KEY= APP_KEY=
LOG_CHANNEL=stderr LOG_CHANNEL=stderr
@@ -122,15 +126,24 @@ SESSION_SECURE_COOKIE=false
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Posta # Posta
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Il server di posta si configura di solito dall'applicazione (Impostazioni →
# Server di posta): se lì è compilato, questi valori vengono ignorati.
# Servono le email di conferma: senza posta gli utenti non possono confermare
# il loro indirizzo e il loro account non funziona.
#
# In produzione: SMTP esterno. # In produzione: SMTP esterno.
# In locale con --profile dev: MAIL_HOST=mailpit, MAIL_PORT=1025 # In locale con --profile dev: MAIL_HOST=mailpit, MAIL_PORT=1025
# Per disattivare del tutto l'invio: MAIL_MAILER=log # Per disattivare del tutto l'invio: MAIL_MAILER=log
#
# MAIL_SCHEME: vuoto (sceglie da solo: STARTTLS sulla 587, SSL sulla 465),
# oppure smtp / smtps. "tls" non è un valore valido con Laravel 11 e fa
# fallire ogni invio.
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST= MAIL_HOST=
MAIL_PORT=587 MAIL_PORT=587
MAIL_USERNAME= MAIL_USERNAME=
MAIL_PASSWORD= MAIL_PASSWORD=
MAIL_SCHEME=tls MAIL_SCHEME=
MAIL_FROM_ADDRESS=noreply@example.org MAIL_FROM_ADDRESS=noreply@example.org
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# Gli script finiscono in container Linux: con CRLF (core.autocrlf su
# Windows) il kernel non trova l'interprete e il container non parte.
*.sh text eol=lf

View File

@@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non 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. può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: **28 agosto 2026**. Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
--- ---
@@ -75,6 +75,103 @@ e l'unica che richiede una decisione, non del lavoro.
--- ---
## Lavori in corso — 12 ottobre 2026 (riprendere da qui)
Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo):
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione
(integrità dati).
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
- `426a952` — blocchi 2 e 3 della revisione (sotto); le ultime due righe
della tabella sono nel commit successivo.
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026)
Richieste dell'utente, con le sue scelte:
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante
"Invia email di prova". Se compilato prende il posto del `.env`
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era
un valore non valido per Laravel 11: ora vuoto.
- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome,
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
considera italiano).
- **Dopo l'assegnazione** si resta sulla pagina Assegna: in cima un riquadro
per ogni assegnazione appena fatta con il link del PDF, "Invia su WhatsApp"
(messaggio già scritto con il link) e la scelta "Sì, inviato / Non ancora",
che imposta `link_sent`. Resta finché non si sceglie
(`Assegna::DA_INVIARE` in sessione).
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`).
L'admin crea l'utente con la password come prima; parte l'email di conferma
(in italiano, `VerifyEmail::toMailUsing`); il link funziona anche senza
login. Cambiare email (profilo o admin) la rende da confermare e rimanda il
link. In Utenti: stato "da confermare" e "Reinvia conferma".
Raggiungibili senza conferma: profilo, cambio password, pagina di conferma
e — per l'amministratore — il server di posta.
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
creato da riga di comando nasce confermato.
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
`$confermaUtentiCreati`).
Provato nel browser in locale con mailpit come server configurato dall'app:
email di prova e di conferma arrivate, link aperto senza login, account
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
e dati di prova rimossi dopo.
### Revisione profonda: blocchi 2 e 3 chiusi
| Problema | Correzione |
|---|---|
| Esiti delle azioni Livewire persi o mostrati alla pagina dopo (11 componenti) | trait `Avvisa` + `<x-avvisi>`; un test vieta `session()->flash()` senza redirect |
| PDF, miniature e versioni scaricabili da `/storage/territori-*` senza login (nomi indovinabili, cache Cloudflare 7 giorni) | nginx risponde 404 su `/storage/territori-`; `TerritorioFileController` con permessi della pagina e cache privata; `Territorio::pdfUrl()` / `miniaturaUrl()` |
| Ripristino non atomico | estrazione verificata (CRC, byte) → una transazione con import, piantine, blocco account e scambio file (`rename`, annullabile) → file vecchi cancellati solo dopo il commit |
| Piantine, storico PDF, area KML fuori dal backup | formato archivio 2 (`piantine.json`, `piantine/…`, `PiantineBackup`); gli archivi di formato 1 si ripristinano lo stesso |
| Blocco "una sola operazione" ignorato da import XML/PDF, e controllo+presa in due passi | `BackupOperationState::occupa()` sotto `Cache::lock`, usato da tutte le porte; rilascio anche su errore e in `failed()` |
| Import PDF da cartella: `1-A` → territorio 1, `12 B` ambiguo, solo 20 file, `Thumbs.db` blocca tutto, `__MACOSX` | abbinamento "cifre + al massimo una lettera"; ambigui scartati; caricamento a lotti dal browser; file non PDF ignorati |
| Import PDF e caricamento dalla scheda cancellavano il vecchio PDF prima di scrivere il nuovo | prima il nuovo, poi il vecchio nello storico (`PiantinaPdfService::archiviaCorrente`, origini `import` / `manuale`) |
| L'import XML lasciava sul disco i file dei territori ricreati (nel PC: 549 file, 80 MB) | l'import elimina i file non più usati; per i vecchi c'è `php artisan territori:file-orfani` (elenca; `--elimina` cancella) — **da lanciare quando si decide** |
| Azzeramento lasciava piantine orfane, area KML, anteprime | cancellate anche quelle |
| `/cambia-password` senza password attuale | richiesta, tranne dopo un ripristino (cambio obbligatorio) |
| Sessioni aperte altrove non chiuse al cambio password | `AuthenticateSession` nel gruppo web; il ripristino conserva l'hash di chi lo esegue, così non viene buttato fuori |
| Nomi dei proclamatori in chiaro nel registro attività | si salva `proclamatore_id`, il registro mostra il nome attuale; migrazione `2026_10_12_000010` ripulisce le voci vecchie |
| Export XML con `remember_token`; l'import XML non obbligava a cambiare password | token non esportato e azzerato all'import; chi riceve l'hash dal file deve cambiare password |
| Campagne: sparivano l'ultimo giorno; percentuale oltre il 100% | confronto per data; si contano i territori attivi distinti |
| `retry_after` 90 s con job fino a 900 s | `config/queue.php`: 960 s |
| Worker ucciso dopo 10 s al riavvio | `stop_grace_period: 16m` |
| Redis `allkeys-lru` poteva sfrattare i job in coda | `volatile-lru` |
| Cache statica di `Setting` vecchia nel worker | svuotata a ogni job (`Queue::before`) |
| Dopo un ripristino le piantine pubblicate risultavano "da pubblicare" (lo stato dipendeva dall'id del territorio) | impronta con il numero, non l'id; la piantina ricorda il PDF pubblicato (`pdf_pubblicato`, migrazione `2026_10_12_000020`) |
| `corrente()` / `liberaCorrente()` potevano cancellare il puntatore di un'operazione appena avviata | `corrente()` in sola lettura; `liberaCorrente()` sotto lo stesso mutex di `occupa()` |
| Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) | `.tm-btn` sta fuori dai layer di Tailwind e vince su `hidden`: si nasconde il contenitore |
Non cambiato, di proposito: `trustProxies('*')` (commento in
`bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS).
Suite: **429 test verdi**. Provati in browser in locale: file protetti (404
senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2
lotti, `Thumbs.db` ignorato, nessun territorio toccato, posto liberato).
**Non provato / aperto:**
- chiusura delle sessioni con due browser veri; coda e worker con un riavvio
reale; modifica vertici al tocco su tablet vero;
- l'upload Livewire ha `throttle:60,1`: oltre ~1200 file al minuto qualche
lotto riceve 429 (il browser lo segnala);
- numeri di territorio con lettere davanti ("A12") non si abbinano da nome
file: il PDF si carica dalla scheda.
---
## Da dove veniamo ## Da dove veniamo
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
@@ -212,13 +309,48 @@ non esiste, lo si crea dall'editor col solo numero. La chiave esterna è
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare `nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
`territori` come fa ogni import XML, non porta più via i disegni. `territori` come fa ogni import XML, non porta più via i disegni.
**Due pagine, non una.** `/piantine` tiene area, generazione, collegamenti e **Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni
storico; `/piantine/editor` è l'editor a schermo intero, con un telaio suo piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate /
(`layouts/editor`) senza barra laterale. Tagliare un confine su una mappa larga scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre,
un terzo di schermo vuol dire sbagliarlo, e quel disegno finisce stampato. si pubblica e si elimina. `/piantine/editor` è l'editor a schermo intero, con
un telaio suo (`layouts/editor`) senza barra laterale: tagliare un confine su
una mappa larga un terzo di schermo vuol dire sbagliarlo. Elenco e pannello
galleggiano sopra la mappa; barra strumenti e controlli Leaflet si spostano
nello spazio libero (variabili `--libero-sx`/`--libero-dx`); sotto i 720px il
pannello sale dal basso. Su tablet i vertici sono più grandi (`pointer: coarse`).
La rigenerazione massiva è stata tolta: job, stato in cache e interfaccia. Si **Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo:
rigenera un territorio alla volta dall'elenco. il confine a ogni disegno o spostamento di vertice, nome e collegamento appena
cambiano. È bozza: il PDF del territorio non si tocca. I dati del territorio
(numero, zona, tipologia, confini) sono invece dati del gestionale e si salvano
subito, colonna per colonna (un campo non valido non trascina gli altri). Il
PDF cambia solo con **Pubblica**, uguale in editor e gestione
(`Livewire\Piantine\Pubblicazione`): `PubblicazionePiantina::avvia()` mette in
coda `PreparaAnteprimaPiantina` (il primo rendering può superare i 100 s di
Cloudflare), che genera immagine e PDF sul disco **privato**
(`storage/app/piantine-anteprime/`, stato in cache sotto un token di 40
caratteri, servito da `piantine.anteprima`); la finestra controlla ogni 2 s e
mostra il PDF; solo **Conferma** lo rende ufficiale. Se nel frattempo è cambiato
qualcosa di stampato, la conferma rifiuta.
Gli stati (`TerritorioMappa::stato()`): **Bozza** (mai pubblicata),
**Pubblicata**, **Modifiche da pubblicare**. Il confronto è sull'`impronta()`:
sha256 di confine, territorio, numero, nome della zona e confini — cambia uno
qualunque e la piantina torna da pubblicare anche col disegno identico. Vale
"Pubblicata" solo se anche il PDF in uso è ancora uno composto da una piantina
(`territori-pdf/piantina-{territorio}-…`): un ripristino dallo storico, un
upload a mano o un import la riportano "da pubblicare". "Ripristina il confine
pubblicato" riporta la bozza a `geojson_pubblicato`. La migrazione
`2026_10_09_000010` ha segnato pubblicate le piantine esistenti solo se il PDF
in vigore del territorio veniva da una piantina.
Disegnare crea **sempre** una piantina nuova; per cambiare quella aperta ci
sono "Modifica vertici" e "Ridisegna confine", espliciti. Con la mappa si
parla per eventi: i dati di tutte le piantine li chiede il JS una volta
(`datiMappa()`), poi arriva solo la piantina cambiata (`piantina-aggiornata`).
Gli avvisi passano dall'evento `avviso` (trait `Livewire\Concerns\Avvisa`,
componente `<x-avvisi>`): i flash di sessione nel telaio non si vedono dopo
un'azione Livewire.
**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è **La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
@@ -234,11 +366,6 @@ nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco
marca i territori collegati senza zona: senza, l'intestazione della cartolina marca i territori collegati senza zona: senza, l'intestazione della cartolina
esce vuota e ce se ne accorge in stampa. esce vuota e ce se ne accorge in stampa.
**L'anteprima non tocca niente.** `piantine.anteprima` compone il PDF al volo e
lo serve `inline`: non aggiorna `pdf_path`, non archivia versioni, non lascia
file. «Genera PDF» manda il documento in vigore, e scoprire un confine sbagliato
dopo significa trovarselo già nello storico.
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte **L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`): che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):
@@ -650,13 +777,16 @@ Scelte non ovvie:
cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che
sopravvive, quella dell'azzeramento stesso. sopravvive, quella dell'azzeramento stesso.
- **Ordine del restore: staging → database → file.** I file si estraggono - **Ordine del restore: verifica → una transazione → pulizia.** Dal 12/10/2026
prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o (`RestoreBackupArchive`): si estrae e si verifica tutto (CRC, byte, il
fuori limite fallisce mentre i dati correnti sono ancora intatti; poi `piantine.json` del formato 2) prima di toccare qualcosa; poi una sola
`importXmlFromContent()` in transazione; poi la sostituzione dei file. Il `DB::transaction` contiene import XML, tabelle delle piantine, blocco degli
disco `public` viene **svuotato**, non fuso: i percorsi vivono nelle righe account e, per ultimo, lo scambio dei file (`BackupArchive::scambiaFile()`:
appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna solo `rename()` sullo stesso volume, annotati e annullabili). Se qualcosa
schermata mostra e nessuna cancellazione raggiunge. fallisce si torna allo stato di prima; i file vecchi aspettano in
`storage/app/backup-precedenti/<uuid>` e si cancellano solo dopo il commit.
Il disco `public` viene sostituito, non fuso: i percorsi vivono nelle righe
appena riscritte.
- **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su - **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su
numero di entry, dimensione della singola entry e totale decompresso, pochi numero di entry, dimensione della singola entry e totale decompresso, pochi
@@ -938,10 +1068,12 @@ Le scelte che vale la pena conoscere:
non parla di cifratura riesce a scrivere una riga. Non ha alcun rapporto con 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. la chiave di produzione, che vive nell'ambiente e nel volume.
- **`righeS13()` si chiama in riflessione.** È privata di proposito (non è - **Il modulo S-13 è un servizio, non un metodo del componente.** Calcolo e
un'azione Livewire) ma è il calcolo, non il PDF, quello che va protetto: PDF stanno in `App\Services\Registro\ModuloS13`, che i test chiamano
renderla pubblica solo per poterla testare avrebbe allargato la superficie direttamente; il componente `Registro` si limita a validare l'anno e
del componente per comodità del test. scaricare. Il PDF scrive sopra il modulo ufficiale
(`resources/pdf/S-13_I.pdf`, convertito in PDF 1.4 per FPDI) alle
coordinate della sua griglia, come la cartolina S-12.
### Un solo container esegue le migrazioni ### Un solo container esegue le migrazioni
@@ -956,6 +1088,12 @@ mancante — con il rischio di chiavi diverse a ogni richiesta e dati cifrati
irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il irrecuperabili. Ora è risolta dall'entrypoint (env → volume → generata) e il
provider **solleva un'eccezione** se manca, invece di rigenerarla. provider **solleva un'eccezione** se manca, invece di rigenerarla.
Dal 09/10/2026 l'entrypoint **si rifiuta di partire** se `APP_KEY`
nell'ambiente è diversa dalla copia in `storage/app/.app_key`: prima la
sovrascriveva, e un valore sbagliato in Dokploy distruggeva l'unica copia della
chiave giusta. Per un cambio voluto si aggiunge `APP_KEY_ROTATE=1` (la chiave
vecchia resta in `.app_key.prev-<data>`) e poi la si toglie.
--- ---
## Bug corretti — i più significativi ## Bug corretti — i più significativi
@@ -984,6 +1122,14 @@ Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
| Eliminazione definitiva territorio | La riga spariva, mappa e miniatura restavano orfane sul volume per sempre | `Territori/TerritorioCestino` | | 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` | | 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` | | 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` |
| Date esportate in UTC | Ogni backup → ripristino (e export → import XML) spostava **tutte** le date indietro di un giorno, sommandosi a ogni giro. I file vecchi vengono riportati al giorno giusto all'import | `XmlExchange::righeEsportate()`, `normalizeDateForDb()` |
| Import di qualsiasi XML | Un XML ben formato ma estraneo svuotava il database con "completato con successo". Ora servono la radice `<termanager2>` e le sezioni principali | `XmlExchange::importXmlFromContent()` |
| Caratteri di controllo nell'export | Uno solo, in una nota, rendeva il file (e il `data.xml` del backup) non reimportabile | `XmlExchange::addXmlText()` |
| Territori senza zona scartati all'import | Sparivano con tutto il loro storico di assegnazioni | `XmlExchange::importXmlFromContent()` |
| Import che estromette chi lo esegue | Password vecchia e ruoli del file applicati anche all'amministratore che importava | `XmlExchange::importXmlFromContent()` |
| `whereNull` fuori da `latestOfMany` | Con una chiusa più recente dell'aperta il territorio risultava libero mentre era fuori, e assegnabile due volte | `Territorio::assegnazioneCorrente()` |
| Periodi di assegnazione accavallati | Uscite retrodatate a prima dell'ultimo rientro, doppie aperte dal Registro, anno scelto a mano incoerente con la data | `Assegnazione::sovrappostaA()`, `Assegna`, `Registro` |
| Declassamento dell'ultimo amministratore | Togliendosi il ruolo l'installazione restava senza nessuno che potesse gestirla | `Settings/UsersIndex` |
| `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` | | `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` | | `@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()` | | 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()` |
@@ -1031,7 +1177,7 @@ docker compose --profile test run --build --rm test
|------|---------------| |------|---------------|
| `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/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/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/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" (fine ciclo e ultimo rientro prima dell'anno), assegnazioni a cavallo d'anno su entrambi i fogli, una riga per ogni territorio attivo anche senza assegnazioni, esclusione di territori disattivati e cestinati, ordinamento naturale dei numeri, fogli da 20 righe e generazione del PDF |
| `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/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/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/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 |
@@ -1073,15 +1219,14 @@ nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard 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. (`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
### 2. Race su doppia assegnazione ### 2. Race su doppia assegnazione — chiusa il 09/10/2026
`Assegna::save()` controlla che il territorio sia libero e poi crea la riga, ma `Assegna::save()` e il salvataggio del Registro controllano e scrivono dentro
nulla a livello di database impedisce due assegnazioni aperte sullo stesso una transazione con `lockForUpdate()` sulla riga del territorio, e rifiutano
territorio (doppio click, due schede). `assegnazioneCorrente` usa `latestOfMany`, ogni periodo che si accavalla a un altro (`Assegnazione::sovrappostaA()`). Non
quindi la seconda resta invisibile nell'interfaccia mentre falsa i conteggi. c'è ancora un vincolo nel database: l'import XML e i dati storici possono
contenere sovrapposizioni, che il controllo segnala solo quando si modifica
Rimedio: transazione con `lockForUpdate()`, oppure un indice univoco su colonna una di quelle righe.
generata che imponga una sola assegnazione aperta per territorio.
### 3. Scrittura durante il rendering ### 3. Scrittura durante il rendering
@@ -1132,21 +1277,12 @@ pubblicate da Laravel sono state rimosse perche' i paginatori sono tutti dentro
componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da
`pagination::`. L'esclusione resta a guardia di future viste di terze parti. `pagination::`. L'esclusione resta a guardia di future viste di terze parti.
### 7. Il backup non contiene l'area KML delle piantine ### 7. Area KML, piantine e storico nel backup — chiuso il 12/10/2026
`BackupArchive` impacchetta il solo disco `public` (`storage/app/public`), e Dal formato 2 l'archivio contiene `piantine.json` (righe di `territorio_mappe`
`PiantineArea` scrive in `storage/app/piantine/` — una cartella **sorella**, non e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e
dentro. Verificato: dopo un ripristino su volumi vuoti l'area va ricaricata a la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina
mano dal KML originale, che quindi conviene conservare fuori di qui. lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca.
I disegni invece si salvano: stanno in `territorio_mappe`, e da quando la chiave
esterna è `nullOnDelete` sopravvivono anche allo svuotamento di `territori` che
fa ogni import XML — scollegati, ma recuperabili.
Sistemarlo vuol dire estendere `BackupArchive` a una seconda cartella, cosa che
tocca anche il formato dell'archivio (`FORMAT_VERSION`) e il ripristino. Non è
stato fatto perché il KML è un file che l'utente ha già sul suo computer: la
perdita è un fastidio, non un dato irrecuperabile.
--- ---

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Conferma a mano l'email di un utente, dalla riga di comando.
*
* Per l'emergenza: la posta non funziona e nessun amministratore confermato
* può sistemarla (per esempio subito dopo l'aggiornamento che ha reso
* obbligatoria la conferma). Chi ha accesso al server è già fidato.
*/
class ConfermaEmailUtente extends Command
{
protected $signature = 'utenti:conferma-email {email : Indirizzo dell\'utente da confermare}';
protected $description = 'Segna come confermata l\'email di un utente (emergenza: posta non funzionante)';
public function handle(): int
{
$user = User::where('email', $this->argument('email'))->first();
if (! $user) {
$this->error('Nessun utente con questo indirizzo.');
return self::FAILURE;
}
if ($user->hasVerifiedEmail()) {
$this->info("{$user->email} è già confermato.");
return self::SUCCESS;
}
$user->markEmailAsVerified();
activity()->performedOn($user)->log('email_confermata_da_riga_di_comando');
$this->info("Email di {$user->email} confermata.");
return self::SUCCESS;
}
}

View File

@@ -95,6 +95,11 @@ class CreateInitialAdmin extends Command
'password' => Hash::make($password), 'password' => Hash::make($password),
]); ]);
// Confermato da subito: su un'installazione nuova il server di
// posta non c'è ancora, e chi ha accesso alla riga di comando del
// server non ha bisogno di dimostrare di chi è l'indirizzo. Senza,
// il primo amministratore arriverebbe solo alla pagina della posta.
$user->markEmailAsVerified();
$user->assignRole('amministratore'); $user->assignRole('amministratore');
return $user; return $user;

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Livewire\Settings\XmlExchange;
use App\Models\Setting;
use App\Models\TerritorioMappa;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Elenca (e su richiesta cancella) i file dei territori che nessuna riga usa.
*
* Fino all'ottobre 2026 l'import XML ricreava i territori con id nuovi senza
* toccare i loro file: PDF, miniature e versioni dei territori di prima
* restavano sul disco, e passavano da un backup all'altro perché l'archivio
* copia tutto il disco pubblico. Oggi l'import li elimina da sé; questo
* comando serve a ripulire quelli accumulati prima.
*
* Di default non cancella niente: elenca. Con --elimina cancella.
*/
class TerritoriFileOrfani extends Command
{
protected $signature = 'territori:file-orfani {--elimina : Cancella i file trovati invece di elencarli}';
protected $description = 'Elenca (o cancella con --elimina) i file dei territori che nessuna riga usa più';
private const CARTELLE = ['territori-pdf', 'territori-thumbnails', 'territori-pdf-versioni', 'territori-mappe'];
public function handle(): int
{
$usati = array_flip(array_merge(
XmlExchange::fileDeiTerritori(),
array_filter([Setting::instance()->logo_path]),
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
));
$disco = Storage::disk('public');
$orfani = [];
$byte = 0;
foreach (self::CARTELLE as $cartella) {
foreach ($disco->files($cartella) as $file) {
if (! isset($usati[$file])) {
$orfani[] = $file;
$byte += $disco->size($file);
}
}
}
$peso = number_format($byte / 1048576, 1, ',', '.') . ' MB';
if ($orfani === []) {
$this->info('Nessun file orfano.');
return self::SUCCESS;
}
if (! $this->option('elimina')) {
foreach ($orfani as $file) {
$this->line($file);
}
$this->info(count($orfani) . " file orfani ({$peso}). Rilancia con --elimina per cancellarli.");
return self::SUCCESS;
}
$cancellati = 0;
foreach ($orfani as $file) {
if ($disco->delete($file)) {
$cancellati++;
}
}
$this->info("{$cancellati} file orfani cancellati ({$peso}).");
return self::SUCCESS;
}
}

View File

@@ -43,23 +43,30 @@ class PasswordChangeController extends Controller
public function store(Request $request) public function store(Request $request)
{ {
// La password attuale NON viene richiesta, ed e' una scelta. $user = $request->user();
// La password attuale si chiede solo a chi arriva qui di sua
// iniziativa, come fa il profilo.
// //
// Il ripristino sovrascrive gli hash con quelli del backup: subito dopo, // Dopo un ripristino no: gli hash sono quelli del backup, e la
// la "password attuale" di un account e' quella che aveva all'epoca del // "password attuale" di un account e' quella che aveva all'epoca, che
// backup, che l'utente puo' benissimo non ricordare — l'amministratore // l'utente puo' benissimo non ricordare. Chiederla bloccherebbe fuori
// che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori // proprio le persone che questa schermata deve sbloccare; la garanzia li'
// proprio le persone che questa schermata deve sbloccare. La garanzia // e' la sessione. Fuori da quel caso, invece, bastava una sessione
// qui e' la sessione: si arriva a questa pagina gia' autenticati. // lasciata aperta per cambiare la password a qualcun altro e chiuderlo
$validated = $request->validate([ // fuori dal suo account.
'password' => ['required', 'confirmed', Password::min(8)], $regole = ['password' => ['required', 'confirmed', Password::min(8)]];
], [ if (! $user->password_change_required) {
$regole['current_password'] = ['required', 'current_password'];
}
$validated = $request->validate($regole, [
'password.required' => 'Inserisci la nuova password.', 'password.required' => 'Inserisci la nuova password.',
'password.confirmed' => 'Le due password non coincidono.', 'password.confirmed' => 'Le due password non coincidono.',
'current_password.required' => 'Inserisci la password attuale.',
'current_password.current_password' => 'La password attuale non è corretta.',
]); ]);
$user = $request->user();
// Il cast `hashed` sul modello cifra il valore al salvataggio. // Il cast `hashed` sul modello cifra il valore al salvataggio.
$user->update([ $user->update([
'password' => $validated['password'], 'password' => $validated['password'],
@@ -68,7 +75,10 @@ class PasswordChangeController extends Controller
// La sessione corrente sopravvive al cambio, le altre no: dopo un // La sessione corrente sopravvive al cambio, le altre no: dopo un
// ripristino le vecchie credenziali sono da considerarsi compromesse e // ripristino le vecchie credenziali sono da considerarsi compromesse e
// una sessione aperta altrove lo e' altrettanto. // una sessione aperta altrove lo e' altrettanto. Le altre le chiude
// AuthenticateSession (bootstrap/app.php) alla loro prossima richiesta,
// perche' l'hash della password e' cambiato; qui si cambia l'id di
// questa e si invalida il "ricordami".
$request->session()->regenerate(); $request->session()->regenerate();
$user->setRememberToken(null); $user->setRememberToken(null);
$user->save(); $user->save();

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\ConfigurazionePosta;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Conferma dell'indirizzo email degli utenti.
*
* Finché l'indirizzo non è confermato l'account non funziona: il middleware
* `verified` porta qui. Il link della mail funziona anche senza essere
* entrati: lo si apre spesso dal telefono, dove la sessione non c'è, e la
* firma dell'indirizzo (con scadenza) basta a garantire che venga dalla mail.
*/
class VerificaEmailController extends Controller
{
public function avviso(Request $request, ConfigurazionePosta $posta)
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->route('dashboard');
}
return view('auth.verifica-email', [
'user' => $request->user(),
'postaFunzionante' => $posta->funzionante(),
]);
}
public function invia(Request $request)
{
$user = $request->user();
if ($user->hasVerifiedEmail()) {
return redirect()->route('dashboard');
}
try {
$user->sendEmailVerificationNotification();
} catch (\Throwable $e) {
Log::warning('Email di conferma non inviata.', ['user' => $user->id, 'exception' => $e]);
return back()->with('error', 'L\'email non è partita: il server di posta non risponde o rifiuta l\'invio. Avvisa un amministratore.');
}
return back()->with('success', "Email inviata a {$user->email}. Apri il link che contiene (controlla anche lo spam).");
}
public function conferma(Request $request, int $id, string $hash)
{
$user = User::find($id);
// La firma garantisce id e scadenza; l'hash lega il link all'indirizzo
// di allora, così un link vecchio non conferma un'email cambiata dopo.
if (! $user || ! hash_equals(sha1($user->getEmailForVerification()), $hash)) {
abort(403, 'Link di conferma non valido.');
}
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
activity()->causedBy($user)->log('email_confermata');
}
$messaggio = 'Indirizzo email confermato.';
if ($request->user()?->is($user)) {
return redirect()->route('dashboard')->with('success', $messaggio);
}
return redirect()->route('login')->with('success', $messaggio . ' Ora puoi accedere.');
}
}

View File

@@ -110,30 +110,45 @@ class BackupController extends Controller
'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.', 'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.',
]); ]);
$operationId = (string) Str::uuid();
// Stesso blocco del componente Livewire, ripetuto qui perche' questa e' // Stesso blocco del componente Livewire, ripetuto qui perche' questa e'
// una porta d'ingresso separata: un ripristino avviato mentre gira un // una porta d'ingresso separata: un ripristino avviato mentre gira un
// azzeramento riscriverebbe le tabelle che l'altro sta cancellando. // azzeramento riscriverebbe le tabelle che l'altro sta cancellando.
if ($this->stato->corrente() !== null) { // Il posto si prende subito e in un passo solo (occupa()): controllarlo
// e prenderlo solo dopo aver salvato il file lasciava una finestra in cui
// un import o un secondo ripristino passavano entrambi.
$inCorso = $this->stato->occupa($operationId, BackupOperationState::TIPO_RESTORE, 'In coda');
if ($inCorso !== null) {
return response()->json([ return response()->json([
'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.', 'message' => $this->stato->messaggioOccupato($inCorso),
], 409); ], 409);
} }
$operationId = (string) Str::uuid(); try {
// Il file dell'upload vive in una cartella temporanea che sparisce a
// fine richiesta: il job lo troverebbe gia' cancellato.
$percorso = $request->file('backupZip')->storeAs(
'backup-restore-in',
$operationId . '.zip',
'local'
);
} catch (\Throwable $e) {
// Il posto e' gia' preso: senza chiuderlo resterebbe occupato da un
// ripristino che non partira' mai, per l'ora intera del TTL.
$this->stato->markFailed($operationId, 'Archivio non salvato sul server: ' . $e->getMessage());
// Il file dell'upload vive in una cartella temporanea che sparisce a throw $e;
// fine richiesta: il job lo troverebbe gia' cancellato. }
$percorso = $request->file('backupZip')->storeAs(
'backup-restore-in', if ($percorso === false) {
$operationId . '.zip', $this->stato->markFailed($operationId, 'Archivio non salvato sul server.');
'local'
); return response()->json([
'message' => 'Il server non è riuscito a salvare il pacchetto ricevuto (spazio o permessi sul disco).',
], 500);
}
$this->stato->initialize(
$operationId,
BackupOperationState::TIPO_RESTORE,
'In coda'
);
$this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.'); $this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.');
RestoreBackupArchive::dispatch( RestoreBackupArchive::dispatch(

View File

@@ -3,37 +3,30 @@
namespace App\Http\Controllers\Settings; namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\TerritorioMappa; use App\Services\Piantine\PubblicazionePiantina;
use App\Services\Piantine\PiantinaPdfService;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
* Anteprima della cartolina, senza sostituire niente. * Il PDF dell'anteprima di una pubblicazione, da guardare prima di confermare.
* *
* Il PDF viene composto al volo e servito da leggere: non tocca `pdf_path` del * Lo prepara PubblicazionePiantina in una cartella privata: qui lo si serve
* territorio, non archivia versioni, non lascia file sul disco. Serve a * soltanto, senza toccare il PDF in vigore del territorio. Il token è casuale
* guardare come viene prima di decidere — perché "Genera PDF" è un'operazione * e scade con l'anteprima; la rotta resta comunque riservata
* che manda il documento in vigore, e vederlo solo dopo significa scoprire un * all'amministratore come il resto delle piantine.
* confine sbagliato quando è già nello storico.
*/ */
class PiantinaAnteprimaController extends Controller class PiantinaAnteprimaController extends Controller
{ {
public function __invoke(TerritorioMappa $mappa, PiantinaPdfService $pdfService): Response public function __invoke(string $token, PubblicazionePiantina $pubblicazione): Response
{ {
$mappa->load('territorio.zona'); $percorso = $pubblicazione->pdfAnteprima($token);
abort_unless($mappa->collegata(), 404); abort_if($percorso === null, 404);
$pdf = $pdfService->genera($mappa->territorio, $mappa); return response()->file($percorso, [
return response($pdf, 200, [
'Content-Type' => 'application/pdf', 'Content-Type' => 'application/pdf',
// `inline`: si apre nel visualizzatore del browser invece di // `inline`: si apre nel visualizzatore del browser invece di
// finire fra i download, che di un'anteprima non se ne fa niente. // finire fra i download, che di un'anteprima non se ne fa niente.
'Content-Disposition' => 'inline; filename="anteprima-territorio-' 'Content-Disposition' => 'inline; filename="anteprima-piantina.pdf"',
. $mappa->territorio->numero . '.pdf"',
// Un'anteprima rispecchia lo stato di adesso: se la cache la
// congela, si continua a guardare il confine di prima.
'Cache-Control' => 'no-store, max-age=0', 'Cache-Control' => 'no-store, max-age=0',
]); ]);
} }

View File

@@ -4,10 +4,21 @@ namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Livewire\Settings\XmlExchange; use App\Livewire\Settings\XmlExchange;
use App\Services\BackupOperationState;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str;
class XmlExchangeUploadController extends Controller class XmlExchangeUploadController extends Controller
{ {
/**
* Quanto resta occupato il posto unico se la richiesta di import muore senza
* liberarlo (timeout, errore fatale: casi in cui il `finally` non gira).
* L'import e' sincrono e PHP lo ferma comunque dopo max_execution_time
* (120 s): dieci minuti bastano a coprirlo, invece dell'ora intera pensata
* per i job in coda.
*/
private const TTL_POSTO_IMPORT_XML = 600;
public function convertSqlToXml(Request $request) public function convertSqlToXml(Request $request)
{ {
$request->validate([ $request->validate([
@@ -51,13 +62,44 @@ class XmlExchangeUploadController extends Controller
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']); return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
} }
$exchange = new XmlExchange(); // L'import svuota e riscrive le tabelle: non puo' girare insieme a un
$result = $exchange->importXmlFromContent($content); // ripristino (che fa lo stesso), a un azzeramento, a un backup che le sta
// fotografando o a un import PDF che sta aggiornando i territori. Il
// ripristino chiama importXmlFromContent() per conto suo, gia' dentro il
// proprio posto: per questo il blocco sta qui, sulla porta d'ingresso, e
// non dentro l'import.
$stato = app(BackupOperationState::class);
$operationId = (string) Str::uuid();
$inCorso = $stato->occupa($operationId, BackupOperationState::TIPO_IMPORT_XML, 'Import XML', self::TTL_POSTO_IMPORT_XML);
if ($inCorso !== null) {
return back()->withErrors(['xmlImport' => $stato->messaggioOccupato($inCorso) . ' Nessun dato è stato modificato.']);
}
$stato->markRunning($operationId);
try {
$exchange = new XmlExchange();
$fileDiPrima = XmlExchange::fileDeiTerritori();
$result = $exchange->importXmlFromContent($content);
if (! isset($result['error'])) {
$result['stats']['file_rimossi'] = XmlExchange::eliminaFileNonPiuUsati($fileDiPrima);
}
} catch (\Throwable $e) {
$stato->markFailed($operationId, $e->getMessage());
throw $e;
}
if (isset($result['error'])) { if (isset($result['error'])) {
$stato->markFailed($operationId, $result['error']);
return back()->withErrors(['xmlImport' => $result['error']]); return back()->withErrors(['xmlImport' => $result['error']]);
} }
$stato->markCompleted($operationId);
$stats = $result['stats']; $stats = $result['stats'];
$issues = $result['issues']; $issues = $result['issues'];
@@ -65,6 +107,9 @@ class XmlExchangeUploadController extends Controller
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) { if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.'; $message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
} }
if (($stats['file_rimossi'] ?? 0) > 0) {
$message .= ' Eliminati ' . $stats['file_rimossi'] . ' file (PDF e miniature) dei territori sostituiti, che i dati importati non usano più.';
}
return redirect()->route('xml.exchange') return redirect()->route('xml.exchange')
->with('success', $message) ->with('success', $message)

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use App\Models\Territorio;
use App\Models\TerritorioPdfVersione;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
/**
* PDF, miniature e versioni archiviate dei territori, solo per chi è dentro.
*
* I file stanno sul disco `public`, ma nginx non serve più le cartelle
* `territori-*` (docker/nginx/default.conf): prima chiunque scaricava
* /storage/territori-pdf/territorio-12-34.pdf senza login, con nomi facili da
* indovinare, e Cloudflare li teneva in cache pubblica per una settimana. I
* proclamatori continuano a riceverli dal link con codice e scadenza
* (AssignmentPdfController); da qui passano solo gli utenti autenticati, con i
* permessi della pagina che li mostra.
*/
class TerritorioFileController extends Controller
{
public function pdf(Territorio $territorio): Response
{
return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf');
}
public function miniatura(Territorio $territorio): Response
{
return $this->servi($territorio->thumbnail_path, null, null);
}
public function versione(TerritorioPdfVersione $versione): Response
{
$numero = $versione->territorio?->numero ?? 'senza-territorio';
return $this->servi($versione->pdf_path, 'application/pdf', "territorio-{$numero}-versione-{$versione->id}.pdf");
}
private function servi(?string $percorso, ?string $tipo, ?string $nome): Response
{
$disco = Storage::disk('public');
abort_unless($percorso && $disco->exists($percorso), 404);
$intestazioni = [];
if ($tipo) {
$intestazioni['Content-Type'] = $tipo;
}
if ($nome) {
$intestazioni['Content-Disposition'] = 'inline; filename="' . $nome . '"';
}
// Privata: niente cache intermedie (Cloudflare). Va impostata dopo,
// perché BinaryFileResponse si dichiara pubblica da sola e
// sovrascriverebbe un Cache-Control passato fra le intestazioni.
// L'indirizzo porta l'impronta del percorso (?v=), che cambia col file.
return response()->file($disco->path($percorso), $intestazioni)
->setPrivate()
->setMaxAge(86400);
}
}

View File

@@ -26,6 +26,9 @@ class RequirePasswordChange
'password.change', 'password.change',
'password.change.store', 'password.change.store',
'logout', 'logout',
// Il link della mail di conferma: aperto in quel momento andrebbe
// perso, e la persona dovrebbe chiederne un altro.
'verification.verify',
]; ];
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response

View File

@@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Livewire\Settings\XmlExchange; use App\Livewire\Settings\XmlExchange;
use App\Services\BackupArchive; use App\Services\BackupArchive;
use App\Services\BackupOperationState; use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -44,7 +45,7 @@ class CreateBackupArchive implements ShouldQueue
) { ) {
} }
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
{ {
$stato->markRunning($this->operationId); $stato->markRunning($this->operationId);
$stato->fase($this->operationId, 'Lettura dei dati gestionali'); $stato->fase($this->operationId, 'Lettura dei dati gestionali');
@@ -55,11 +56,13 @@ class CreateBackupArchive implements ShouldQueue
$this->rimuoviArchiviVecchi($archivio); $this->rimuoviArchiviVecchi($archivio);
$xml = $exchange->currentXmlBackup(); $xml = $exchange->currentXmlBackup();
$datiPiantine = $piantine->esporta();
$stato->fase($this->operationId, 'Compressione dei file'); $stato->fase($this->operationId, 'Compressione dei file');
$archivio->create( $archivio->create(
$xml, $xml,
$datiPiantine,
$destinazione, $destinazione,
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale) fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
); );

View File

@@ -4,6 +4,8 @@ namespace App\Jobs;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\User; use App\Models\User;
use App\Services\BackupOperationState;
use App\Services\Piantine\PiantinaPdfService;
use App\Services\TerritorioPdfImportState; use App\Services\TerritorioPdfImportState;
use App\Services\TerritorioThumbnailService; use App\Services\TerritorioThumbnailService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@@ -35,14 +37,21 @@ class ImportTerritoryPdfFolder implements ShouldQueue
) { ) {
} }
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService): void public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService, BackupOperationState $postoUnico, PiantinaPdfService $pdfService): void
{ {
$stateService->markRunning($this->importId); $stateService->markRunning($this->importId);
$postoUnico->markRunning($this->importId);
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.'); $stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.');
// Una chiave puo' raccogliere piu' territori ("12B" e "12 B"): non si
// tiene l'ultimo arrivato, si tengono tutti, cosi' l'abbinamento li vede
// e scarta il file come ambiguo invece di sceglierne uno a caso.
$territoriMap = []; $territoriMap = [];
foreach (Territorio::withTrashed()->get() as $territorio) { foreach (Territorio::withTrashed()->get() as $territorio) {
$territoriMap[$this->normalizeTerritoryNumber($territorio->numero)] = $territorio; $chiave = $this->chiaveTerritorio((string) $territorio->numero);
if ($chiave !== '') {
$territoriMap[$chiave][] = $territorio;
}
} }
$actor = User::find($this->actorId); $actor = User::find($this->actorId);
@@ -78,9 +87,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue
continue; continue;
} }
$normalizedNumber = $territoryMatch['normalized_number']; if (($territoryMatch['type'] ?? 'single') === 'unknown-suffix') {
$codici = implode(', ', $territoryMatch['codes']);
$esistenti = implode(', ', $territoryMatch['matched_numbers']);
$stateService->increment($this->importId, 'skipped');
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - il nome indica il territorio ' . $codici . ', che non esiste (esiste ' . $esistenti . '): file non associato, per non sostituire il PDF di un altro territorio.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'unknown-suffix',
'message' => 'Il nome indica il territorio ' . $codici . ', che non esiste: non associato a ' . $esistenti . '.',
'matched_numbers' => $territoryMatch['matched_numbers'],
]);
continue;
}
$matchedNumber = $territoryMatch['matched_number']; $matchedNumber = $territoryMatch['matched_number'];
$territorio = $territoryMatch['territorio']; $territorio = $territoryMatch['territorio'];
// Per id, non per numero: due file che nominano lo stesso
// territorio scritto in due modi ("12B.pdf", "12 B.pdf") sono
// comunque un doppione.
$normalizedNumber = $territorio->id;
if (isset($seenNumbers[$normalizedNumber])) { if (isset($seenNumbers[$normalizedNumber])) {
$stateService->increment($this->importId, 'skipped'); $stateService->increment($this->importId, 'skipped');
@@ -111,24 +137,27 @@ class ImportTerritoryPdfFolder implements ShouldQueue
} }
try { try {
if ($territorio->pdf_path) {
Storage::disk('public')->delete($territorio->pdf_path);
}
if ($territorio->thumbnail_path) {
$thumbnailService->delete($territorio->thumbnail_path);
}
// The territory id is part of the filename because the slug // The territory id is part of the filename because the slug
// alone is not unique: "1 A" and "1-A" are distinct // alone is not unique: "1 A" and "1-A" are distinct
// territory numbers that both slug to "territorio-1-a". // territory numbers that both slug to "territorio-1-a".
// Without it the second import overwrites the first's PDF // Without it the second import overwrites the first's PDF
// and both rows point at one file — then removing the PDF // and both rows point at one file — then removing the PDF
// from either territory breaks the other. // from either territory breaks the other.
//
// Il nuovo file si scrive per primo, con un nome che non
// coincide con quello in vigore; solo dopo il vecchio va
// nello storico, come per le piantine. Prima si cancellava
// il vecchio e poi si scriveva: con una scrittura fallita il
// territorio restava senza PDF, e un import sbagliato non si
// poteva annullare.
$storedFilename = Str::slug('territorio-' . $territorio->numero) $storedFilename = Str::slug('territorio-' . $territorio->numero)
. '-' . $territorio->id . '.pdf'; . '-' . $territorio->id . '-' . now()->format('YmdHis') . '.pdf';
$publicPath = 'territori-pdf/' . $storedFilename; $publicPath = 'territori-pdf/' . $storedFilename;
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath)); if (! Storage::disk('public')->put($publicPath, file_get_contents($sourcePath))) {
throw new \RuntimeException('impossibile scrivere il file sul disco: il PDF in vigore non è stato toccato.');
}
$pdfService->archiviaCorrente($territorio, 'import');
$thumbnailPath = $thumbnailService->generate($publicPath); $thumbnailPath = $thumbnailService->generate($publicPath);
$territorio->update([ $territorio->update([
@@ -166,10 +195,12 @@ class ImportTerritoryPdfFolder implements ShouldQueue
$stateService->appendLog($this->importId, 'Import completato.'); $stateService->appendLog($this->importId, 'Import completato.');
$stateService->markCompleted($this->importId); $stateService->markCompleted($this->importId);
$postoUnico->markCompleted($this->importId);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$stateService->increment($this->importId, 'errors'); $stateService->increment($this->importId, 'errors');
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage()); $stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
$stateService->markFailed($this->importId); $stateService->markFailed($this->importId);
$postoUnico->markFailed($this->importId, $exception->getMessage());
throw $exception; throw $exception;
} finally { } finally {
@@ -183,6 +214,10 @@ class ImportTerritoryPdfFolder implements ShouldQueue
* Without this the import state is stranded in "running" — the UI polls it * Without this the import state is stranded in "running" — the UI polls it
* until the 24h cache entry expires — and the uploaded temp directory is * until the 24h cache entry expires — and the uploaded temp directory is
* never reclaimed, since handle()'s finally never executes. * never reclaimed, since handle()'s finally never executes.
*
* Libera anche il posto unico: senza, un import ucciso dal timeout
* lascerebbe backup, ripristino e azzeramento bloccati per l'ora intera
* del TTL, con un'operazione che non avanza piu'.
*/ */
public function failed(?\Throwable $exception): void public function failed(?\Throwable $exception): void
{ {
@@ -193,79 +228,128 @@ class ImportTerritoryPdfFolder implements ShouldQueue
'[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout') '[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
); );
$stateService->markFailed($this->importId); $stateService->markFailed($this->importId);
app(BackupOperationState::class)->markFailed(
$this->importId,
'Import PDF terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
);
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId); Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
} }
/**
* Abbina il nome di un file al territorio, oppure spiega perche' non lo fa.
*
* La regola: nel nome si cercano i "numeri di territorio", cioe' cifre
* seguite al massimo da UNA lettera, attaccata o separata da uno spazio, un
* trattino, un trattino basso o un punto ("12", "012", "12B", "12 B",
* "12-b"). Le forme scritte in modo diverso dello stesso numero sono lo
* stesso numero: "12 B", "12B" e "12-B" sono tutte 12B, mai 12. Il numero
* deve stare da solo — "T12" o "12AB" non contano — e quelli che non
* corrispondono a nessun territorio (un anno, un CAP) vengono ignorati.
*
* Prima bastava che il numero di un territorio *comparisse* nel nome: "1-A"
* conteneva "1", e in assenza di un territorio 1A il PDF finiva sul
* territorio 1, sostituendone la mappa senza avvisare. Adesso una lettera
* dopo il numero fa parte del numero, e se quel territorio non esiste il
* file si scarta: si nomina il territorio 1 nel messaggio, ma non lo si
* tocca.
*
* Il file si scarta anche quando l'abbinamento non e' univoco: due numeri
* di territori esistenti nello stesso nome, o due territori che si
* scrivono diversi ma sono lo stesso numero ("12B" e "12 B"). Sostituire
* la mappa sbagliata e' peggio che non sostituirne nessuna: un file
* scartato si ricarica, un PDF sovrascritto non torna.
*
* I territori con numeri di altra forma (lettere davanti, piu' lettere in
* fondo) non si abbinano da nome file: il PDF va caricato dalla loro
* scheda.
*/
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
{ {
if ($territoriMap === []) { $trovati = [];
return null; $suffissiSconosciuti = [];
}
$basename = pathinfo($filename, PATHINFO_FILENAME); foreach ($this->numeriNelNomeFile($filename) as $codice) {
$normalizedBasename = mb_strtoupper($basename); if (isset($territoriMap[$codice])) {
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename); foreach ($territoriMap[$codice] as $territorio) {
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename)); $trovati[$territorio->id] = $territorio;
}
$territoryKeys = array_keys($territoriMap);
usort($territoryKeys, function (string $left, string $right) {
$lengthComparison = mb_strlen($right) <=> mb_strlen($left);
if ($lengthComparison !== 0) {
return $lengthComparison;
}
return strnatcasecmp($left, $right);
});
$matches = [];
foreach ($territoryKeys as $normalizedNumber) {
if (! $this->filenameContainsTerritoryNumber($normalizedBasename, $normalizedNumber)) {
continue; continue;
} }
$matches[] = [ // "1A" che non esiste, mentre 1 si': e' il caso che prima
'normalized_number' => $normalizedNumber, // sovrascriveva il territorio 1. Lo si annota per spiegarlo.
'matched_number' => $territoriMap[$normalizedNumber]->numero, $base = preg_replace('/\p{L}$/u', '', $codice);
'territorio' => $territoriMap[$normalizedNumber], if ($base !== $codice && isset($territoriMap[$base])) {
$suffissiSconosciuti[$codice] = $territoriMap[$base];
}
}
$numeri = fn (array $territori) => array_values(array_unique(array_map(fn (Territorio $t) => $t->numero, $territori)));
if (count($trovati) > 1 || ($trovati !== [] && $suffissiSconosciuti !== [])) {
return [
'type' => 'ambiguous',
'matched_numbers' => $numeri(array_merge(array_values($trovati), ...array_values($suffissiSconosciuti))),
]; ];
} }
if ($matches === []) { if ($trovati === [] && $suffissiSconosciuti !== []) {
return [
'type' => 'unknown-suffix',
'codes' => array_keys($suffissiSconosciuti),
'matched_numbers' => $numeri(array_merge(...array_values($suffissiSconosciuti))),
];
}
if ($trovati === []) {
return null; return null;
} }
if (count($matches) > 1) { $territorio = reset($trovati);
return [
'type' => 'ambiguous',
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
];
}
return $matches[0]; return [
'type' => 'single',
'normalized_number' => $this->chiaveTerritorio($territorio->numero),
'matched_number' => $territorio->numero,
'territorio' => $territorio,
];
} }
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool /**
* I numeri di territorio presenti nel nome del file, gia' nella forma di
* chiaveTerritorio(). Vedi la regola in resolveTerritoryFromFilename().
*
* @return array<int, string>
*/
protected function numeriNelNomeFile(string $filename): array
{ {
$escapedNumber = preg_quote($normalizedNumber, '/'); $nome = mb_strtoupper(pathinfo($filename, PATHINFO_FILENAME));
if (preg_match('/^\d+$/', $normalizedNumber)) { // Il lookahead finale e' quello che separa "12 B" (numero 12B) da
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/'); // "12 Via Roma" (numero 12): la lettera vale come parte del numero solo
// se dopo di lei la parola finisce.
preg_match_all('/(?<![\p{L}\p{N}])(\d+)(?:[ _.\-]?(\p{L}))?(?![\p{L}\p{N}])/u', $nome, $trovati, PREG_SET_ORDER);
$codici = [];
foreach ($trovati as $trovato) {
$codici[] = (ltrim($trovato[1], '0') ?: '0') . ($trovato[2] ?? '');
} }
return (bool) preg_match('/(^| )' . $escapedNumber . '(?= |$)/', $normalizedBasename); return array_values(array_unique($codici));
} }
protected function normalizeTerritoryNumber(string $number): string /**
* Forma di confronto di un numero di territorio: maiuscolo, senza
* separatori, senza zeri iniziali. "12 B", "12-b" e "012B" diventano tutti
* "12B"; due territori con la stessa chiave rendono ambiguo ogni file che
* la nomina.
*/
protected function chiaveTerritorio(string $numero): string
{ {
$normalized = preg_replace('/\s+/', ' ', trim(mb_strtoupper($number))); $chiave = preg_replace('/[^\p{L}\p{N}]+/u', '', mb_strtoupper($numero));
if ($normalized !== '' && preg_match('/^\d+$/', $normalized)) { return preg_replace('/(?<!\d)0+(?=\d)/', '', $chiave);
return ltrim($normalized, '0') ?: '0';
}
return $normalized;
} }
} }

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Services\Piantine\PubblicazionePiantina;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Prepara l'anteprima di una pubblicazione (immagine HD + PDF) fuori dalla
* richiesta web. Non modifica niente: lo fa solo la conferma.
*/
class PreparaAnteprimaPiantina implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** Ripetere non serve: un errore va mostrato, e si riprova dal pulsante. */
public int $tries = 1;
/** Il rendering più il margine per comporre il PDF. */
public int $timeout;
public function __construct(public string $token)
{
$this->timeout = (int) config('piantine.renderer_timeout') + 60;
}
public function handle(PubblicazionePiantina $pubblicazione): void
{
$pubblicazione->prepara($this->token);
}
/** Job ucciso (timeout, worker fermato): l'anteprima non resta "in corso" per sempre. */
public function failed(?\Throwable $e): void
{
app(PubblicazionePiantina::class)->segnaErrore(
$this->token,
'La preparazione dell\'anteprima si è interrotta. Riprova.'
);
}
}

View File

@@ -8,6 +8,8 @@ use App\Models\Campagna;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\Tipologia; use App\Models\Tipologia;
use App\Models\User; use App\Models\User;
use App\Models\Zona; use App\Models\Zona;
@@ -21,6 +23,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/** /**
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale, * Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
@@ -72,12 +75,17 @@ class ResetApplication implements ShouldQueue
$stato->avanza($this->operationId, 1); $stato->avanza($this->operationId, 1);
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.'); $stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 8); $stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10);
DB::transaction(function () use ($stato) { DB::transaction(function () use ($stato) {
$passi = [ $passi = [
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(), 'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
'Campagne' => fn () => Campagna::query()->delete(), 'Campagne' => fn () => Campagna::query()->delete(),
// Prima dei territori, e per esteso: le piantine hanno la
// chiave a null on delete, quindi sopravvivevano scollegate
// e con l'immagine che puntava a un file appena cancellato.
'Piantine' => fn () => TerritorioMappa::query()->delete(),
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(), 'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(), 'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
'Tipologie' => fn () => Tipologia::query()->delete(), 'Tipologie' => fn () => Tipologia::query()->delete(),
@@ -100,6 +108,13 @@ class ResetApplication implements ShouldQueue
// ricreare la riga. // ricreare la riga.
Setting::flushCache(); Setting::flushCache();
// Area della città e anteprime delle pubblicazioni stanno sul disco
// privato, che svuotaDiscoPubblico() non tocca: restavano lì e
// l'editor ripartiva con la città di prima.
Storage::disk('local')->deleteDirectory('piantine');
Storage::disk('local')->deleteDirectory('piantine-anteprime');
$stato->log($this->operationId, 'Area della città e anteprime delle piantine rimosse.');
$stato->fase($this->operationId, 'Rimozione degli altri utenti'); $stato->fase($this->operationId, 'Rimozione degli altri utenti');
$utentiRimossi = $this->rimuoviAltriUtenti($stato); $utentiRimossi = $this->rimuoviAltriUtenti($stato);

View File

@@ -3,8 +3,10 @@
namespace App\Jobs; namespace App\Jobs;
use App\Livewire\Settings\XmlExchange; use App\Livewire\Settings\XmlExchange;
use App\Models\Setting;
use App\Services\BackupArchive; use App\Services\BackupArchive;
use App\Services\BackupOperationState; use App\Services\BackupOperationState;
use App\Services\Piantine\PiantineBackup;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,6 +16,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use RuntimeException;
use Spatie\Permission\PermissionRegistrar; use Spatie\Permission\PermissionRegistrar;
/** /**
@@ -41,14 +44,35 @@ class RestoreBackupArchive implements ShouldQueue
) { ) {
} }
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void /**
* Il ripristino e' tutto o niente. Tre tempi:
*
* 1. estrazione e verifica dell'archivio nello staging, piantine.json
* compreso: un archivio rotto si ferma qui, a installazione intatta;
* 2. un'unica transazione con l'import XML, le tabelle delle piantine, il
* blocco degli account e — per ultimo, ancora dentro — lo scambio dei
* file. Se lo scambio fallisce si disfa da solo e la transazione si
* annulla; se fallisce il commit, i file si rimettono a posto qui;
* 3. a commit avvenuto, si buttano i file precedenti.
*
* Prima import, blocco account e file erano tre passi separati: un errore
* sui file lasciava il database gia' riscritto con il disco a meta', e un
* errore sul blocco lasciava attive le password del backup.
*
* Niente TRUNCATE in nessuno dei passi: su MariaDB e' DDL e fa commit
* implicito, chiudendo la transazione a meta'. L'import XML e
* PiantineBackup cancellano con DELETE.
*/
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
{ {
$stato->markRunning($this->operationId); $stato->markRunning($this->operationId);
$staging = null; $staging = null;
$scambio = null;
$confermato = false;
try { try {
$stato->fase($this->operationId, 'Estrazione dell\'archivio'); $stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio');
$estratto = $archivio->extract( $estratto = $archivio->extract(
$this->zipPath, $this->zipPath,
@@ -56,33 +80,46 @@ class RestoreBackupArchive implements ShouldQueue
); );
$staging = $estratto['staging']; $staging = $estratto['staging'];
$piantine->valida($estratto['piantine']);
$stato->fase($this->operationId, 'Riscrittura del database', 1); $stato->fase($this->operationId, 'Riscrittura del database', 1);
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni // importXmlFromContent() attribuisce a auth()->id() le assegnazioni
// il cui autore non e' nel file. Qui non c'e' sessione, quindi // il cui autore non e' nel file. Qui non c'e' sessione, quindi
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul // l'utente va reimpostato a mano: senza, l'audit finirebbe sul
// primo id disponibile invece che su chi ha avviato il ripristino. // primo id disponibile invece che su chi ha avviato il ripristino.
//
// E' anche cio' che tiene dentro chi ripristina: l'import riconosce
// l'attore e ne conserva hash della password, remember_token e
// ruolo di amministratore. Con AuthenticateSession attivo, un hash
// cambiato chiuderebbe la sua sessione e lo costringerebbe a
// rientrare con la password dell'epoca del backup, che puo' non
// ricordare. Se il suo account nel backup non c'e', l'import (che
// fa upsert per email, senza cancellare nessuno) non lo tocca
// affatto: resta con password e ruoli attuali.
Auth::onceUsingId($this->actorId); Auth::onceUsingId($this->actorId);
$result = $exchange->importXmlFromContent($estratto['xml']); DB::transaction(function () use ($stato, $archivio, $exchange, $piantine, $estratto, &$result, &$esitoPiantine, &$utenti, &$scambio) {
// La transazione dell'import diventa un savepoint di questa:
// il suo commit non e' piu' definitivo.
$result = $exchange->importXmlFromContent($estratto['xml']);
if (isset($result['error'])) { if (isset($result['error'])) {
$stato->markFailed($this->operationId, $result['error']); throw new RuntimeException($result['error']);
}
return; $esitoPiantine = $piantine->ripristina($estratto['piantine']);
}
$stato->avanza($this->operationId, 1, 1); $utenti = $this->richiediCambioPasswordATutti();
$stato->fase($this->operationId, 'Ripristino dei file'); $stato->avanza($this->operationId, 1, 1);
$stato->fase($this->operationId, 'Sostituzione dei file', 1);
$fileRipristinati = $archivio->restoreFiles( $scambio = $archivio->scambiaFile($estratto);
$staging, });
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
);
$stato->fase($this->operationId, 'Blocco degli account', 1); $confermato = true;
$utenti = $this->richiediCambioPasswordATutti(); $archivio->confermaScambio($scambio);
// L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la // L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia' // mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
@@ -92,8 +129,26 @@ class RestoreBackupArchive implements ShouldQueue
$stato->avanza($this->operationId, 1, 1); $stato->avanza($this->operationId, 1, 1);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$messaggio = $e->getMessage();
// Lo scambio era riuscito ma il commit no: il database e' tornato
// indietro, i file devono seguirlo.
if ($scambio !== null && ! $confermato) {
try {
$archivio->annullaScambio($scambio, $e);
} catch (\Throwable $annullamento) {
$messaggio = $annullamento->getMessage();
}
}
// La transazione annullata non svuota le cache in memoria del
// processo: Setting terrebbe l'istanza creata dall'import (una
// riga che non esiste piu') e Spatie i ruoli letti durante l'import.
Setting::flushCache();
app(PermissionRegistrar::class)->forgetCachedPermissions();
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]); Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
$stato->markFailed($this->operationId, $e->getMessage()); $stato->markFailed($this->operationId, $messaggio);
return; return;
} finally { } finally {
@@ -107,17 +162,31 @@ class RestoreBackupArchive implements ShouldQueue
} }
$stats = $result['stats']; $stats = $result['stats'];
$stats['piantine_ripristinate'] = $esitoPiantine['mappe'];
$stats['versioni_pdf_ripristinate'] = $esitoPiantine['versioni'];
$fileRipristinati = $scambio['file'];
$messaggio = sprintf( $messaggio = sprintf(
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. ' 'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni, %d piantine e %d file ripristinati. '
. 'Per sicurezza %s impostare una nuova password.', . 'Per sicurezza %s impostare una nuova password.',
$stats['territori_importati'] ?? 0, $stats['territori_importati'] ?? 0,
$stats['proclamatori_importati'] ?? 0, $stats['proclamatori_importati'] ?? 0,
$stats['assegnazioni_importate'] ?? 0, $stats['assegnazioni_importate'] ?? 0,
$esitoPiantine['mappe'],
$fileRipristinati, $fileRipristinati,
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono' $utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono'
); );
// Un archivio di formato 1 non conteneva le piantine: va detto, o
// l'amministratore le cercherebbe senza sapere perche' sono sparite.
if ($estratto['piantine'] === null) {
$stato->log(
$this->operationId,
'Archivio di un formato precedente: non contiene piantine e storico dei PDF, che restano vuoti. '
. 'L\'area KML in uso non e\' stata toccata.'
);
}
// Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la // Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la
// pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso, // pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
// il middleware dirotta l'amministratore sul cambio password. Lo si // il middleware dirotta l'amministratore sul cambio password. Lo si
@@ -127,7 +196,7 @@ class RestoreBackupArchive implements ShouldQueue
$stato->log($this->operationId, $messaggio); $stato->log($this->operationId, $messaggio);
$stato->markCompleted($this->operationId, [ $stato->markCompleted($this->operationId, [
'stats' => $stats, 'stats' => $stats,
'issues' => array_slice($result['issues'], 0, 50), 'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50),
'file_ripristinati' => $fileRipristinati, 'file_ripristinati' => $fileRipristinati,
'messaggio' => $messaggio, 'messaggio' => $messaggio,
]); ]);
@@ -138,17 +207,21 @@ class RestoreBackupArchive implements ShouldQueue
* muore per cause che handle() non intercetta, cosi' il posto unico si * muore per cause che handle() non intercetta, cosi' il posto unico si
* libera subito invece di restare occupato fino alla scadenza. * libera subito invece di restare occupato fino alla scadenza.
* *
* Qui il messaggio dice anche in che stato si e' rimasti: un ripristino * Qui il messaggio dice anche in che stato si e' rimasti. Un processo
* interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi * ucciso a meta' non arriva al commit, e il database annulla da se' la
* con database e file disallineati, e chi legge deve sapere che la strada * transazione rimasta aperta: i dati restano quelli di prima. L'unica
* e' rilanciare il ripristino, non ignorare l'errore. * finestra scoperta e' quella, di pochi millisecondi, fra lo scambio dei
* file e il commit — in quel caso i file precedenti sono ancora in
* storage/app/backup-precedenti/. La strada resta rilanciare il
* ripristino, non ignorare l'errore.
*/ */
public function failed(?\Throwable $e): void public function failed(?\Throwable $e): void
{ {
app(BackupOperationState::class)->markFailed( app(BackupOperationState::class)->markFailed(
$this->operationId, $this->operationId,
($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).') ($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).')
. ' I dati possono essere rimasti a meta\': rilancia il ripristino con lo stesso pacchetto.' . ' Un ripristino interrotto prima della conferma non modifica il database: rilancialo con lo stesso pacchetto. '
. 'Se i file risultano diversi, quelli precedenti sono in storage/app/backup-precedenti/.'
); );
@unlink($this->zipPath); @unlink($this->zipPath);

View File

@@ -8,10 +8,13 @@ use App\Models\Territorio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\DB;
class Assegna extends Component class Assegna extends Component
{ {
/** Chiave di sessione delle assegnazioni appena fatte, in attesa della scelta sul link. */
public const DA_INVIARE = 'assegnazioni_da_inviare';
public ?int $territorio_id = null; public ?int $territorio_id = null;
public ?int $proclamatore_id = null; public ?int $proclamatore_id = null;
public string $assigned_at = ''; public string $assigned_at = '';
@@ -43,45 +46,91 @@ class Assegna extends Component
$territorio = Territorio::findOrFail($this->territorio_id); $territorio = Territorio::findOrFail($this->territorio_id);
// Check territory is available (not currently assigned) // Gli errori vanno sul campo e non in sessione: il messaggio flash
// sta nel telaio della pagina, che Livewire non ridisegna, e il
// rifiuto passerebbe inosservato.
if ($territorio->assegnazioneCorrente) { if ($territorio->assegnazioneCorrente) {
session()->flash('error', "Il territorio {$territorio->numero} è già assegnato a {$territorio->assegnazioneCorrente->proclamatore->nome_completo}."); $this->addError('territorio_id', "Il territorio {$territorio->numero} è già assegnato a {$territorio->assegnazioneCorrente->proclamatore->nome_completo}.");
return; return;
} }
// Check territory is active
if (!$territorio->attivo) { if (!$territorio->attivo) {
session()->flash('error', "Il territorio {$territorio->numero} è inattivo."); $this->addError('territorio_id', "Il territorio {$territorio->numero} è inattivo.");
return; return;
} }
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id); $proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
if (!$proclamatore->attivo) { if (!$proclamatore->attivo) {
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} è inattivo."); $this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
return; return;
} }
$assignedDate = \Carbon\Carbon::parse($this->assigned_at); $assignedDate = \Carbon\Carbon::parse($this->assigned_at);
$annoTeocratico = AnnoTeocratico::perData($assignedDate); $annoTeocratico = AnnoTeocratico::perData($assignedDate);
$assegnazione = Assegnazione::create([ // Controllo e inserimento sotto lo stesso lock sulla riga del
'territorio_id' => $this->territorio_id, // territorio: un doppio clic o due schede aperte non possono più
'proclamatore_id' => $this->proclamatore_id, // superare entrambi il controllo e creare due assegnazioni aperte.
'anno_teocratico_id' => $annoTeocratico->id, $esito = DB::transaction(function () use ($territorio, $assignedDate, $annoTeocratico) {
'assigned_at' => $assignedDate, Territorio::whereKey($territorio->id)->lockForUpdate()->first();
'created_by' => auth()->id(),
]); $conflitto = Assegnazione::sovrappostaA($territorio->id, $assignedDate);
if ($conflitto) {
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}: "
. ($conflitto->returned_at
? 'la data di assegnazione non può essere precedente al suo rientro.'
: 'registrane prima il rientro.');
}
return Assegnazione::create([
'territorio_id' => $this->territorio_id,
'proclamatore_id' => $this->proclamatore_id,
'anno_teocratico_id' => $annoTeocratico->id,
'assigned_at' => $assignedDate,
'created_by' => auth()->id(),
]);
});
if (is_string($esito)) {
$this->addError('assigned_at', $esito);
return;
}
$assegnazione = $esito;
activity()->causedBy(auth()->user()) activity()->causedBy(auth()->user())
->performedOn($assegnazione) ->performedOn($assegnazione)
// L'id, non il nome: nome e cognome sono cifrati nel database, e
// copiati qui in chiaro restavano leggibili anche dopo
// l'anonimizzazione. Il registro mostra il nome di oggi.
->withProperties([ ->withProperties([
'territorio' => $territorio->numero, 'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo, 'proclamatore_id' => $proclamatore->id,
]) ])
->log('assigned'); ->log('assigned');
// Si resta qui: in cima compare il riquadro con il link da mandare al
// proclamatore (WhatsApp) e la scelta "inviato / non ancora". La lista
// sta in sessione, così il riquadro resta finché non si sceglie, anche
// ricaricando la pagina o assegnando altri territori nel frattempo.
$daInviare = session(self::DA_INVIARE, []);
$daInviare[] = $assegnazione->id;
session([self::DA_INVIARE => array_values(array_unique($daInviare))]);
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}."); session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}.");
return $this->redirect(route('territori.show', $territorio), navigate: true); return $this->redirect(route('assegnazioni.assegna'), navigate: true);
}
/** La scelta nel riquadro: il link è stato mandato, oppure non ancora. */
public function esitoInvio(int $assegnazioneId, bool $inviato): void
{
$this->authorize('territori.assign');
$assegnazione = Assegnazione::find($assegnazioneId);
$assegnazione?->forceFill(['link_sent' => $inviato])->saveQuietly();
session([self::DA_INVIARE => array_values(array_diff(session(self::DA_INVIARE, []), [$assegnazioneId]))]);
} }
public function toggleLinkSent(int $assegnazioneId): void public function toggleLinkSent(int $assegnazioneId): void
@@ -98,7 +147,7 @@ class Assegna extends Component
return null; return null;
} }
$t = Territorio::find($this->territorio_id); $t = Territorio::find($this->territorio_id);
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : null; return $t?->miniaturaUrl();
} }
public function render() public function render()
@@ -149,7 +198,19 @@ class Assegna extends Component
// Il link scade alla soglia rientro (giorni_per_smarrito). // Il link scade alla soglia rientro (giorni_per_smarrito).
$sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120)); $sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
// I riquadri in evidenza: solo le assegnazioni ancora aperte (una
// rientrata o cancellata nel frattempo non ha più un link da mandare).
$idDaInviare = session(self::DA_INVIARE, []);
$daInviare = $idDaInviare === [] ? collect() : Assegnazione::aperte()
->whereIn('id', $idDaInviare)
->with(['territorio', 'proclamatore'])
->get();
if ($daInviare->count() !== count($idDaInviare)) {
session([self::DA_INVIARE => $daInviare->pluck('id')->all()]);
}
return view('livewire.assegnazioni.assegna', [ return view('livewire.assegnazioni.assegna', [
'daInviare' => $daInviare,
'territoriDisponibili' => $territoriDisponibili, 'territoriDisponibili' => $territoriDisponibili,
'proclamatoriAttivi' => $proclamatoriAttivi, 'proclamatoriAttivi' => $proclamatoriAttivi,
'assegnazioniAperte' => $assegnazioniAperte, 'assegnazioniAperte' => $assegnazioniAperte,

View File

@@ -92,7 +92,7 @@ class Rientra extends Component
->performedOn($this->assegnazione) ->performedOn($this->assegnazione)
->withProperties([ ->withProperties([
'territorio' => $territorio->numero, 'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo, 'proclamatore_id' => $proclamatore->id, // vedi Assegna: niente nomi in chiaro
'giorni' => $this->assegnazione->giorni, 'giorni' => $this->assegnazione->giorni,
'campagna' => $this->counted_in_campaign, 'campagna' => $this->counted_in_campaign,
]) ])

View File

@@ -48,8 +48,22 @@ class AuditLog extends Component
$events = Activity::select('description')->distinct()->pluck('description'); $events = Activity::select('description')->distinct()->pluck('description');
$users = \App\Models\User::orderBy('name')->get(); $users = \App\Models\User::orderBy('name')->get();
$activities = $query->paginate(30);
// Il log conserva solo l'id del proclamatore: il nome si legge adesso,
// così chi è stato anonimizzato compare come tale anche nello storico.
$idProclamatori = $activities->getCollection()
->map(fn ($a) => data_get($a->properties, 'proclamatore_id'))
->filter()
->unique();
$nomiProclamatori = \App\Models\Proclamatore::withTrashed()
->whereIn('id', $idProclamatori)
->get()
->mapWithKeys(fn ($p) => [$p->id => $p->nome_completo]);
return view('livewire.audit-log', [ return view('livewire.audit-log', [
'activities' => $query->paginate(30), 'activities' => $activities,
'nomiProclamatori' => $nomiProclamatori,
'events' => $events, 'events' => $events,
'users' => $users, 'users' => $users,
]); ]);

View File

@@ -2,12 +2,14 @@
namespace App\Livewire\Campagne; namespace App\Livewire\Campagne;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Campagna; use App\Models\Campagna;
class CampagnaIndex extends Component class CampagnaIndex extends Component
{ {
use Avvisa;
use WithPagination; use WithPagination;
public function deleteCampagna(int $id) public function deleteCampagna(int $id)
@@ -19,7 +21,7 @@ class CampagnaIndex extends Component
$campagna = Campagna::findOrFail($id); $campagna = Campagna::findOrFail($id);
$campagna->delete(); $campagna->delete();
session()->flash('success', "Campagna '{$campagna->descrizione}' eliminata."); $this->avvisa("Campagna '{$campagna->descrizione}' eliminata.");
} }
public function render() public function render()

View File

@@ -18,12 +18,12 @@ class CampagnaShow extends Component
public function render() public function render()
{ {
// Assignments counted for this campaign: // Le assegnazioni contate per la campagna, cioè quelle per cui al
// - assigned on or after campaign start // rientro si è risposto sì (campagnaApplicabile propone anche quelle
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed) // uscite poco prima dell'inizio e rientrate durante). Le stesse della
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id) // percentuale: prima qui si scartavano quelle uscite prima dell'inizio,
->where('counted_in_campaign', true) // e l'elenco era più corto di quanto la percentuale contasse.
->where('assigned_at', '>=', $this->campagna->start_date) $conteggiate = $this->campagna->assegnazioniConteggiate()
->with(['territorio', 'proclamatore']) ->with(['territorio', 'proclamatore'])
->orderBy('returned_at') ->orderBy('returned_at')
->get(); ->get();

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Concerns;
/**
* Avvisi a comparsa che funzionano dopo un'azione Livewire.
*
* session()->flash() non basta: il messaggio sta nel telaio della pagina, e
* dopo un'azione Livewire ridisegna solo il componente. L'avviso compariva
* solo al caricamento completo successivo, fuori contesto, e un salvataggio
* rifiutato sembrava riuscito. Qui l'avviso parte come evento del browser e
* lo raccoglie <x-avvisi> nel telaio, che resta sempre in pagina.
*/
trait Avvisa
{
protected function avvisa(string $testo, string $tipo = 'ok'): void
{
$this->dispatch('avviso', tipo: $tipo === 'errore' ? 'errore' : 'ok', testo: $testo);
}
protected function avvisaErrore(string $testo): void
{
$this->avvisa($testo, 'errore');
}
}

View File

@@ -174,22 +174,46 @@ trait SegueOperazioneBackup
return true; return true;
} }
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha $this->rifiutaPerOccupato($inCorso);
// 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; return false;
} }
protected function nuovaOperazione(string $tipo): string /**
* Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
* premuto il pulsante vuole comunque vedere a che punto e'. Il messaggio
* dice quale operazione occupa il posto, perche' puo' essere un import
* avviato da un'altra zona della pagina, o da un altro amministratore.
*/
private function rifiutaPerOccupato(string $inCorso): void
{
$this->operationId = $inCorso;
$this->logSpediti = 0;
$this->aggiorna();
$this->erroreOperazione = app(BackupOperationState::class)->messaggioOccupato($inCorso);
}
/**
* Prende il posto e crea l'operazione; null se il posto e' occupato.
*
* postoLibero() resta il controllo rapido prima di validare il resto, ma fra
* quel controllo e questo passo un'altra richiesta puo' arrivare prima — una
* seconda scheda, un import partito da un'altra zona. E' qui, sotto il mutex
* di BackupOperationState::occupa(), che si decide davvero.
*/
protected function nuovaOperazione(string $tipo): ?string
{ {
$operationId = (string) Str::uuid(); $operationId = (string) Str::uuid();
$stato = app(BackupOperationState::class); $stato = app(BackupOperationState::class);
$stato->initialize($operationId, $tipo, 'In coda'); $inCorso = $stato->occupa($operationId, $tipo, 'In coda');
if ($inCorso !== null) {
$this->rifiutaPerOccupato($inCorso);
return null;
}
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.'); $stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
$this->logSpediti = 0; $this->logSpediti = 0;

View File

@@ -2,45 +2,54 @@
namespace App\Livewire\Piantine; namespace App\Livewire\Piantine;
use App\Livewire\Concerns\Avvisa;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\TerritorioMappa; use App\Models\TerritorioMappa;
use App\Models\Tipologia; use App\Models\Tipologia;
use App\Models\Zona; use App\Models\Zona;
use App\Services\Piantine\PiantinaRenderer;
use App\Services\Piantine\PiantineArea; use App\Services\Piantine\PiantineArea;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
/** /**
* Editor delle piantine a schermo intero (solo amministratore). * Editor delle piantine a schermo intero (solo amministratore).
* *
* Una pagina sola con la mappa grande quanto la finestra e i comandi in una * Tutto quello che si fa qui è bozza e si salva da solo: il confine a ogni
* barra sottile in alto: tagliare un confine su una cartografia larga un terzo * disegno o spostamento di vertice, il nome e il collegamento appena cambiano.
* di schermo significa sbagliarlo, e questo disegno finisce stampato su un * La bozza non tocca il PDF del territorio, che cambia solo con "Pubblica"
* modulo che poi gira di mano in mano. * (anteprima e conferma, nel componente Pubblicazione). I dati del territorio
* — numero, zona, tipologia, confini — sono invece dati del gestionale e si
* salvano subito, come dalla scheda territorio.
* *
* Il disegno viene prima del numero. Si taglia l'area guardando le strade, e * Disegnare crea sempre una piantina nuova: per cambiare il confine di quella
* solo dopo si compila la scheda del territorio — che può anche non esistere * aperta ci sono "Modifica vertici" e "Ridisegna", espliciti. Prima un
* ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o * disegno fatto con una piantina aperta ne sostituiva il confine in silenzio.
* la tipologia mancano si creano sul posto, senza abbandonare il disegno. *
* Con la mappa si parla per eventi, e solo con il necessario: i dati di tutte
* le piantine li chiede la mappa una volta (datiMappa), poi riceve solo la
* piantina cambiata. Prima ogni richiesta rispediva tutte le geometrie e
* l'area KML, fino a una decina di MB per aprire un pannello.
*/ */
#[Layout('components.layouts.editor')] #[Layout('components.layouts.editor')]
class PiantineEditor extends Component class PiantineEditor extends Component
{ {
/** Piantina aperta, o null quando se ne sta disegnando una nuova. */ use Avvisa;
/** Piantina aperta, o null quando non ce n'è nessuna selezionata. */
#[Locked]
public ?int $mappaId = null; public ?int $mappaId = null;
public string $nome = ''; public string $nome = '';
/** Geometria appena disegnata, non ancora salvata. */ /** Ora dell'ultimo salvataggio automatico, per l'indicazione in testata. */
public ?string $geometriaInAttesa = null; public string $salvatoAlle = '';
public bool $pannelloAperto = false; // ─── Territorio ─────────────────────────────────────────────────────────
// ─── Scheda del territorio ──────────────────────────────────────────────
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */ /** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
public string $territorioScelto = ''; public string $territorioScelto = '';
@@ -53,7 +62,6 @@ class PiantineEditor extends Component
public string $confini = ''; public string $confini = '';
/** Campi di creazione al volo, aperti solo quando servono. */
public string $nuovaZona = ''; public string $nuovaZona = '';
public string $nuovaTipologia = ''; public string $nuovaTipologia = '';
@@ -61,7 +69,7 @@ class PiantineEditor extends Component
public function mount(?int $mappa = null): void public function mount(?int $mappa = null): void
{ {
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) { if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
$this->apri($mappa); $this->carica(TerritorioMappa::with('territorio')->find($mappa));
} }
} }
@@ -73,38 +81,46 @@ class PiantineEditor extends Component
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null); $mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
if (! $mappa) { if (! $mappa) {
$this->avvisaErrore('Questa piantina non esiste più: forse è stata eliminata da un\'altra scheda.');
$this->dispatch('piantina-rimossa', mappaId: (int) $mappaId);
return; return;
} }
$this->mappaId = $mappa->id; $this->carica($mappa);
$this->nome = (string) $mappa->nome; $this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: true);
$this->geometriaInAttesa = null;
$this->pannelloAperto = true;
$this->caricaScheda($mappa->territorio);
$this->aggiornaMappaJs(inquadra: true);
} }
/** "Nuova piantina": si deseleziona e la mappa entra in modalità disegno. */
public function nuova(): void public function nuova(): void
{ {
$this->svuota();
$this->dispatch('piantina-selezionata', mappaId: null, inquadra: false);
$this->dispatch('editor-disegna');
}
private function carica(TerritorioMappa $mappa): void
{
$this->resetValidation();
$this->mappaId = $mappa->id;
$this->nome = (string) $mappa->nome;
$this->salvatoAlle = '';
$this->nuovaZona = '';
$this->nuovaTipologia = '';
$this->caricaTerritorio($mappa->territorio);
}
private function svuota(): void
{
$this->resetValidation();
$this->reset([ $this->reset([
'mappaId', 'nome', 'geometriaInAttesa', 'mappaId', 'nome', 'salvatoAlle',
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini', 'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
'nuovaZona', 'nuovaTipologia', 'nuovaZona', 'nuovaTipologia',
]); ]);
$this->resetValidation();
$this->pannelloAperto = true;
$this->aggiornaMappaJs();
} }
public function chiudiPannello(): void private function caricaTerritorio(?Territorio $territorio): void
{
$this->pannelloAperto = false;
}
/** Riempie i campi della scheda dal territorio collegato (o li svuota). */
private function caricaScheda(?Territorio $territorio): void
{ {
$this->territorioScelto = $territorio ? (string) $territorio->id : ''; $this->territorioScelto = $territorio ? (string) $territorio->id : '';
$this->numero = (string) ($territorio->numero ?? ''); $this->numero = (string) ($territorio->numero ?? '');
@@ -113,12 +129,118 @@ class PiantineEditor extends Component
$this->confini = (string) ($territorio->confini ?? ''); $this->confini = (string) ($territorio->confini ?? '');
} }
/** Cambiare territorio nella tendina ricarica la sua scheda. */ // ─── Confine (bozza, salvataggio automatico) ────────────────────────────
/**
* Arriva dalla mappa dopo un disegno o lo spostamento di un vertice.
* $nuova: il disegno è una piantina nuova (strumento "Disegna"), non il
* confine ridisegnato di quella aperta.
*/
#[On('geometria-modificata')]
public function salvaGeometria($geometry, bool $nuova = false): void
{
if (! $this->geometriaValida($geometry)) {
$this->avvisaErrore('Il confine disegnato non è valido: ridisegnalo.');
return;
}
$geojson = json_encode($geometry);
$mappa = (! $nuova && $this->mappaId) ? TerritorioMappa::find($this->mappaId) : null;
if ($mappa) {
$mappa->update(['geojson' => $geojson]);
} else {
$mappa = TerritorioMappa::create(['geojson' => $geojson]);
$this->carica($mappa);
activity()->causedBy(auth()->user())->log('piantina_creata');
}
$this->salvato($mappa, seleziona: true);
}
/**
* Anelli chiusi di almeno quattro punti con coordinate nei limiti: un
* record malformato faceva fallire il disegno di tutte le piantine.
*/
private function geometriaValida($geometry): bool
{
if (! is_array($geometry) || ! is_array($geometry['coordinates'] ?? null)) {
return false;
}
$poligoni = match ($geometry['type'] ?? null) {
'Polygon' => [$geometry['coordinates']],
'MultiPolygon' => $geometry['coordinates'],
default => null,
};
if (! $poligoni) {
return false;
}
foreach ($poligoni as $anelli) {
if (! is_array($anelli) || $anelli === []) {
return false;
}
foreach ($anelli as $anello) {
if (! is_array($anello) || count($anello) < 4) {
return false;
}
foreach ($anello as $punto) {
if (! is_array($punto) || count($punto) < 2
|| ! is_numeric($punto[0]) || ! is_numeric($punto[1])
|| abs($punto[0]) > 180 || abs($punto[1]) > 90) {
return false;
}
}
}
}
return true;
}
/** Riporta il confine a quello pubblicato. */
public function scartaModifiche(): void
{
$mappa = $this->mappaAperta();
if (! $mappa || ! $mappa->bozzaScartabile()) {
return;
}
$mappa->update(['geojson' => $mappa->geojson_pubblicato]);
$this->salvato($mappa, seleziona: true);
$this->avvisa('Confine riportato a quello pubblicato.');
}
public function updatedNome(): void
{
$mappa = $this->mappaAperta();
if (! $mappa) {
return;
}
$nome = trim($this->nome);
$mappa->update(['nome' => $nome !== '' ? mb_substr($nome, 0, 120) : null]);
$this->salvato($mappa);
}
// ─── Collegamento al territorio ─────────────────────────────────────────
public function updatedTerritorioScelto(string $valore): void public function updatedTerritorioScelto(string $valore): void
{ {
$this->resetValidation(); $this->resetValidation();
$mappa = $this->mappaAperta();
if (! $mappa) {
return;
}
if ($valore === 'nuovo') { if ($valore === 'nuovo') {
// Si compilano i campi e si crea con il pulsante: un territorio
// non nasce da un salvataggio automatico a metà digitazione.
$this->numero = ''; $this->numero = '';
$this->zonaId = ''; $this->zonaId = '';
$this->tipologiaId = ''; $this->tipologiaId = '';
@@ -127,34 +249,158 @@ class PiantineEditor extends Component
return; return;
} }
$this->caricaScheda($valore === '' ? null : Territorio::find($valore)); if ($valore === '') {
} $numero = $mappa->territorio?->numero;
$mappa->update([
'territorio_id' => null,
// Senza territorio serve un'etichetta, altrimenti la piantina
// finisce in elenco come un anonimo "Piantina #12".
'nome' => $mappa->nome ?: ($numero ? "Ex territorio {$numero}" : null),
]);
$this->nome = (string) $mappa->nome;
$this->caricaTerritorio(null);
$this->salvato($mappa);
// ─── Disegno ────────────────────────────────────────────────────────────
#[On('poligono-disegnato')]
public function poligonoDisegnato($geometry): void
{
if (! is_array($geometry)
|| ! in_array($geometry['type'] ?? null, ['Polygon', 'MultiPolygon'], true)
|| ! is_array($geometry['coordinates'] ?? null)) {
return; return;
} }
$this->geometriaInAttesa = json_encode($geometry); $territorio = Territorio::find($valore);
$this->pannelloAperto = true; if (! $territorio) {
$this->addError('territorioScelto', 'Il territorio scelto non esiste più.');
$this->caricaTerritorio($mappa->territorio);
return;
}
$occupata = TerritorioMappa::where('territorio_id', $territorio->id)->whereKeyNot($mappa->id)->exists();
if ($occupata) {
$this->addError('territorioScelto', "Il territorio {$territorio->numero} ha già una piantina.");
$this->caricaTerritorio($mappa->territorio);
return;
}
$mappa->update(['territorio_id' => $territorio->id]);
$this->caricaTerritorio($territorio);
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
$this->salvato($mappa);
} }
public function scartaDisegno(): void public function creaTerritorio(): void
{ {
$this->geometriaInAttesa = null; $mappa = $this->mappaAperta();
$this->aggiornaMappaJs();
if (! $mappa || $this->territorioScelto !== 'nuovo') {
return;
}
$this->numero = trim($this->numero);
$this->confini = trim($this->confini);
$this->validate(
[
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')],
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
'confini' => ['nullable', 'string', 'max:5000'],
],
$this->messaggiTerritorio(),
);
try {
$territorio = DB::transaction(function () use ($mappa) {
$territorio = Territorio::create($this->valoriTerritorio() + ['attivo' => true]);
$mappa->update(['territorio_id' => $territorio->id]);
return $territorio;
});
} catch (\Illuminate\Database\UniqueConstraintViolationException) {
$this->addError('numero', 'Esiste già un territorio con questo numero.');
return;
}
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
$this->caricaTerritorio($territorio);
$this->salvato($mappa);
$this->avvisa("Territorio {$territorio->numero} creato e collegato.");
}
// ─── Dati del territorio (salvati subito nel gestionale) ────────────────
public function updatedNumero(): void
{
$this->salvaCampoTerritorio('numero');
}
public function updatedZonaId(): void
{
$this->salvaCampoTerritorio('zonaId');
}
public function updatedTipologiaId(): void
{
$this->salvaCampoTerritorio('tipologiaId');
}
public function updatedConfini(): void
{
$this->salvaCampoTerritorio('confini');
}
private function salvaCampoTerritorio(string $campo): void
{
// In creazione i campi si raccolgono e basta: li salva creaTerritorio().
$territorio = $this->territorioCollegato();
if (! $territorio) {
return;
}
$this->numero = trim($this->numero);
$regole = [
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')->ignore($territorio->id)],
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
'confini' => ['nullable', 'string', 'max:5000'],
];
$this->validateOnly($campo, $regole, $this->messaggiTerritorio());
// Solo la colonna del campo cambiato: con un altro campo ancora non
// valido a schermo, riscrivere tutto salverebbe anche quel valore.
$colonna = ['numero' => 'numero', 'zonaId' => 'zona_id', 'tipologiaId' => 'tipologia_id', 'confini' => 'confini'][$campo];
$territorio->update([$colonna => $this->valoriTerritorio()[$colonna]]);
$this->salvato($this->mappaAperta());
}
private function valoriTerritorio(): array
{
return [
'numero' => trim($this->numero),
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
];
}
private function messaggiTerritorio(): array
{
return [
'numero.required' => 'Il territorio ha bisogno di un numero.',
'numero.unique' => 'Esiste già un territorio con questo numero.',
'numero.max' => 'Il numero può avere al massimo 20 caratteri.',
'confini.max' => 'I confini possono avere al massimo 5000 caratteri.',
];
} }
// ─── Zona e tipologia create sul posto ────────────────────────────────── // ─── Zona e tipologia create sul posto ──────────────────────────────────
public function creaZona() public function creaZona(): void
{ {
$this->nuovaZona = trim($this->nuovaZona);
$dati = $this->validate( $dati = $this->validate(
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]], ['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
[ [
@@ -163,16 +409,17 @@ class PiantineEditor extends Component
], ],
); );
$zona = Zona::create(['nome' => trim($dati['nuovaZona']), 'attivo' => true]); $zona = Zona::create(['nome' => $dati['nuovaZona'], 'attivo' => true]);
$this->zonaId = (string) $zona->id; $this->zonaId = (string) $zona->id;
$this->nuovaZona = ''; $this->nuovaZona = '';
$this->updatedZonaId();
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata."); $this->avvisa("Zona «{$zona->nome}» creata e selezionata.");
} }
public function creaTipologia() public function creaTipologia(): void
{ {
$this->nuovaTipologia = trim($this->nuovaTipologia);
$dati = $this->validate( $dati = $this->validate(
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]], ['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
[ [
@@ -181,221 +428,155 @@ class PiantineEditor extends Component
], ],
); );
$tipologia = Tipologia::create(['nome' => trim($dati['nuovaTipologia']), 'attivo' => true]); $tipologia = Tipologia::create(['nome' => $dati['nuovaTipologia'], 'attivo' => true]);
$this->tipologiaId = (string) $tipologia->id; $this->tipologiaId = (string) $tipologia->id;
$this->nuovaTipologia = ''; $this->nuovaTipologia = '';
$this->updatedTipologiaId();
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata."); $this->avvisa("Tipologia «{$tipologia->nome}» creata e selezionata.");
} }
// ─── Salvataggio ──────────────────────────────────────────────────────── // ─── Pubblicazione ed eliminazione ──────────────────────────────────────
public function salva() public function pubblica(): void
{ {
$mappa = $this->mappaId ? TerritorioMappa::find($this->mappaId) : null; $mappa = $this->mappaAperta();
if (! $mappa && ! $this->geometriaInAttesa) {
session()->flash('error', 'Disegna il poligono prima di salvare.');
return;
}
$territorio = $this->salvaScheda($mappa);
// salvaScheda() torna false quando la validazione della scheda non
// passa: in quel caso il messaggio è già a schermo e non si tocca nulla.
if ($territorio === false) {
return;
}
$valori = [
'territorio_id' => $territorio?->id,
'nome' => trim($this->nome) !== '' ? trim($this->nome) : null,
];
if ($this->geometriaInAttesa) {
$valori['geojson'] = $this->geometriaInAttesa;
}
if ($mappa) {
$mappa->update($valori);
} else {
$mappa = TerritorioMappa::create($valori);
$this->mappaId = $mappa->id;
}
$this->geometriaInAttesa = null;
activity()->causedBy(auth()->user())->log('piantina_salvata');
session()->flash('success', $mappa->fresh()->load('territorio')->etichetta() . ' salvata.');
$this->aggiornaMappaJs();
}
/**
* Crea o aggiorna il territorio della scheda.
*
* @return Territorio|null|false Il territorio collegato, null se la
* piantina resta scollegata, false se la
* scheda non è valida.
*/
private function salvaScheda(?TerritorioMappa $mappa): Territorio|null|false
{
if ($this->territorioScelto === '') {
return null;
}
$esistente = $this->territorioScelto === 'nuovo'
? null
: Territorio::find($this->territorioScelto);
if ($this->territorioScelto !== 'nuovo' && ! $esistente) {
session()->flash('error', 'Il territorio scelto non esiste più.');
return false;
}
try {
$this->validate(
[
'numero' => [
'required', 'string', 'max:20',
Rule::unique('territori', 'numero')->ignore($esistente?->id),
],
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
'confini' => ['nullable', 'string', 'max:5000'],
],
[
'numero.required' => 'Il territorio ha bisogno di un numero.',
'numero.unique' => 'Esiste già un territorio con questo numero.',
],
['numero' => 'numero'],
);
} catch (\Illuminate\Validation\ValidationException $e) {
$this->pannelloAperto = true;
throw $e;
}
// Un territorio ha una piantina sola.
if ($esistente) {
$occupato = TerritorioMappa::where('territorio_id', $esistente->id)
->when($mappa, fn ($q) => $q->where('id', '!=', $mappa->id))
->exists();
if ($occupato) {
session()->flash('error', "Il territorio {$esistente->numero} ha già una piantina: scollegala prima di assegnargli questa.");
return false;
}
}
$valori = [
'numero' => trim($this->numero),
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
];
if ($esistente) {
$esistente->update($valori);
return $esistente;
}
$territorio = Territorio::create($valori + ['attivo' => true]);
$this->territorioScelto = (string) $territorio->id;
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
return $territorio;
}
// ─── Immagine ed eliminazione ───────────────────────────────────────────
public function generaImmagine(PiantinaRenderer $renderer)
{
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
if (! $mappa) { if (! $mappa) {
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.'); return;
}
if ($motivo = $mappa->motivoNonPubblicabile()) {
$this->avvisaErrore($motivo);
return; return;
} }
try { $this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
$renderer->render($mappa);
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
} }
public function elimina() #[On('piantina-pubblicata')]
public function pubblicata(int $mappaId): void
{ {
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null; $mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
if ($mappa) {
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
}
}
public function elimina(): void
{
$mappa = $this->mappaAperta();
if (! $mappa) { if (! $mappa) {
return; return;
} }
$etichetta = $mappa->etichetta(); $etichetta = $mappa->etichetta();
$id = $mappa->id;
if ($mappa->image_path) { if ($mappa->image_path) {
Storage::disk('public')->delete($mappa->image_path); Storage::disk('public')->delete($mappa->image_path);
} }
$mappa->delete(); $mappa->delete();
$this->nuova(); $this->svuota();
session()->flash('success', "{$etichetta} eliminata."); $this->dispatch('piantina-rimossa', mappaId: $id);
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
} }
// ─── Dati per la mappa ────────────────────────────────────────────────── // ─── Dati per la mappa ──────────────────────────────────────────────────
public function datiMappa(?PiantineArea $area = null): array /** Chiamata una volta dalla mappa all'avvio ($wire.datiMappa()). */
public function datiMappa(PiantineArea $area): array
{ {
$area ??= app(PiantineArea::class);
$piantine = TerritorioMappa::with('territorio')->get()
->filter(fn ($m) => $m->geometria() !== null)
->map(fn ($m) => [
'mappaId' => $m->id,
'etichetta' => $m->etichetta(),
'collegata' => $m->collegata(),
'geometry' => $m->geometria(),
])
->values()
->all();
return [ return [
'area' => $area->area(), 'area' => $area->area(),
'piantine' => $piantine, 'piantine' => TerritorioMappa::with('territorio.zona')->get()
->filter(fn (TerritorioMappa $m) => $m->geometria() !== null)
->map(fn (TerritorioMappa $m) => $this->datiPiantina($m))
->values()
->all(),
'selezionata' => $this->mappaId, 'selezionata' => $this->mappaId,
]; ];
} }
private function aggiornaMappaJs(bool $inquadra = false): void private function datiPiantina(TerritorioMappa $mappa): array
{ {
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa(), inquadra: $inquadra); return [
'mappaId' => $mappa->id,
'etichetta' => $mappa->etichetta(),
'collegata' => $mappa->collegata(),
'stato' => $mappa->stato(),
'statoEtichetta' => $mappa->statoEtichetta(),
'geometry' => $mappa->geometria(),
];
} }
public function render(PiantineArea $area) /** Dopo ogni salvataggio automatico: ora in testata, mappa e indirizzo allineati. */
private function salvato(?TerritorioMappa $mappa, bool $seleziona = false): void
{ {
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null; if (! $mappa) {
return;
}
$mappa = $mappa->fresh(['territorio.zona']);
$this->salvatoAlle = now()->format('H:i');
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
if ($seleziona) {
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: false);
}
// La piantina aperta resta nell'indirizzo: ricaricando la pagina si
// torna su questa e non su un editor vuoto.
$this->dispatch('editor-indirizzo', url: route('piantine.editor', $mappa->id));
}
private function mappaAperta(): ?TerritorioMappa
{
return $this->mappaId ? TerritorioMappa::with('territorio.zona')->find($this->mappaId) : null;
}
private function territorioCollegato(): ?Territorio
{
if ($this->territorioScelto === '' || $this->territorioScelto === 'nuovo') {
return null;
}
$mappa = $this->mappaAperta();
return $mappa?->territorio;
}
public function render()
{
$mappa = $this->mappaAperta();
// Nella tendina: i territori attivi senza piantina, più quello già
// collegato anche se nel frattempo è stato disattivato o cestinato.
$occupati = TerritorioMappa::whereNotNull('territorio_id')
->when($mappa, fn ($q) => $q->whereKeyNot($mappa->id))
->pluck('territorio_id');
$territori = Territorio::attivi()
->whereNotIn('id', $occupati)
->orderByRaw('LENGTH(numero), numero')
->get(['id', 'numero', 'attivo', 'deleted_at']);
if ($mappa?->territorio && ! $territori->contains('id', $mappa->territorio_id)) {
$territori->prepend($mappa->territorio);
}
return view('livewire.piantine.piantine-editor', [ return view('livewire.piantine.piantine-editor', [
'mappa' => $mappa, 'mappa' => $mappa,
'areaCaricata' => $area->area() !== null, 'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
'datiMappaIniziali' => $this->datiMappa($area), 'territori' => $territori,
'territori' => Territorio::attivi()
->orderByRaw('LENGTH(numero), numero')
->get(['id', 'numero']),
'zone' => Zona::orderBy('nome')->get(['id', 'nome']), 'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']), 'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
'title' => 'Editor delle piantine', 'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine',
]); ]);
} }
} }

View File

@@ -2,38 +2,60 @@
namespace App\Livewire\Piantine; namespace App\Livewire\Piantine;
use App\Models\Territorio; use App\Livewire\Concerns\Avvisa;
use App\Models\TerritorioMappa; use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione; use App\Models\TerritorioPdfVersione;
use App\Services\Piantine\PiantinaPdfService; use App\Services\Piantine\PiantinaPdfService;
use App\Services\Piantine\PiantinaRenderer;
use App\Services\Piantine\PiantineArea; use App\Services\Piantine\PiantineArea;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use Livewire\WithPagination;
/** /**
* Pagina di gestione delle piantine (solo amministratore). * Pagina di gestione delle piantine (solo amministratore).
* *
* Qui si tiene l'area della città, si generano immagini e PDF, si collegano * È il quadro d'insieme: a che punto è ogni piantina (bozza, pubblicata,
* le piantine ai territori e si ripescano le versioni sostituite. Il disegno * con modifiche da pubblicare), l'area della città e lo storico dei PDF
* non sta qui: ha una pagina sua a schermo intero (PiantineEditor), perché su * sostituiti. Il disegno e i dati del territorio si toccano nell'editor;
* una mappa larga un terzo di schermo non si taglia un confine. * da qui si apre, si pubblica (con la stessa finestra di anteprima
* dell'editor) e si elimina.
*/ */
class PiantineManager extends Component class PiantineManager extends Component
{ {
use Avvisa;
use WithFileUploads; use WithFileUploads;
use WithPagination;
public const FILTRI = [
'tutte' => 'Tutte',
'bozze' => 'Bozze',
'da-pubblicare' => 'Da pubblicare',
'pubblicate' => 'Pubblicate',
'scollegate' => 'Scollegate',
];
#[Url(as: 'q', except: '')]
public string $cerca = '';
#[Url(except: 'tutte')]
public string $filtro = 'tutte';
public $kmlFile = null; public $kmlFile = null;
/** Piantina di cui è aperto il pannello "collega a un territorio". */ public function updatedFiltro(): void
public ?int $collegamentoAperto = null; {
if (! array_key_exists($this->filtro, self::FILTRI)) {
public ?int $territorioDaCollegare = null; $this->filtro = 'tutte';
}
}
// ─── Area città (KML) ─────────────────────────────────────────────────── // ─── Area città (KML) ───────────────────────────────────────────────────
public function caricaKml(PiantineArea $area) public function caricaKml(PiantineArea $area): void
{ {
$this->validate( $this->validate(
['kmlFile' => 'required|file|max:10240'], ['kmlFile' => 'required|file|max:10240'],
@@ -62,111 +84,42 @@ class PiantineManager extends Component
$this->kmlFile = null; $this->kmlFile = null;
activity()->causedBy(auth()->user())->log('piantine_kml_caricato'); activity()->causedBy(auth()->user())->log('piantine_kml_caricato');
session()->flash('success', "Area della città aggiornata: {$quante} geometrie riconosciute nel KML."); $this->avvisa("Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
} }
// ─── Generazione ──────────────────────────────────────────────────────── // ─── Piantine ───────────────────────────────────────────────────────────
public function generaImmagine(int $mappaId, PiantinaRenderer $renderer) public function pubblica(int $mappaId): void
{ {
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId); $mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
try { if (! $mappa) {
$renderer->render($mappa); $this->avvisaErrore('La piantina non esiste più.');
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
return; return;
} }
session()->flash('success', "Immagine generata per {$mappa->etichetta()}."); if ($motivo = $mappa->motivoNonPubblicabile()) {
} $this->avvisaErrore("{$mappa->etichetta()}: {$motivo}");
public function generaPdf(int $mappaId, PiantinaPdfService $pdfService)
{
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
if (! $mappa->collegata()) {
session()->flash('error', 'Questa piantina non è collegata a nessun territorio: collegala prima di generare il PDF.');
return; return;
} }
try { $this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
$pdfService->sostituisci($mappa->territorio, $mappa); }
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
/** La finestra di pubblicazione ha finito: basta ridisegnare l'elenco. */
#[On('piantina-pubblicata')]
public function pubblicata(): void {}
public function elimina(int $mappaId): void
{
$mappa = TerritorioMappa::with('territorio')->find($mappaId);
if (! $mappa) {
return; return;
} }
session()->flash('success', "PDF del territorio {$mappa->territorio->numero} sostituito. Il precedente è nello storico versioni.");
}
// ─── Collegamento al territorio ─────────────────────────────────────────
public function apriCollegamento(int $mappaId): void
{
$this->collegamentoAperto = $mappaId;
$this->territorioDaCollegare = TerritorioMappa::find($mappaId)?->territorio_id;
}
public function chiudiCollegamento(): void
{
$this->collegamentoAperto = null;
$this->territorioDaCollegare = null;
}
public function collega(int $mappaId)
{
$mappa = TerritorioMappa::findOrFail($mappaId);
if (! $this->territorioDaCollegare) {
session()->flash('error', 'Scegli il territorio a cui collegare la piantina.');
return;
}
$territorio = Territorio::findOrFail($this->territorioDaCollegare);
// Un territorio ha una piantina sola: se ne aveva già un'altra il
// vincolo di unicità la respingerebbe con un errore di database, che
// non spiega niente a chi sta guardando la pagina.
$occupato = TerritorioMappa::where('territorio_id', $territorio->id)
->where('id', '!=', $mappa->id)
->exists();
if ($occupato) {
session()->flash('error', "Il territorio {$territorio->numero} ha già una piantina: scollegala prima di assegnargli questa.");
return;
}
$mappa->update(['territorio_id' => $territorio->id]);
$this->chiudiCollegamento();
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
session()->flash('success', "Piantina collegata al territorio {$territorio->numero}.");
}
public function scollega(int $mappaId)
{
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
$numero = $mappa->territorio?->numero ?? '?';
// Senza territorio serve un'etichetta, altrimenti la piantina sparisce
// dall'elenco sotto un anonimo "Piantina #12".
$mappa->update([
'territorio_id' => null,
'nome' => $mappa->nome ?: "Ex territorio {$numero}",
]);
session()->flash('success', "Piantina scollegata dal territorio {$numero}. Il PDF del territorio resta com'è.");
}
public function eliminaPiantina(int $mappaId)
{
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
$etichetta = $mappa->etichetta(); $etichetta = $mappa->etichetta();
if ($mappa->image_path) { if ($mappa->image_path) {
@@ -174,59 +127,106 @@ class PiantineManager extends Component
} }
$mappa->delete(); $mappa->delete();
session()->flash('success', "{$etichetta}: poligono e immagine eliminati. Il PDF del territorio resta com'è."); $this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
} }
// ─── Storico versioni ─────────────────────────────────────────────────── // ─── Storico versioni ───────────────────────────────────────────────────
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService) public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService): void
{ {
$versione = TerritorioPdfVersione::with('territorio')->findOrFail($versioneId); $versione = TerritorioPdfVersione::with('territorio')->find($versioneId);
if (! $versione) {
return;
}
$numero = $versione->territorio?->numero ?? '?'; $numero = $versione->territorio?->numero ?? '?';
try { try {
$pdfService->ripristina($versione); $pdfService->ripristina($versione);
} catch (\Throwable $e) { } catch (\Throwable $e) {
session()->flash('error', $e->getMessage()); $this->avvisaErrore($e->getMessage());
return; return;
} }
session()->flash('success', "Versione precedente ripristinata per il territorio {$numero}."); $this->avvisa("Versione precedente ripristinata per il territorio {$numero}. Il PDF che c'era è nello storico.");
} }
public function eliminaVersione(int $versioneId) public function eliminaVersione(int $versioneId): void
{ {
$versione = TerritorioPdfVersione::findOrFail($versioneId); $versione = TerritorioPdfVersione::find($versioneId);
if (Storage::disk('public')->exists($versione->pdf_path)) { if (! $versione) {
Storage::disk('public')->delete($versione->pdf_path); return;
} }
Storage::disk('public')->delete($versione->pdf_path);
$versione->delete(); $versione->delete();
session()->flash('success', 'Versione eliminata definitivamente.'); $this->avvisa('Versione eliminata definitivamente.');
}
// ─── Dati per la vista ──────────────────────────────────────────────────
/**
* Tutte le piantine con il loro stato, in memoria: sono qualche centinaio
* al massimo, e lo stato dipende dall'impronta, che si calcola in PHP.
*
* @return Collection<int, TerritorioMappa>
*/
private function piantine(): Collection
{
return TerritorioMappa::with('territorio.zona')
->get()
->sortBy(fn (TerritorioMappa $m) => [
$m->territorio_id === null ? 1 : 0,
strlen((string) $m->territorio?->numero),
(string) $m->territorio?->numero,
mb_strtolower((string) $m->nome),
])
->values();
}
private function corrisponde(TerritorioMappa $mappa, string $filtro): bool
{
return match ($filtro) {
'bozze' => $mappa->stato() === TerritorioMappa::STATO_BOZZA,
'da-pubblicare' => $mappa->stato() === TerritorioMappa::STATO_MODIFICATA,
'pubblicate' => $mappa->stato() === TerritorioMappa::STATO_PUBBLICATA,
'scollegate' => ! $mappa->collegata(),
default => true,
};
} }
public function render(PiantineArea $area) public function render(PiantineArea $area)
{ {
$piantine = $this->piantine();
$conteggi = collect(self::FILTRI)
->map(fn ($etichetta, $filtro) => $piantine->filter(fn ($m) => $this->corrisponde($m, $filtro))->count());
$cerca = mb_strtolower(trim($this->cerca));
$elenco = $piantine
->filter(fn ($m) => $this->corrisponde($m, $this->filtro))
->filter(fn (TerritorioMappa $m) => $cerca === '' || str_contains(mb_strtolower(implode(' ', [
$m->etichetta(),
$m->territorio?->numero,
$m->nome,
$m->territorio?->zona?->nome,
])), $cerca))
->values();
return view('livewire.piantine.piantine-manager', [ return view('livewire.piantine.piantine-manager', [
'config' => $area->config(), 'config' => $area->config(),
'piantine' => TerritorioMappa::with('territorio') 'elenco' => $elenco,
->get() 'totale' => $piantine->count(),
->sortBy(fn ($m) => [ 'conteggi' => $conteggi,
$m->collegata() ? 0 : 1,
strlen((string) $m->territorio?->numero),
(string) $m->territorio?->numero,
(string) $m->nome,
])
->values(),
'territori' => Territorio::attivi()
->orderByRaw('LENGTH(numero), numero')
->get(['id', 'numero']),
'versioni' => TerritorioPdfVersione::with('territorio') 'versioni' => TerritorioPdfVersione::with('territorio')
->orderByDesc('sostituito_il') ->orderByDesc('sostituito_il')
->limit(30) ->orderByDesc('id')
->get(), ->paginate(15, pageName: 'versioni'),
'title' => 'Piantine dei territori', 'title' => 'Piantine dei territori',
]); ]);
} }

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Livewire\Piantine;
use App\Livewire\Concerns\Avvisa;
use App\Models\TerritorioMappa;
use App\Services\Piantine\PubblicazionePiantina;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* La finestra di pubblicazione, uguale nell'editor e nella gestione.
*
* Si apre con l'evento "pubblica-piantina", prepara l'anteprima in coda
* (controllando a intervalli quando è pronta), la mostra, e solo su
* "Conferma" sostituisce il PDF del territorio. Chiuderla butta l'anteprima.
*/
class Pubblicazione extends Component
{
use Avvisa;
#[Locked]
public ?int $mappaId = null;
#[Locked]
public ?string $token = null;
public string $fase = '';
public string $messaggio = '';
public string $etichetta = '';
#[On('pubblica-piantina')]
public function apri(int $mappaId, PubblicazionePiantina $pubblicazione): void
{
$this->chiudi($pubblicazione);
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
if (! $mappa) {
$this->avvisaErrore('La piantina non esiste più.');
return;
}
$this->mappaId = $mappa->id;
$this->etichetta = $mappa->etichetta();
try {
$this->token = $pubblicazione->avvia($mappa);
} catch (\RuntimeException $e) {
$this->fase = PubblicazionePiantina::ERRORE;
$this->messaggio = $e->getMessage();
return;
}
$this->aggiorna($pubblicazione);
}
/** Chiamata a intervalli finché l'anteprima è in preparazione. */
public function aggiorna(PubblicazionePiantina $pubblicazione): void
{
if (! $this->token) {
return;
}
$stato = $pubblicazione->stato($this->token);
if (! $stato) {
$this->fase = PubblicazionePiantina::ERRORE;
$this->messaggio = 'L\'anteprima è scaduta: generala di nuovo.';
return;
}
$this->fase = $stato['stato'];
$this->messaggio = $stato['messaggio'] ?? '';
}
public function conferma(PubblicazionePiantina $pubblicazione): void
{
if (! $this->token) {
return;
}
try {
$mappa = $pubblicazione->conferma($this->token);
} catch (\RuntimeException $e) {
$this->fase = PubblicazionePiantina::ERRORE;
$this->messaggio = $e->getMessage();
return;
}
$this->reset(['mappaId', 'token', 'fase', 'messaggio', 'etichetta']);
$this->avvisa("{$mappa->etichetta()} pubblicata: il PDF del territorio è stato sostituito, il precedente è nello storico.");
$this->dispatch('piantina-pubblicata', mappaId: $mappa->id);
}
/** Riprova dopo un errore, con lo stato attuale della piantina. */
public function riprova(PubblicazionePiantina $pubblicazione): void
{
if ($this->mappaId) {
$this->apri($this->mappaId, $pubblicazione);
}
}
public function chiudi(PubblicazionePiantina $pubblicazione): void
{
if ($this->token) {
$pubblicazione->annulla($this->token);
}
$this->reset(['mappaId', 'token', 'fase', 'messaggio', 'etichetta']);
}
public function render()
{
return view('livewire.piantine.pubblicazione');
}
}

View File

@@ -2,12 +2,14 @@
namespace App\Livewire\Proclamatori; namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Proclamatore; use App\Models\Proclamatore;
class ProclamatoreCestino extends Component class ProclamatoreCestino extends Component
{ {
use Avvisa;
use WithPagination; use WithPagination;
public function restore(int $id) public function restore(int $id)
@@ -19,7 +21,7 @@ class ProclamatoreCestino extends Component
activity()->causedBy(auth()->user()) activity()->causedBy(auth()->user())
->performedOn($proclamatore) ->performedOn($proclamatore)
->log('restored'); ->log('restored');
session()->flash('success', "Proclamatore ripristinato."); $this->avvisa("Proclamatore ripristinato.");
} }
public function forceDelete(int $id) public function forceDelete(int $id)
@@ -29,14 +31,14 @@ class ProclamatoreCestino extends Component
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id); $proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
if ($proclamatore->assegnazioni()->exists()) { if ($proclamatore->assegnazioni()->exists()) {
session()->flash('error', 'Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.'); $this->avvisaErrore('Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
return; return;
} }
activity()->causedBy(auth()->user()) activity()->causedBy(auth()->user())
->log('force_deleted_proclamatore'); ->log('force_deleted_proclamatore');
$proclamatore->forceDelete(); $proclamatore->forceDelete();
session()->flash('success', 'Proclamatore eliminato definitivamente.'); $this->avvisa('Proclamatore eliminato definitivamente.');
} }
public function render() public function render()

View File

@@ -10,6 +10,8 @@ class ProclamatoreCreate extends Component
public string $nome = ''; public string $nome = '';
public string $cognome = ''; public string $cognome = '';
public bool $attivo = true; public bool $attivo = true;
public string $email = '';
public string $telefono = '';
protected function rules(): array protected function rules(): array
{ {
@@ -17,6 +19,17 @@ class ProclamatoreCreate extends Component
'nome' => 'required|string|max:100', 'nome' => 'required|string|max:100',
'cognome' => 'required|string|max:100', 'cognome' => 'required|string|max:100',
'attivo' => 'boolean', 'attivo' => 'boolean',
// Facoltativi: si controlla il formato solo se compilati.
'email' => 'nullable|email|max:255',
'telefono' => ['nullable', 'string', 'max:30', 'regex:/^\+?[0-9 ().\/-]{6,25}$/'],
];
}
protected function messages(): array
{
return [
'email.email' => "L'indirizzo email non è valido (puoi anche lasciarlo vuoto).",
'telefono.regex' => 'Il numero può contenere solo cifre, spazi e un "+" iniziale (puoi anche lasciarlo vuoto).',
]; ];
} }
@@ -30,6 +43,8 @@ class ProclamatoreCreate extends Component
'nome' => $this->nome, 'nome' => $this->nome,
'cognome' => $this->cognome, 'cognome' => $this->cognome,
'attivo' => $this->attivo, 'attivo' => $this->attivo,
'email' => trim($this->email) ?: null,
'telefono' => trim($this->telefono) ?: null,
]); ]);
activity()->causedBy(auth()->user()) activity()->causedBy(auth()->user())

View File

@@ -11,6 +11,8 @@ class ProclamatoreEdit extends Component
public string $nome = ''; public string $nome = '';
public string $cognome = ''; public string $cognome = '';
public bool $attivo = true; public bool $attivo = true;
public string $email = '';
public string $telefono = '';
public function mount(Proclamatore $proclamatore) public function mount(Proclamatore $proclamatore)
{ {
@@ -18,6 +20,8 @@ class ProclamatoreEdit extends Component
$this->nome = $proclamatore->nome; $this->nome = $proclamatore->nome;
$this->cognome = $proclamatore->cognome; $this->cognome = $proclamatore->cognome;
$this->attivo = $proclamatore->attivo; $this->attivo = $proclamatore->attivo;
$this->email = (string) $proclamatore->email;
$this->telefono = (string) $proclamatore->telefono;
} }
protected function rules(): array protected function rules(): array
@@ -26,6 +30,17 @@ class ProclamatoreEdit extends Component
'nome' => 'required|string|max:100', 'nome' => 'required|string|max:100',
'cognome' => 'required|string|max:100', 'cognome' => 'required|string|max:100',
'attivo' => 'boolean', 'attivo' => 'boolean',
// Facoltativi: si controlla il formato solo se compilati.
'email' => 'nullable|email|max:255',
'telefono' => ['nullable', 'string', 'max:30', 'regex:/^\+?[0-9 ().\/-]{6,25}$/'],
];
}
protected function messages(): array
{
return [
'email.email' => "L'indirizzo email non è valido (puoi anche lasciarlo vuoto).",
'telefono.regex' => 'Il numero può contenere solo cifre, spazi e un "+" iniziale (puoi anche lasciarlo vuoto).',
]; ];
} }
@@ -39,6 +54,8 @@ class ProclamatoreEdit extends Component
'nome' => $this->nome, 'nome' => $this->nome,
'cognome' => $this->cognome, 'cognome' => $this->cognome,
'attivo' => $this->attivo, 'attivo' => $this->attivo,
'email' => trim($this->email) ?: null,
'telefono' => trim($this->telefono) ?: null,
]); ]);
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato."); session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");

View File

@@ -2,12 +2,14 @@
namespace App\Livewire\Proclamatori; namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Proclamatore; use App\Models\Proclamatore;
class ProclamatoreIndex extends Component class ProclamatoreIndex extends Component
{ {
use Avvisa;
use WithPagination; use WithPagination;
public string $search = ''; public string $search = '';
@@ -59,12 +61,12 @@ class ProclamatoreIndex extends Component
$proclamatore = Proclamatore::findOrFail($id); $proclamatore = Proclamatore::findOrFail($id);
if ($proclamatore->assegnazioni()->aperte()->exists()) { if ($proclamatore->assegnazioni()->aperte()->exists()) {
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori."); $this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
return; return;
} }
$proclamatore->delete(); $proclamatore->delete();
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} spostato nel cestino."); $this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
} }
public function anonimizza(int $id) public function anonimizza(int $id)
@@ -73,7 +75,7 @@ class ProclamatoreIndex extends Component
$proclamatore = Proclamatore::findOrFail($id); $proclamatore = Proclamatore::findOrFail($id);
$proclamatore->anonimizza(); $proclamatore->anonimizza();
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR)."); $this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
} }
public function render() public function render()

View File

@@ -2,11 +2,13 @@
namespace App\Livewire; namespace App\Livewire;
use App\Livewire\Concerns\Avvisa;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Component; use Livewire\Component;
class Profile extends Component class Profile extends Component
{ {
use Avvisa;
public string $name = ''; public string $name = '';
public string $email = ''; public string $email = '';
@@ -30,10 +32,22 @@ class Profile extends Component
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
]); ]);
$user->update([ $user->fill([
'name' => $validated['name'], 'name' => $validated['name'],
'email' => $validated['email'], 'email' => $validated['email'],
]); ]);
$emailCambiata = $user->isDirty('email');
$user->save();
// L'indirizzo nuovo va confermato (User::booted lo segna da
// confermare): finché non apre il link, l'account resta bloccato.
if ($emailCambiata) {
$user->inviaConfermaEmail()
? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.")
: session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.');
return $this->redirect(route('verification.notice'), navigate: false);
}
session()->flash('profile_success', 'Profilo aggiornato con successo.'); session()->flash('profile_success', 'Profilo aggiornato con successo.');
@@ -67,7 +81,7 @@ class Profile extends Component
$this->reset(['current_password', 'password', 'password_confirmation']); $this->reset(['current_password', 'password', 'password_confirmation']);
session()->flash('password_success', 'Password aggiornata con successo.'); $this->avvisa('Password aggiornata con successo.');
} }
public function render() public function render()

View File

@@ -11,18 +11,14 @@ use App\Models\Proclamatore;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Zona; use App\Models\Zona;
use App\Models\Tipologia; use App\Models\Tipologia;
use Barryvdh\DomPDF\Facade\Pdf; use App\Services\Registro\ModuloS13;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class Registro extends Component class Registro extends Component
{ {
use WithPagination; use WithPagination;
/** Blocchi-territorio per foglio, come nel modulo cartaceo S-13. */
private const S13_RIGHE_PER_PAGINA = 21;
/** Gruppi "Assegnato a" disponibili su ogni riga del modulo S-13. */
private const S13_ASSEGNAZIONI_PER_RIGA = 4;
public string $search = ''; public string $search = '';
public string $filtroAnno = ''; public string $filtroAnno = '';
public string $filtroZona = ''; public string $filtroZona = '';
@@ -37,7 +33,6 @@ class Registro extends Component
public string $form_territorio_id = ''; public string $form_territorio_id = '';
public string $form_proclamatore_id = ''; public string $form_proclamatore_id = '';
public string $form_anno_id = '';
public string $form_assigned_at = ''; public string $form_assigned_at = '';
public string $form_returned_at = ''; public string $form_returned_at = '';
public bool $form_counted_in_campaign = false; public bool $form_counted_in_campaign = false;
@@ -101,7 +96,6 @@ class Registro extends Component
$this->editingId = null; $this->editingId = null;
$this->form_territorio_id = ''; $this->form_territorio_id = '';
$this->form_proclamatore_id = ''; $this->form_proclamatore_id = '';
$this->form_anno_id = '';
$this->form_assigned_at = now()->format('Y-m-d'); $this->form_assigned_at = now()->format('Y-m-d');
$this->form_returned_at = ''; $this->form_returned_at = '';
$this->form_counted_in_campaign = false; $this->form_counted_in_campaign = false;
@@ -118,7 +112,6 @@ class Registro extends Component
$this->editingId = $id; $this->editingId = $id;
$this->form_territorio_id = (string) $a->territorio_id; $this->form_territorio_id = (string) $a->territorio_id;
$this->form_proclamatore_id = (string) $a->proclamatore_id; $this->form_proclamatore_id = (string) $a->proclamatore_id;
$this->form_anno_id = (string) $a->anno_teocratico_id;
$this->form_assigned_at = $a->assigned_at?->format('Y-m-d') ?? ''; $this->form_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
$this->form_returned_at = $a->returned_at?->format('Y-m-d') ?? ''; $this->form_returned_at = $a->returned_at?->format('Y-m-d') ?? '';
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign; $this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
@@ -134,28 +127,54 @@ class Registro extends Component
$this->validate([ $this->validate([
'form_territorio_id' => 'required|exists:territori,id', 'form_territorio_id' => 'required|exists:territori,id',
'form_proclamatore_id' => 'required|exists:proclamatori,id', 'form_proclamatore_id' => 'required|exists:proclamatori,id',
'form_anno_id' => 'required|exists:anni_teocratici,id', 'form_assigned_at' => 'required|date|before_or_equal:today',
'form_assigned_at' => 'required|date', 'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at|before_or_equal:today',
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
'form_campaign_id' => 'nullable|exists:campagne,id', 'form_campaign_id' => 'nullable|exists:campagne,id',
], [
'form_assigned_at.before_or_equal' => 'La data di assegnazione non può essere futura.',
'form_returned_at.before_or_equal' => 'La data di rientro non può essere futura.',
'form_returned_at.after_or_equal' => 'Il rientro non può precedere l\'assegnazione.',
]); ]);
$uscita = Carbon::parse($this->form_assigned_at);
$rientro = $this->form_returned_at ? Carbon::parse($this->form_returned_at) : null;
// L'anno di servizio non si sceglie: è quello della data di uscita.
// Scelto a mano poteva restare incoerente con la data, e l'assegnazione
// finiva contata nell'anno sbagliato da Home, filtri e modulo S-13.
$data = [ $data = [
'territorio_id' => $this->form_territorio_id, 'territorio_id' => $this->form_territorio_id,
'proclamatore_id' => $this->form_proclamatore_id, 'proclamatore_id' => $this->form_proclamatore_id,
'anno_teocratico_id' => $this->form_anno_id, 'anno_teocratico_id' => AnnoTeocratico::perData($uscita)->id,
'assigned_at' => $this->form_assigned_at, 'assigned_at' => $uscita,
'returned_at' => $this->form_returned_at ?: null, 'returned_at' => $rientro,
'counted_in_campaign' => $this->form_counted_in_campaign, // "Contata nella campagna" senza campagna non ha significato.
'counted_in_campaign' => $this->form_counted_in_campaign && $this->form_campaign_id !== '',
'campaign_id' => $this->form_campaign_id ?: null, 'campaign_id' => $this->form_campaign_id ?: null,
'note' => $this->form_note ?: null, 'note' => $this->form_note ?: null,
]; ];
if ($this->editingId) { $conflitto = DB::transaction(function () use ($data, $uscita, $rientro) {
Assegnazione::findOrFail($this->editingId)->update($data); Territorio::whereKey($data['territorio_id'])->lockForUpdate()->first();
} else {
$data['created_by'] = auth()->id(); $conflitto = Assegnazione::sovrappostaA((int) $data['territorio_id'], $uscita, $rientro, $this->editingId);
Assegnazione::create($data); if ($conflitto) {
return $conflitto;
}
if ($this->editingId) {
Assegnazione::findOrFail($this->editingId)->update($data);
} else {
Assegnazione::create($data + ['created_by' => auth()->id()]);
}
return null;
});
if ($conflitto) {
$this->addError('form_assigned_at', "Il periodo si sovrappone all'assegnazione {$conflitto->descrizionePeriodo()}.");
return;
} }
$this->showModal = false; $this->showModal = false;
@@ -199,16 +218,15 @@ class Registro extends Component
} }
/** /**
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del * Modulo ufficiale S-13-I compilato per l'anno di servizio scelto nella
* territorio") compilato con le assegnazioni dell'anno di servizio scelto * modale; le regole di compilazione stanno in ModuloS13.
* nella modale.
* *
* Solo amministratore, come il resto del CRUD di questa pagina: il modulo * Solo amministratore, come il resto del CRUD di questa pagina: il modulo
* espone in un unico file l'anagrafica completa di chi ha avuto quale * espone in un unico file l'anagrafica completa di chi ha avuto quale
* territorio, dati personali che il ruolo assegnatario non deve poter * territorio, dati personali che il ruolo assegnatario non deve poter
* esportare in blocco. * esportare in blocco.
*/ */
public function downloadS13() public function downloadS13(ModuloS13 $modulo)
{ {
abort_if(!auth()->user()->can('settings.manage'), 403); abort_if(!auth()->user()->can('settings.manage'), 403);
@@ -224,134 +242,15 @@ class Registro extends Component
$this->showS13Modal = false; $this->showS13Modal = false;
$pdf = Pdf::loadView('pdf.registro-s13', [ $pdf = $modulo->genera($anno);
'annoLabel' => $anno->label,
'pagine' => $this->impaginaS13($this->righeS13($anno)),
])->setPaper('a4', 'portrait');
return response()->streamDownload( return response()->streamDownload(
fn () => print($pdf->output()), fn () => print($pdf),
'S-13-registro-assegnazioni-' . $anno->label . '.pdf' 'S-13-registro-assegnazioni-' . $anno->label . '.pdf',
['Content-Type' => 'application/pdf']
); );
} }
/**
* Impagina le righe in fogli da 21 blocchi, completando l'ultimo con
* blocchi vuoti: il foglio stampato resta identico al modulo cartaceo e le
* righe libere restano disponibili per la compilazione a mano.
*
* Senza righe da stampare si restituisce comunque un foglio vuoto, così il
* modulo scaricato è un S-13 in bianco anziché un PDF senza pagine.
*
* @param \Illuminate\Support\Collection<int, array> $righe
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, array>>
*/
private function impaginaS13(\Illuminate\Support\Collection $righe): \Illuminate\Support\Collection
{
$vuota = [
'numero' => '',
'riporto' => '',
'assegnazioni' => $this->completaCelleS13([]),
];
$pagine = $righe->isEmpty()
? collect([collect()])
: $righe->chunk(self::S13_RIGHE_PER_PAGINA)->map->values()->values();
return $pagine->map(fn ($pagina) => collect(
array_pad($pagina->all(), self::S13_RIGHE_PER_PAGINA, $vuota)
));
}
/**
* Righe del modulo: un blocco per ogni territorio attivo con almeno
* un'assegnazione (chiusa o in corso) nell'anno indicato, in ordine di
* numero crescente.
*
* @return \Illuminate\Support\Collection<int, array>
*/
private function righeS13(AnnoTeocratico $anno): \Illuminate\Support\Collection
{
// territorio() è definita withTrashed(), quindi la condizione sui
// cestinati va scritta a mano: senza, i territori eliminati
// rientrerebbero nel modulo.
$assegnazioni = Assegnazione::with(['proclamatore', 'territorio'])
->where('anno_teocratico_id', $anno->id)
->whereHas('territorio', fn ($q) => $q
->where('attivo', true)
->whereNull('territori.deleted_at'))
->orderBy('assigned_at')
->orderBy('id')
->get()
->groupBy('territorio_id');
return $assegnazioni
->map(function ($gruppo) {
$gruppo = $gruppo->values();
// Il ciclo di registrazione è lungo quanto i gruppi "Assegnato
// a": riempito il quarto si ricomincia da capo, come sul modulo
// cartaceo si aprirebbe una riga nuova per quel territorio.
// Cinque assegnazioni non mostrano quindi le ultime quattro, ma
// solo la quinta, in prima colonna.
$inizioCiclo = intdiv($gruppo->count() - 1, self::S13_ASSEGNAZIONI_PER_RIGA)
* self::S13_ASSEGNAZIONI_PER_RIGA;
$celle = $gruppo
->slice($inizioCiclo, self::S13_ASSEGNAZIONI_PER_RIGA)
->map(fn (Assegnazione $a) => [
'nome' => $a->proclamatore?->nome_completo ?? '',
'assegnato_il' => $a->assigned_at?->format('d/m/Y') ?? '',
'restituito_il' => $a->returned_at?->format('d/m/Y') ?? '',
])->all();
return [
'numero' => $gruppo->first()->territorio?->numero ?? '',
'riporto' => $this->riportoS13($gruppo, $inizioCiclo),
'assegnazioni' => $this->completaCelleS13($celle),
];
})
->sort(fn (array $a, array $b) => strnatcasecmp((string) $a['numero'], (string) $b['numero']))
->values();
}
/**
* Contenuto della colonna "Restituito l'ultima volta il": la data di
* rientro dell'ultima assegnazione *prima* che il ciclo ricominciasse,
* cioè quella che occupava il quarto gruppo del giro precedente.
*
* È il riporto che il modulo cartaceo chiede quando si apre una riga nuova
* per un territorio già registrato: la riga mostra da dove si riparte,
* senza il nome di chi lo aveva (la colonna ha solo la data).
*
* Finché il primo ciclo non è esaurito — quattro assegnazioni o meno
* nell'anno — non c'è niente da riportare e la colonna resta vuota.
*
* @param \Illuminate\Support\Collection<int, Assegnazione> $gruppo
*/
private function riportoS13(\Illuminate\Support\Collection $gruppo, int $inizioCiclo): string
{
if ($inizioCiclo === 0) {
return '';
}
return $gruppo->get($inizioCiclo - 1)?->returned_at?->format('d/m/Y') ?? '';
}
/**
* Porta le celle a quattro riempiendo con voci vuote: le colonne
* inutilizzate restano stampate e a disposizione della compilazione a mano.
*
* @param array<int, array> $celle
* @return array<int, array>
*/
private function completaCelleS13(array $celle): array
{
$vuota = ['nome' => '', 'assegnato_il' => '', 'restituito_il' => ''];
return array_pad($celle, self::S13_ASSEGNAZIONI_PER_RIGA, $vuota);
}
public function mount(): void public function mount(): void
{ {
if ($this->filtroAnno === '') { if ($this->filtroAnno === '') {

View File

@@ -48,7 +48,11 @@ class BackupManager extends Component
return; return;
} }
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP); $operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
if ($operationId === null) {
return;
}
$this->operationId = $operationId;
CreateBackupArchive::dispatch($this->operationId, false); CreateBackupArchive::dispatch($this->operationId, false);

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Models\Setting;
use App\Services\ConfigurazionePosta;
use Illuminate\Validation\Rule;
use Livewire\Component;
/**
* Server di posta (solo amministratore).
*
* Serve alle email di conferma degli utenti. Raggiungibile anche da un
* amministratore che non ha ancora confermato la sua email (vedi
* routes/web.php): se la posta non funziona, è l'unica pagina da cui
* sistemarla senza passare dal server.
*/
class PostaElettronica extends Component
{
use Avvisa;
public string $host = '';
public string $porta = '';
public string $cifratura = ConfigurazionePosta::CIFRATURA_AUTOMATICA;
public string $utente = '';
/** Vuota = si tiene quella salvata. Non torna mai al browser. */
public string $password = '';
public string $mittente = '';
public string $nomeMittente = '';
public function mount(): void
{
$impostazioni = Setting::instance();
$this->host = (string) $impostazioni->mail_host;
$this->porta = (string) ($impostazioni->mail_port ?? '');
$this->cifratura = $impostazioni->mail_cifratura ?: ConfigurazionePosta::CIFRATURA_AUTOMATICA;
$this->utente = (string) $impostazioni->mail_username;
$this->mittente = (string) $impostazioni->mail_from_address;
$this->nomeMittente = (string) $impostazioni->mail_from_name;
}
public function salva(): void
{
$this->host = trim($this->host);
$this->mittente = trim($this->mittente);
$this->validate([
'host' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9.\-]+$/'],
'porta' => ['nullable', 'integer', 'min:1', 'max:65535'],
'cifratura' => ['required', Rule::in(array_keys(ConfigurazionePosta::CIFRATURE))],
'utente' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:500'],
'mittente' => ['required', 'email', 'max:255'],
'nomeMittente' => ['nullable', 'string', 'max:255'],
], [
'host.required' => 'Indica il server SMTP (es. smtp.gmail.com).',
'host.regex' => 'Solo il nome del server, senza "smtp://" né porta.',
'mittente.required' => 'Indica l\'indirizzo da cui partono le email.',
'mittente.email' => 'L\'indirizzo del mittente non è valido.',
]);
$valori = [
'mail_host' => $this->host,
'mail_port' => $this->porta !== '' ? (int) $this->porta : null,
'mail_cifratura' => $this->cifratura,
'mail_username' => trim($this->utente) ?: null,
'mail_from_address' => $this->mittente,
'mail_from_name' => trim($this->nomeMittente) ?: null,
];
if ($this->password !== '') {
$valori['mail_password'] = $this->password;
}
Setting::instance()->update($valori);
$this->password = '';
activity()->causedBy(auth()->user())->log('posta_configurata');
$this->avvisa('Server di posta salvato. Prova l\'invio con "Invia email di prova".');
}
public function inviaProva(ConfigurazionePosta $posta): void
{
if (! $posta->configurataNellApp()) {
$this->avvisaErrore('Salva prima il server di posta.');
return;
}
try {
$posta->inviaProva(auth()->user()->email);
} catch (\RuntimeException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->avvisa('Email di prova inviata a ' . auth()->user()->email . '. Controlla anche lo spam.');
}
/** Torna al server del .env. */
public function rimuovi(): void
{
Setting::instance()->update(array_fill_keys(Setting::CAMPI_POSTA, null));
$this->mount();
$this->password = '';
activity()->causedBy(auth()->user())->log('posta_rimossa');
$this->avvisa('Configurazione rimossa: valgono di nuovo le impostazioni del server (.env).');
}
public function render(ConfigurazionePosta $posta)
{
$impostazioni = Setting::instance();
return view('livewire.settings.posta-elettronica', [
'configurataNellApp' => $posta->configurataNellApp(),
'funzionante' => $posta->funzionante(),
'passwordSalvata' => $impostazioni->getRawOriginal('mail_password') !== null,
'cifrature' => ConfigurazionePosta::CIFRATURE,
'nonConfermato' => ! auth()->user()->hasVerifiedEmail(),
'title' => 'Server di posta',
]);
}
}

View File

@@ -2,11 +2,13 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component; use Livewire\Component;
use App\Models\Setting; use App\Models\Setting;
class SettingsEdit extends Component class SettingsEdit extends Component
{ {
use Avvisa;
public string $congregazione_nome = ''; public string $congregazione_nome = '';
public int $giorni_giacenza_da_assegnare = 120; public int $giorni_giacenza_da_assegnare = 120;
public int $giorni_giacenza_prioritari = 180; public int $giorni_giacenza_prioritari = 180;
@@ -55,7 +57,7 @@ class SettingsEdit extends Component
'audit_retention_days' => $this->audit_retention_days, 'audit_retention_days' => $this->audit_retention_days,
]); ]);
session()->flash('success', 'Impostazioni aggiornate.'); $this->avvisa('Impostazioni aggiornate.');
} }
public function render() public function render()

View File

@@ -2,11 +2,13 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component; use Livewire\Component;
use App\Models\Tipologia; use App\Models\Tipologia;
class TipologieIndex extends Component class TipologieIndex extends Component
{ {
use Avvisa;
public string $nuovaTipologia = ''; public string $nuovaTipologia = '';
public ?int $editingId = null; public ?int $editingId = null;
public string $editingNome = ''; public string $editingNome = '';
@@ -16,7 +18,7 @@ class TipologieIndex extends Component
$this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']); $this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']);
Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]); Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]);
$this->nuovaTipologia = ''; $this->nuovaTipologia = '';
session()->flash('success', 'Tipologia aggiunta.'); $this->avvisa('Tipologia aggiunta.');
} }
public function startEdit(int $id) public function startEdit(int $id)
@@ -33,7 +35,7 @@ class TipologieIndex extends Component
$tipo->update(['nome' => $this->editingNome]); $tipo->update(['nome' => $this->editingNome]);
$this->editingId = null; $this->editingId = null;
$this->editingNome = ''; $this->editingNome = '';
session()->flash('success', 'Tipologia aggiornata.'); $this->avvisa('Tipologia aggiornata.');
} }
public function cancelEdit() public function cancelEdit()
@@ -52,11 +54,11 @@ class TipologieIndex extends Component
{ {
$tipo = Tipologia::findOrFail($id); $tipo = Tipologia::findOrFail($id);
if ($tipo->territori()->exists()) { if ($tipo->territori()->exists()) {
session()->flash('error', "Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati."); $this->avvisaErrore("Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
return; return;
} }
$tipo->delete(); $tipo->delete();
session()->flash('success', "Tipologia '{$tipo->nome}' eliminata."); $this->avvisa("Tipologia '{$tipo->nome}' eliminata.");
} }
public function render() public function render()

View File

@@ -2,21 +2,27 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
class UsersIndex extends Component class UsersIndex extends Component
{ {
use Avvisa;
public string $name = ''; public string $name = '';
public string $email = ''; public string $email = '';
public string $password = ''; public string $password = '';
public string $password_confirmation = ''; public string $password_confirmation = '';
public string $selectedRole = ''; public string $selectedRole = '';
// Locked: è la lista che le regole di validazione usano per accettare un
// ruolo; modificabile dal client, ammetterebbe qualsiasi nome.
#[Locked]
public array $availableRoles = []; public array $availableRoles = [];
public ?int $editingUserId = null; public ?int $editingUserId = null;
@@ -79,7 +85,30 @@ class UsersIndex extends Component
if (! empty($this->availableRoles)) { if (! empty($this->availableRoles)) {
$this->selectedRole = $this->availableRoles[0]; $this->selectedRole = $this->availableRoles[0];
} }
session()->flash('success', 'Utente creato con successo.');
// L'account funziona solo dopo la conferma dell'indirizzo.
if ($user->inviaConfermaEmail()) {
$this->avvisa("Utente creato. Gli è stata inviata un'email a {$user->email}: l'account funziona dopo che conferma l'indirizzo.");
} else {
$this->avvisaErrore("Utente creato, ma l'email di conferma non è partita: controlla il server di posta e usa \"Reinvia conferma\".");
}
}
public function reinviaConferma(int $userId): void
{
$user = User::query()->findOrFail($userId);
if ($user->hasVerifiedEmail()) {
$this->avvisa("{$user->email} è già confermato.");
return;
}
if ($user->inviaConfermaEmail()) {
$this->avvisa("Email di conferma inviata a {$user->email}.");
} else {
$this->avvisaErrore('L\'email non è partita: controlla il server di posta.');
}
} }
public function startEdit(int $userId): void public function startEdit(int $userId): void
@@ -115,6 +144,23 @@ class UsersIndex extends Component
$validated = $this->validate($this->editRules()); $validated = $this->validate($this->editRules());
$user = User::query()->findOrFail($this->editingUserId); $user = User::query()->findOrFail($this->editingUserId);
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
// rimasto, lascia l'installazione senza nessuno che possa gestire
// utenti, impostazioni e backup.
if ($user->hasRole('amministratore') && $validated['editSelectedRole'] !== 'amministratore') {
if ($user->id === auth()->id()) {
$this->addError('editSelectedRole', 'Non puoi togliere a te stesso il ruolo di amministratore: chiedilo a un altro amministratore.');
return;
}
if (User::role('amministratore')->count() <= 1) {
$this->addError('editSelectedRole', "È l'ultimo amministratore: assegna prima il ruolo a un altro utente.");
return;
}
}
$user->name = $validated['editName']; $user->name = $validated['editName'];
$user->email = $validated['editEmail']; $user->email = $validated['editEmail'];
@@ -122,11 +168,25 @@ class UsersIndex extends Component
$user->password = $validated['editPassword']; $user->password = $validated['editPassword'];
} }
$emailCambiata = $user->isDirty('email');
$user->save(); $user->save();
$user->syncRoles([$validated['editSelectedRole']]); $user->syncRoles([$validated['editSelectedRole']]);
$this->cancelEdit(); $this->cancelEdit();
session()->flash('success', 'Utente aggiornato con successo.');
// Indirizzo nuovo: torna da confermare (User::booted) e gli si manda
// il link, altrimenti il suo account smetterebbe di funzionare senza
// che sappia perché.
if ($emailCambiata) {
$inviata = $user->inviaConfermaEmail();
$inviata
? $this->avvisa("Utente aggiornato. Il nuovo indirizzo va confermato: email inviata a {$user->email}.")
: $this->avvisaErrore('Utente aggiornato, ma l\'email di conferma del nuovo indirizzo non è partita: controlla il server di posta.');
return;
}
$this->avvisa('Utente aggiornato con successo.');
} }
public function deleteUser(int $userId): void public function deleteUser(int $userId): void
@@ -135,12 +195,12 @@ class UsersIndex extends Component
$user = User::query()->with('roles')->findOrFail($userId); $user = User::query()->with('roles')->findOrFail($userId);
if (! $currentUser || $currentUser->id === $user->id) { if (! $currentUser || $currentUser->id === $user->id) {
session()->flash('error', 'Non puoi cancellare il tuo utente.'); $this->avvisaErrore('Non puoi cancellare il tuo utente.');
return; return;
} }
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) { if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) {
session()->flash('error', 'Non puoi cancellare l\'ultimo amministratore.'); $this->avvisaErrore('Non puoi cancellare l\'ultimo amministratore.');
return; return;
} }
@@ -180,7 +240,7 @@ class UsersIndex extends Component
$this->cancelEdit(); $this->cancelEdit();
} }
session()->flash('success', 'Utente cancellato. I log sono stati preservati.'); $this->avvisa('Utente cancellato. I log sono stati preservati.');
} }
public function render() public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Jobs\ImportTerritoryPdfFolder; use App\Jobs\ImportTerritoryPdfFolder;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Assegnazione; use App\Models\Assegnazione;
@@ -9,20 +10,27 @@ use App\Models\Campagna;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\Tipologia; use App\Models\Tipologia;
use App\Models\User; use App\Models\User;
use App\Models\Zona; use App\Models\Zona;
use App\Services\BackupOperationState;
use App\Services\TerritorioPdfImportDispatcher; use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioPdfImportState; use App\Services\TerritorioPdfImportState;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
class XmlExchange extends Component class XmlExchange extends Component
{ {
use Avvisa;
use WithFileUploads; use WithFileUploads;
public array $importStats = []; public array $importStats = [];
@@ -52,39 +60,149 @@ class XmlExchange extends Component
} }
} }
public function importTerritoryPdfFolder(): void /**
* Quanti file viaggiano in una singola richiesta di caricamento della
* cartella.
*
* Il browser non manda la cartella tutta insieme: la spezza in lotti e li
* aggiunge uno dopo l'altro a $pdfFolder. Un'unica richiesta con tutti i file
* si scontrava con `max_file_uploads` di PHP (20 per default), che non
* rifiuta: scarta in silenzio i file in piu', e l'import partiva con i primi
* venti di una cartella da trecento. Alzare il limite non basterebbe: la
* richiesta unica resterebbe soggetta al tetto di dimensione e al timeout di
* 100 secondi di Cloudflare, e un errore a meta' farebbe ricominciare da capo.
* A lotti, ogni richiesta e' piccola e un errore ne ferma una sola.
*
* Si legge il valore vero di PHP, perche' un'installazione con un limite piu'
* basso non perda file in silenzio come prima.
*/
public function getFilePerLottoProperty(): int
{ {
$this->validate([ return max(1, min(20, (int) ini_get('max_file_uploads') ?: 20));
'pdfFolder' => ['required', 'array', 'min:1'], }
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]);
$importId = (string) Str::uuid(); /**
$storedFiles = []; * Controllo rapido prima di caricare la cartella: senza, si caricherebbero
* centinaia di file per poi sentirsi dire che c'e' un ripristino in corso.
* Non prende il posto — lo prende importTerritoryPdfFolder(), a caricamento
* finito — perche' una scheda chiusa a meta' caricamento lo terrebbe
* occupato per un'ora.
*/
public function cartellaPuoPartire(): bool
{
$inCorso = app(BackupOperationState::class)->corrente();
foreach ($this->pdfFolder as $index => $file) { if ($inCorso === null) {
$originalName = $file->getClientOriginalName(); return true;
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME)); }
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
'local'
);
$storedFiles[] = [ $this->avvisaErrore(app(BackupOperationState::class)->messaggioOccupato($inCorso));
'original_name' => $originalName,
'stored_path' => $storedPath, return false;
]; }
/** Il browser azzera i lotti di un caricamento precedente prima di iniziarne uno nuovo. */
public function svuotaCartella(): void
{
foreach ($this->pdfFolder as $file) {
if ($file instanceof TemporaryUploadedFile) {
$file->delete();
}
} }
$this->pdfFolder = []; $this->pdfFolder = [];
}
/**
* Mette in coda l'import dei lotti gia' caricati. Restituisce true se
* l'import e' partito: il browser tiene la cartella scelta finche' non lo e',
* cosi' dopo un rifiuto basta ripremere.
*/
public function importTerritoryPdfFolder(): bool
{
$this->validate([
'pdfFolder' => ['required', 'array', 'min:1'],
]);
$importId = (string) Str::uuid();
$dispatcher = app(TerritorioPdfImportDispatcher::class);
try {
$dispatcher->occupaPosto($importId);
} catch (\RuntimeException $e) {
// Il posto l'ha preso qualcun altro fra il controllo iniziale e la
// fine del caricamento: si avvisa, e non si tocca niente.
$this->avvisaErrore($e->getMessage());
return false;
}
$storedFiles = [];
$ignorati = [];
$scartati = [];
try {
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
// Thumbs.db, .DS_Store, desktop.ini e simili: prima uno solo di
// questi faceva fallire la validazione e con lei l'intero import.
if (TerritorioPdfImportDispatcher::daIgnorare($originalName)) {
$ignorati[] = $originalName;
continue;
}
// Un PDF rotto o troppo grande si scarta da solo, con il suo
// motivo nell'esito: non e' una ragione per buttare gli altri.
$verifica = Validator::make(['file' => $file], ['file' => ['file', 'mimes:pdf', 'max:10240']]);
if ($verifica->fails()) {
$scartati[] = [
'file' => $originalName,
'message' => 'File non valido: ' . $verifica->errors()->first('file'),
];
continue;
}
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf',
'local'
);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
}
} catch (\Throwable $e) {
$dispatcher->liberaPosto($importId, 'Import PDF non avviato: ' . $e->getMessage());
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw $e;
}
$this->svuotaCartella();
if ($storedFiles === []) {
$dispatcher->liberaPosto($importId, 'Nessun PDF da importare.');
$this->avvisaErrore('Nella cartella non c\'è nessun PDF da importare'
. ($ignorati !== [] ? ' (ignorati ' . count($ignorati) . ' file di sistema o di altro tipo)' : '')
. ($scartati !== [] ? ' (' . count($scartati) . ' PDF non validi: ' . implode(', ', array_column($scartati, 'file')) . ')' : '')
. '.');
return false;
}
$this->dispatchPdfImport( $this->dispatchPdfImport(
$importId, $importId,
$storedFiles, $storedFiles,
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.' 'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
$ignorati,
$scartati
); );
return true;
} }
public function refreshPdfImportStatus(): void public function refreshPdfImportStatus(): void
@@ -114,8 +232,9 @@ class XmlExchange extends Component
* contenuti non possono essere ri-renderizzati dal server senza cancellare * contenuti non possono essere ri-renderizzati dal server senza cancellare
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso * le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita * meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState) e * perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState).
* non partecipa al posto unico: puo' girare mentre un backup lavora. * Del posto unico usa solo l'occupazione (BackupOperationState::occupa()):
* non gira mai insieme a un backup, un ripristino o un azzeramento.
*/ */
protected function spedisciImportAllaConsole(): void protected function spedisciImportAllaConsole(): void
{ {
@@ -156,10 +275,10 @@ class XmlExchange extends Component
return in_array($this->pdfImportStatus, ['queued', 'running'], true); return in_array($this->pdfImportStatus, ['queued', 'running'], true);
} }
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog, array $ignorati = [], array $scartati = []): void
{ {
$state = app(TerritorioPdfImportDispatcher::class) $state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog); ->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
$this->currentPdfImportId = $importId; $this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued'; $this->pdfImportStatus = $state['status'] ?? 'queued';
@@ -172,13 +291,13 @@ class XmlExchange extends Component
$this->logPdfSpediti = 0; $this->logPdfSpediti = 0;
$this->refreshPdfImportStatus(); $this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.'); $this->avvisa('Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
} }
public function downloadImportLogPdf() public function downloadImportLogPdf()
{ {
if (empty($this->importStats)) { if (empty($this->importStats)) {
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.'); $this->avvisaErrore('Nessun log da scaricare. Esegui prima un import XML.');
return; return;
} }
@@ -236,16 +355,99 @@ class XmlExchange extends Component
return $this->datasetToXml($dataset, $source); return $this->datasetToXml($dataset, $source);
} }
/**
* I file sul disco `public` a cui puntano i territori e il loro storico PDF.
*
* Serve all'import XML per sapere, dopo, quali file ha reso orfani. L'import
* cancella tutti i territori e li ricrea con id nuovi; i PDF portano l'id nel
* nome (territorio-142-141.pdf), quindi un file che arriva da un'altra
* installazione, o da un dump della vecchia applicazione senza PDF, lascia i
* file dei territori cancellati sul disco: nessuna schermata li mostra,
* nessuna cancellazione li raggiunge, e finiscono dentro ogni backup
* successivo. E' cosi' che la cartella territori-pdf si era trovata con due
* PDF per territorio.
*
* @return array<int, string>
*/
public static function fileDeiTerritori(): array
{
return Territorio::withTrashed()->get(['pdf_path', 'thumbnail_path'])
->concat(TerritorioPdfVersione::query()->get(['pdf_path', 'thumbnail_path']))
->flatMap(fn ($riga) => [$riga->pdf_path, $riga->thumbnail_path])
->filter()
->unique()
->values()
->all();
}
/**
* Elimina i file che prima dell'import appartenevano a un territorio e che
* adesso nessuna riga richiama piu'.
*
* Si toccano solo i file elencati in $fileDiPrima: un file sul disco che
* nessuno conosceva non e' affare dell'import. E prima di cancellare si
* ricontrolla ogni riferimento possibile — anche logo e piantine — cosi' un
* XML che riporta gli stessi percorsi (un export di questa stessa
* installazione) non perde niente.
*
* @param array<int, string> $fileDiPrima
*/
public static function eliminaFileNonPiuUsati(array $fileDiPrima): int
{
$usati = array_flip(array_merge(
self::fileDeiTerritori(),
array_filter([Setting::instance()->logo_path]),
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
));
$disco = Storage::disk('public');
$eliminati = 0;
foreach ($fileDiPrima as $percorso) {
if (isset($usati[$percorso]) || ! $disco->exists($percorso)) {
continue;
}
if ($disco->delete($percorso)) {
$eliminati++;
}
}
return $eliminati;
}
public function importXmlFromContent(string $content): array public function importXmlFromContent(string $content): array
{ {
$this->importStats = []; $this->importStats = [];
$this->importIssues = []; $this->importIssues = [];
$xml = @simplexml_load_string($content); $xml = $this->leggiXml($content);
if (! $xml) { if (is_string($xml)) {
return ['error' => 'Impossibile leggere il file XML.']; return ['error' => $xml];
} }
// L'import parte svuotando le tabelle: un XML ben formato ma estraneo
// (un sitemap, l'export di un altro programma) lascerebbe
// l'installazione vuota con un "completato con successo". Si accetta
// solo un file prodotto da questa applicazione, con le sue sezioni.
if ($xml->getName() !== 'termanager2') {
return ['error' => 'Il file non è un export di TerManager2: l\'elemento principale è <' . $xml->getName() . '>, atteso <termanager2>. Nessun dato è stato modificato.'];
}
$mancanti = array_values(array_filter(
['territori', 'proclamatori', 'assegnazioni'],
fn (string $sezione) => ! isset($xml->{$sezione})
));
if ($mancanti !== []) {
return ['error' => 'Il file è incompleto: mancano le sezioni ' . implode(', ', $mancanti) . '. Nessun dato è stato modificato.'];
}
// Chi esegue l'import (o il ripristino, che imposta l'utente a mano)
// non deve uscirne estromesso: il file può contenere il suo account
// con una password vecchia o senza il ruolo di amministratore.
$attore = auth()->user();
$attoreEraAmministratore = $attore?->hasRole('amministratore') ?? false;
$actorId = auth()->id() ?? User::query()->value('id'); $actorId = auth()->id() ?? User::query()->value('id');
if (! $actorId) { if (! $actorId) {
return ['error' => 'Serve almeno un utente nel sistema per importare i dati.']; return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
@@ -264,7 +466,8 @@ class XmlExchange extends Component
'assegnazioni_saltate' => 0, 'assegnazioni_saltate' => 0,
]; ];
DB::transaction(function () use ($xml, $actorId, &$stats) { try {
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
Assegnazione::query()->delete(); Assegnazione::query()->delete();
Campagna::query()->delete(); Campagna::query()->delete();
Territorio::query()->withTrashed()->forceDelete(); Territorio::query()->withTrashed()->forceDelete();
@@ -272,6 +475,13 @@ class XmlExchange extends Component
Tipologia::query()->delete(); Tipologia::query()->delete();
Zona::query()->delete(); Zona::query()->delete();
AnnoTeocratico::query()->delete(); AnnoTeocratico::query()->delete();
// Il server di posta è configurazione di questa installazione, non
// un dato da trasferire: non si esporta (la password è cifrata con
// la sua APP_KEY) e un import non deve cancellarlo — senza posta
// nessuno riceverebbe più le email di conferma.
$posta = Setting::query()->first()?->only(Setting::CAMPI_POSTA) ?? [];
Setting::query()->delete(); Setting::query()->delete();
// The mass delete above fires no model events, so Setting's // The mass delete above fires no model events, so Setting's
@@ -299,6 +509,10 @@ class XmlExchange extends Component
]); ]);
} }
if ($posta !== []) {
Setting::instance()->update($posta);
}
// Users are UPSERTED by email, never wiped. Deleting the users // Users are UPSERTED by email, never wiped. Deleting the users
// table during a restore risks locking out the administrator // table during a restore risks locking out the administrator
// performing the import (e.g. a backup that predates their account, // performing the import (e.g. a backup that predates their account,
@@ -334,6 +548,10 @@ class XmlExchange extends Component
$roleNames[] = $roleName; $roleNames[] = $roleName;
} }
} }
$eAttore = $attore !== null && $user->id === $attore->id;
if ($eAttore && $attoreEraAmministratore && ! in_array('amministratore', $roleNames, true)) {
$roleNames[] = 'amministratore';
}
$user->syncRoles($roleNames); $user->syncRoles($roleNames);
// Password hash, remember_token, verification flag and // Password hash, remember_token, verification flag and
@@ -341,13 +559,23 @@ class XmlExchange extends Component
// hash is preserved exactly (the 'hashed' cast would re-validate // hash is preserved exactly (the 'hashed' cast would re-validate
// the bcrypt cost and could reject a hash made with a different // the bcrypt cost and could reject a hash made with a different
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps. // BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
// Per chi sta importando password e token restano i suoi:
// quelli del file possono essere di mesi fa.
//
// Chi riceve l'hash dal file deve scegliere una password nuova,
// come dopo un ripristino: chi ha avuto il file fra le mani ha
// potuto tentarne il crack con calma. Il "ricordami" si azzera
// e basta: non si esporta più, e quello di un file vecchio
// sarebbe una credenziale persistente regalata a chi lo tiene.
$meta = []; $meta = [];
$passwordHash = (string) ($userNode->password ?? ''); $passwordHash = (string) ($userNode->password ?? '');
if ($passwordHash !== '') { if ($passwordHash !== '' && ! $eAttore) {
$meta['password'] = $passwordHash; $meta['password'] = $passwordHash;
$meta['password_change_required'] = true;
}
if (! $eAttore) {
$meta['remember_token'] = null;
} }
$rememberToken = (string) ($userNode->remember_token ?? '');
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? '')); $meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? '')); $createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
@@ -401,6 +629,9 @@ class XmlExchange extends Component
'nome' => (string) $proclamatoreNode->nome, 'nome' => (string) $proclamatoreNode->nome,
'cognome' => (string) $proclamatoreNode->cognome, 'cognome' => (string) $proclamatoreNode->cognome,
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1, 'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
// Facoltativi, e assenti nei file di prima dell'ottobre 2026.
'email' => trim((string) ($proclamatoreNode->email ?? '')) ?: null,
'telefono' => trim((string) ($proclamatoreNode->telefono ?? '')) ?: null,
]); ]);
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true); $this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
$stats['proclamatori_importati']++; $stats['proclamatori_importati']++;
@@ -415,8 +646,8 @@ class XmlExchange extends Component
$label = (string) $annoNode->label; $label = (string) $annoNode->label;
$anno = AnnoTeocratico::create([ $anno = AnnoTeocratico::create([
'label' => $label, 'label' => $label,
'start_date' => (string) $annoNode->start_date, 'start_date' => $this->normalizeDateForDb((string) $annoNode->start_date, true),
'end_date' => (string) $annoNode->end_date, 'end_date' => $this->normalizeDateForDb((string) $annoNode->end_date, true),
]); ]);
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode); $this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
$stats['anni_importati']++; $stats['anni_importati']++;
@@ -430,8 +661,8 @@ class XmlExchange extends Component
foreach (($xml->campagne->campagna ?? []) as $campagnaNode) { foreach (($xml->campagne->campagna ?? []) as $campagnaNode) {
$campagna = Campagna::create([ $campagna = Campagna::create([
'descrizione' => (string) $campagnaNode->descrizione, 'descrizione' => (string) $campagnaNode->descrizione,
'start_date' => (string) $campagnaNode->start_date, 'start_date' => $this->normalizeDateForDb((string) $campagnaNode->start_date, true),
'end_date' => (string) $campagnaNode->end_date, 'end_date' => $this->normalizeDateForDb((string) $campagnaNode->end_date, true),
]); ]);
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode); $this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
$stats['campagne_importate']++; $stats['campagne_importate']++;
@@ -455,9 +686,14 @@ class XmlExchange extends Component
continue; continue;
} }
if (!isset($zoneMap[$legacyZonaId], $tipologiaMap[$legacyTipologiaId])) { // Zona e tipologia sono facoltative (le colonne sono nullable e
$this->pushImportIssue('territorio', $legacyId, 'Riferimento zona/tipologia non trovato (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . ')'); // un territorio resta senza quando la sua zona viene
continue; // eliminata): un riferimento vuoto o sconosciuto non è motivo
// per scartare il territorio, che si porterebbe dietro tutto
// il suo storico di assegnazioni.
if (($legacyZonaId !== '' && ! isset($zoneMap[$legacyZonaId]))
|| ($legacyTipologiaId !== '' && ! isset($tipologiaMap[$legacyTipologiaId]))) {
$this->pushImportIssue('territorio', $legacyId, 'Zona o tipologia non trovata (zona=' . $legacyZonaId . ', tipologia=' . $legacyTipologiaId . '): territorio importato senza');
} }
$pdfPath = (string) ($territorioNode->pdf_path ?? ''); $pdfPath = (string) ($territorioNode->pdf_path ?? '');
@@ -465,8 +701,8 @@ class XmlExchange extends Component
$territorio = Territorio::create([ $territorio = Territorio::create([
'numero' => $numero, 'numero' => $numero,
'zona_id' => $zoneMap[$legacyZonaId], 'zona_id' => $zoneMap[$legacyZonaId] ?? null,
'tipologia_id' => $tipologiaMap[$legacyTipologiaId], 'tipologia_id' => $tipologiaMap[$legacyTipologiaId] ?? null,
'confini' => (string) ($territorioNode->confini ?? ''), 'confini' => (string) ($territorioNode->confini ?? ''),
'note' => (string) ($territorioNode->note ?? ''), 'note' => (string) ($territorioNode->note ?? ''),
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1, 'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
@@ -540,7 +776,15 @@ class XmlExchange extends Component
$this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode); $this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode);
$stats['assegnazioni_importate']++; $stats['assegnazioni_importate']++;
} }
}); });
} catch (\Illuminate\Database\QueryException $e) {
// La transazione è già stata annullata: il database è com'era.
// Senza questo l'amministratore vedrebbe un "Server Error" senza
// sapere quale dato del file ha violato un vincolo.
report($e);
return ['error' => 'Import annullato, nessun dato è stato modificato: il file contiene un dato che il database rifiuta (' . Str::limit($e->getPrevious()?->getMessage() ?? $e->getMessage(), 240) . ').'];
}
return [ return [
'stats' => $stats, 'stats' => $stats,
@@ -576,21 +820,81 @@ class XmlExchange extends Component
'email' => (string) $u->email, 'email' => (string) $u->email,
'password' => (string) $u->getAuthPassword(), 'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '', 'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
'remember_token' => (string) ($u->getRememberToken() ?? ''), // remember_token no: all'import si azzera comunque, e nel file
// sarebbe solo una credenziale in più per chi lo tiene.
'roles' => $u->roles->pluck('name')->all(), 'roles' => $u->roles->pluck('name')->all(),
'created_at' => optional($u->created_at)->toIso8601String() ?? '', 'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '', 'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
])->all(), ])->all(),
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(), 'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(), 'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at'])->toArray(), 'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'email', 'telefono', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at'])->toArray(), 'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(), 'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(), 'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at'])->toArray(), 'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at']),
]; ];
} }
/**
* Righe da esportare, con le date scritte in modo che il reimport le
* ritrovi identiche.
*
* toArray() serializza ogni data in UTC: con l'applicazione su
* Europe/Rome il 15/03 a mezzanotte diventava "2024-03-14T23:00:00Z" e
* al reimport tornava 14/03, un giorno perso a ogni backup e ripristino.
* Le colonne con cast date escono come Y-m-d, che non ha fuso; i
* timestamp come ISO 8601 con lo scostamento esplicito.
*
* @param array<int, string> $colonne
* @return array<int, array<string, mixed>>
*/
private function righeEsportate(\Illuminate\Database\Eloquent\Builder $query, array $colonne): array
{
return $query->get($colonne)->map(function (\Illuminate\Database\Eloquent\Model $riga) use ($colonne) {
$dati = [];
foreach ($colonne as $colonna) {
$valore = $riga->getAttribute($colonna);
if ($valore instanceof \Carbon\CarbonInterface) {
$valore = $riga->hasCast($colonna, ['date', 'immutable_date'])
? $valore->format('Y-m-d')
: $valore->toIso8601String();
}
$dati[$colonna] = $valore;
}
return $dati;
})->all();
}
/**
* Il file come SimpleXMLElement, oppure il messaggio d'errore con riga e
* motivo: "impossibile leggere il file" da solo non aiuta a correggerlo.
*/
private function leggiXml(string $content): \SimpleXMLElement|string
{
$precedente = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
$xml = simplexml_load_string($content);
if ($xml !== false) {
return $xml;
}
$errore = libxml_get_errors()[0] ?? null;
return $errore
? sprintf('Impossibile leggere il file XML (riga %d): %s.', $errore->line, trim($errore->message))
: 'Impossibile leggere il file XML.';
} finally {
libxml_clear_errors();
libxml_use_internal_errors($precedente);
}
}
private function legacySqlToDataset(string $sql): array private function legacySqlToDataset(string $sql): array
{ {
$rows = $this->extractInsertRows($sql); $rows = $this->extractInsertRows($sql);
@@ -880,8 +1184,12 @@ class XmlExchange extends Component
return $nullable ? null : null; return $nullable ? null : null;
} }
// Riportata nel fuso dell'applicazione prima di tenere solo il giorno:
// i backup fatti prima della correzione dell'export hanno le date in
// UTC ("2024-03-14T23:00:00Z" per il 15/03), e così tornano giuste.
// Una data senza orario e senza fuso resta com'è.
try { try {
return Carbon::parse($raw)->toDateString(); return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateString();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return $nullable ? null : null; return $nullable ? null : null;
} }
@@ -923,7 +1231,6 @@ class XmlExchange extends Component
$this->addXmlText($node, 'email', (string) ($user['email'] ?? '')); $this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? '')); $this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? '')); $node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? '')); $node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? '')); $node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles'); $rolesNode = $node->addChild('roles');
@@ -956,6 +1263,8 @@ class XmlExchange extends Component
$this->addLegacyIdAttribute($node, $proclamatore); $this->addLegacyIdAttribute($node, $proclamatore);
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? '')); $this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? '')); $this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
$this->addXmlText($node, 'email', (string) ($proclamatore['email'] ?? ''));
$this->addXmlText($node, 'telefono', (string) ($proclamatore['telefono'] ?? ''));
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? '')); $node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
$this->addTimestampNodes($node, $proclamatore); $this->addTimestampNodes($node, $proclamatore);
@@ -1022,6 +1331,13 @@ class XmlExchange extends Component
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
{ {
// XML 1.0 non ammette i caratteri di controllo (tranne tab e a capo)
// né l'UTF-8 non valido: uno solo, magari incollato da Word in una
// nota, rende l'intero file illeggibile al reimport — e quel file è
// anche il data.xml dei backup.
$value = mb_scrub($value, 'UTF-8');
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '';
$child = $parent->addChild($name); $child = $parent->addChild($name);
$dom = dom_import_simplexml($child); $dom = dom_import_simplexml($child);
$dom->textContent = $value; $dom->textContent = $value;
@@ -1062,7 +1378,7 @@ class XmlExchange extends Component
} }
try { try {
return Carbon::parse($raw)->toDateTimeString(); return Carbon::parse($raw)->setTimezone(config('app.timezone'))->toDateTimeString();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return null; return null;
} }

View File

@@ -98,8 +98,13 @@ class ZonaPericolosa extends Component
return; return;
} }
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
if ($operationId === null) {
return;
}
$this->confermaReset = ''; $this->confermaReset = '';
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET); $this->operationId = $operationId;
CreateBackupArchive::dispatch($this->operationId, true); CreateBackupArchive::dispatch($this->operationId, true);

View File

@@ -2,11 +2,13 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component; use Livewire\Component;
use App\Models\Zona; use App\Models\Zona;
class ZoneIndex extends Component class ZoneIndex extends Component
{ {
use Avvisa;
public string $nuovaZona = ''; public string $nuovaZona = '';
public ?int $editingId = null; public ?int $editingId = null;
public string $editingNome = ''; public string $editingNome = '';
@@ -16,7 +18,7 @@ class ZoneIndex extends Component
$this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']); $this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']);
Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]); Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]);
$this->nuovaZona = ''; $this->nuovaZona = '';
session()->flash('success', 'Zona aggiunta.'); $this->avvisa('Zona aggiunta.');
} }
public function startEdit(int $id) public function startEdit(int $id)
@@ -33,7 +35,7 @@ class ZoneIndex extends Component
$zona->update(['nome' => $this->editingNome]); $zona->update(['nome' => $this->editingNome]);
$this->editingId = null; $this->editingId = null;
$this->editingNome = ''; $this->editingNome = '';
session()->flash('success', 'Zona aggiornata.'); $this->avvisa('Zona aggiornata.');
} }
public function cancelEdit() public function cancelEdit()
@@ -52,11 +54,11 @@ class ZoneIndex extends Component
{ {
$zona = Zona::findOrFail($id); $zona = Zona::findOrFail($id);
if ($zona->territori()->exists()) { if ($zona->territori()->exists()) {
session()->flash('error', "Impossibile eliminare: la zona '{$zona->nome}' ha territori associati."); $this->avvisaErrore("Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
return; return;
} }
$zona->delete(); $zona->delete();
session()->flash('success', "Zona '{$zona->nome}' eliminata."); $this->avvisa("Zona '{$zona->nome}' eliminata.");
} }
public function render() public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori; namespace App\Livewire\Territori;
use App\Livewire\Concerns\Avvisa;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Territorio; use App\Models\Territorio;
@@ -10,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
class TerritorioCestino extends Component class TerritorioCestino extends Component
{ {
use Avvisa;
use WithPagination; use WithPagination;
public function restore(int $id) public function restore(int $id)
@@ -21,7 +23,7 @@ class TerritorioCestino extends Component
activity()->causedBy(auth()->user()) activity()->causedBy(auth()->user())
->performedOn($territorio) ->performedOn($territorio)
->log('restored'); ->log('restored');
session()->flash('success', "Territorio {$territorio->numero} ripristinato."); $this->avvisa("Territorio {$territorio->numero} ripristinato.");
} }
public function forceDelete(int $id) public function forceDelete(int $id)
@@ -31,7 +33,7 @@ class TerritorioCestino extends Component
$territorio = Territorio::onlyTrashed()->findOrFail($id); $territorio = Territorio::onlyTrashed()->findOrFail($id);
if ($territorio->assegnazioni()->exists()) { if ($territorio->assegnazioni()->exists()) {
session()->flash('error', 'Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.'); $this->avvisaErrore('Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
return; return;
} }
@@ -48,7 +50,7 @@ class TerritorioCestino extends Component
$this->rimuoviFile($territorio); $this->rimuoviFile($territorio);
$territorio->forceDelete(); $territorio->forceDelete();
session()->flash('success', 'Territorio eliminato definitivamente.'); $this->avvisa('Territorio eliminato definitivamente.');
} }
/** /**

View File

@@ -64,14 +64,17 @@ class TerritorioEdit extends Component
if ($this->pdf) { if ($this->pdf) {
$thumbService = app(TerritorioThumbnailService::class); $thumbService = app(TerritorioThumbnailService::class);
// Remove old PDF and thumbnail // Prima il nuovo, poi il vecchio nello storico (ripristinabile
if ($this->territorio->pdf_path) { // dalla pagina Piantine). Prima il vecchio si cancellava subito:
Storage::disk('public')->delete($this->territorio->pdf_path); // un caricamento sbagliato non si poteva disfare, e con il
} // salvataggio fallito il territorio restava senza PDF.
if ($this->territorio->thumbnail_path) {
$thumbService->delete($this->territorio->thumbnail_path);
}
$pdfPath = $this->pdf->store('territori-pdf', 'public'); $pdfPath = $this->pdf->store('territori-pdf', 'public');
if (! $pdfPath) {
$this->addError('pdf', 'Non è stato possibile salvare il PDF: quello in uso non è stato toccato.');
return;
}
app(\App\Services\Piantine\PiantinaPdfService::class)->archiviaCorrente($this->territorio, 'manuale');
$data['pdf_path'] = $pdfPath; $data['pdf_path'] = $pdfPath;
$data['thumbnail_path'] = $thumbService->generate($pdfPath); $data['thumbnail_path'] = $thumbService->generate($pdfPath);
} }

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori; namespace App\Livewire\Territori;
use App\Livewire\Concerns\Avvisa;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@@ -11,6 +12,7 @@ use App\Models\Tipologia;
class TerritorioIndex extends Component class TerritorioIndex extends Component
{ {
use Avvisa;
use WithPagination; use WithPagination;
public string $search = ''; public string $search = '';
@@ -127,7 +129,7 @@ class TerritorioIndex extends Component
->performedOn($territorio) ->performedOn($territorio)
->log('soft_deleted'); ->log('soft_deleted');
$territorio->delete(); $territorio->delete();
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino."); $this->avvisa("Territorio {$territorio->numero} spostato nel cestino.");
} }
public function render() public function render()

View File

@@ -55,6 +55,44 @@ class Assegnazione extends Model
}); });
} }
/**
* L'assegnazione dello stesso territorio il cui periodo si sovrappone a
* quello indicato, oppure null. Un'assegnazione aperta dura fino a oggi e
* oltre; rientro e nuova uscita nello stesso giorno non si sovrappongono.
*
* Copre in un colpo solo la doppia assegnazione aperta, l'uscita
* retrodatata a prima dell'ultimo rientro e le correzioni a mano nel
* Registro che accavallano due periodi. Il confronto è fatto in PHP sulle
* poche assegnazioni del territorio: in SQL dipenderebbe da come il
* driver memorizza le DATE (MariaDB "Y-m-d", SQLite "Y-m-d H:i:s").
*/
public static function sovrappostaA(
int $territorioId,
Carbon $uscita,
?Carbon $rientro = null,
?int $esclusaId = null
): ?self {
$uscita = $uscita->copy()->startOfDay();
$rientro = $rientro?->copy()->startOfDay();
return static::with('proclamatore')
->where('territorio_id', $territorioId)
->when($esclusaId, fn ($q) => $q->whereKeyNot($esclusaId))
->orderBy('assigned_at')
->get()
->first(fn (self $altra) => ($rientro === null || $altra->assigned_at->lt($rientro))
&& ($altra->returned_at === null || $uscita->lt($altra->returned_at)));
}
/** Descrizione del periodo per i messaggi d'errore: "a Rossi Mario dal 01/09/2025 al 15/10/2025". */
public function descrizionePeriodo(): string
{
$chi = $this->proclamatore?->nome_completo ?? 'un proclamatore';
$al = $this->returned_at ? ' al ' . $this->returned_at->format('d/m/Y') : ', non ancora rientrato';
return "a {$chi} dal {$this->assigned_at->format('d/m/Y')}{$al}";
}
// ─── Relationships ───────────────────────────────────────── // ─── Relationships ─────────────────────────────────────────
public function territorio() public function territorio()

View File

@@ -46,8 +46,13 @@ class Campagna extends Model
*/ */
public static function attiva(): ?static public static function attiva(): ?static
{ {
return static::where('start_date', '<=', now()) // Confronto sulla data, non sull'ora: end_date vale la mezzanotte del
->where('end_date', '>=', now()) // suo giorno, e contro now() la campagna spariva dalla home proprio
// nel suo ultimo giorno.
$oggi = now()->toDateString();
return static::whereDate('start_date', '<=', $oggi)
->whereDate('end_date', '>=', $oggi)
->first(); ->first();
} }
@@ -70,9 +75,11 @@ class Campagna extends Model
} }
/** /**
* Campaign coverage percentage. * Quota dei territori attivi percorsi durante la campagna.
* Numerator: assignments counted for campaign *
* Denominator: total active territories * Si contano i territori, non le assegnazioni: un territorio percorso due
* volte valeva doppio, e insieme ai territori poi disattivati (fuori dal
* denominatore ma ancora nel numeratore) la percentuale superava il 100%.
*/ */
public function getPercentualePercorrenzaAttribute(): float public function getPercentualePercorrenzaAttribute(): float
{ {
@@ -82,13 +89,17 @@ class Campagna extends Model
return 0.0; return 0.0;
} }
$conteggiate = $this->assegnazioniConteggiate()->count(); $percorsi = $this->assegnazioniConteggiate()
->whereHas('territorio', fn ($q) => $q->where('attivo', true))
->distinct()
->count('territorio_id');
return round(($conteggiate / $totaleAttivi) * 100, 1); return round(($percorsi / $totaleAttivi) * 100, 1);
} }
public function scopeCompletate($query) public function scopeCompletate($query)
{ {
return $query->where('end_date', '<', now())->orderByDesc('end_date'); // Una campagna che finisce oggi è ancora in corso.
return $query->whereDate('end_date', '<', now()->toDateString())->orderByDesc('end_date');
} }
} }

View File

@@ -13,13 +13,16 @@ class Proclamatore extends Model
protected $table = 'proclamatori'; protected $table = 'proclamatori';
protected $fillable = ['nome', 'cognome', 'attivo']; protected $fillable = ['nome', 'cognome', 'email', 'telefono', 'attivo'];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'nome' => 'encrypted', 'nome' => 'encrypted',
'cognome' => 'encrypted', 'cognome' => 'encrypted',
// Facoltativi. Cifrati come nome e cognome: dati personali.
'email' => 'encrypted',
'telefono' => 'encrypted',
'attivo' => 'boolean', 'attivo' => 'boolean',
]; ];
} }
@@ -27,7 +30,7 @@ class Proclamatore extends Model
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
{ {
return LogOptions::defaults() return LogOptions::defaults()
->logOnly(['attivo']) // Do NOT log nome/cognome in audit (encrypted, GDPR) ->logOnly(['attivo']) // Do NOT log nome/cognome/email/telefono in audit (encrypted, GDPR)
->logOnlyDirty() ->logOnlyDirty()
->dontSubmitEmptyLogs(); ->dontSubmitEmptyLogs();
} }
@@ -40,6 +43,46 @@ class Proclamatore extends Model
return trim($this->cognome . ' ' . $this->nome); return trim($this->cognome . ' ' . $this->nome);
} }
/**
* Il numero come lo vuole WhatsApp: solo cifre, con il prefisso
* internazionale. Un numero senza prefisso si considera italiano: un
* cellulare scritto "333 1234567" diventa 393331234567. Null se non c'è un
* numero utilizzabile.
*/
public function telefonoInternazionale(): ?string
{
$grezzo = trim((string) $this->telefono);
if ($grezzo === '') {
return null;
}
$cifre = preg_replace('/\D+/', '', $grezzo);
if (str_starts_with($cifre, '00') && ! str_starts_with($grezzo, '+')) {
$cifre = substr($cifre, 2);
} elseif (! str_starts_with($grezzo, '+') && (! str_starts_with($cifre, '39') || strlen($cifre) <= 10)) {
// Senza prefisso: cellulari (3…) e fissi (0…) italiani. Un
// "39…" di 11 cifre o più ha già il prefisso, scritto senza "+".
$cifre = '39' . $cifre;
}
return strlen($cifre) >= 8 && strlen($cifre) <= 15 ? $cifre : null;
}
/**
* Il link che apre una chat WhatsApp con il proclamatore, con un messaggio
* già scritto se c'è. Null se non ha un numero utilizzabile.
*/
public function whatsappUrl(?string $messaggio = null): ?string
{
$numero = $this->telefonoInternazionale();
if ($numero === null) {
return null;
}
return 'https://wa.me/' . $numero . ($messaggio !== null && $messaggio !== '' ? '?text=' . rawurlencode($messaggio) : '');
}
public function assegnazioni() public function assegnazioni()
{ {
return $this->hasMany(Assegnazione::class, 'proclamatore_id'); return $this->hasMany(Assegnazione::class, 'proclamatore_id');
@@ -63,6 +106,8 @@ class Proclamatore extends Model
{ {
$this->nome = 'Anonimo'; $this->nome = 'Anonimo';
$this->cognome = 'Proclamatore #' . $this->id; $this->cognome = 'Proclamatore #' . $this->id;
$this->email = null;
$this->telefono = null;
$this->attivo = false; $this->attivo = false;
$this->save(); $this->save();
$this->delete(); // soft delete $this->delete(); // soft delete

View File

@@ -20,8 +20,31 @@ class Setting extends Model
'pdf_viewer_show_download', 'pdf_viewer_show_download',
'audit_retention_days', 'audit_retention_days',
'setup_completed', 'setup_completed',
'mail_host',
'mail_port',
'mail_cifratura',
'mail_username',
'mail_password',
'mail_from_address',
'mail_from_name',
]; ];
/**
* Il server di posta: configurazione di questa installazione, non dati.
* Non si esporta e un import non la tocca (vedi XmlExchange).
*/
public const CAMPI_POSTA = [
'mail_host',
'mail_port',
'mail_cifratura',
'mail_username',
'mail_password',
'mail_from_address',
'mail_from_name',
];
protected $hidden = ['mail_password'];
protected function casts(): array protected function casts(): array
{ {
return [ return [
@@ -33,6 +56,8 @@ class Setting extends Model
'home_limit_list' => 'integer', 'home_limit_list' => 'integer',
'assignment_link_ttl_hours' => 'integer', 'assignment_link_ttl_hours' => 'integer',
'audit_retention_days' => 'integer', 'audit_retention_days' => 'integer',
'mail_port' => 'integer',
'mail_password' => 'encrypted',
]; ];
} }

View File

@@ -61,9 +61,13 @@ class Territorio extends Model
public function assegnazioneCorrente() public function assegnazioneCorrente()
{ {
// Il filtro sulle aperte va nella closure di ofMany: scritto fuori con
// whereNull() vale solo per la query esterna, mentre il MAX(assigned_at)
// del sottoquery si calcola su tutte le assegnazioni. Bastava una
// chiusa più recente dell'aperta per far risultare il territorio
// libero mentre era fuori, e quindi assegnabile una seconda volta.
return $this->hasOne(Assegnazione::class, 'territorio_id') return $this->hasOne(Assegnazione::class, 'territorio_id')
->whereNull('returned_at') ->ofMany(['assigned_at' => 'max', 'id' => 'max'], fn ($q) => $q->whereNull('returned_at'));
->latestOfMany('assigned_at');
} }
public function ultimaAssegnazione() public function ultimaAssegnazione()
@@ -216,4 +220,24 @@ class Territorio extends Model
}); });
}); });
} }
// ─── Indirizzi dei file (serviti da TerritorioFileController) ──────────
/**
* ?v= cambia con il file: l'indirizzo resta per territorio, e senza la
* cache del browser mostrerebbe il PDF di prima dopo una sostituzione.
*/
public function pdfUrl(): ?string
{
return $this->pdf_path
? route('territori.file.pdf', ['territorio' => $this->id, 'v' => substr(md5($this->pdf_path), 0, 8)])
: null;
}
public function miniaturaUrl(): ?string
{
return $this->thumbnail_path
? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
: null;
}
} }

View File

@@ -11,15 +11,30 @@ use Illuminate\Database\Eloquent\Model;
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il * cartografia, il numero si decide dopo. Finche' resta scollegato vale il
* campo `nome` come etichetta; una volta collegato comanda il numero del * campo `nome` come etichetta; una volta collegato comanda il numero del
* territorio, che e' il riferimento con cui lo chiamano tutti. * territorio, che e' il riferimento con cui lo chiamano tutti.
*
* `geojson` e' la bozza: l'editor la salva da solo a ogni modifica, e non
* tocca il PDF del territorio. Il PDF cambia solo con la pubblicazione, che
* ricorda cosa e' stato pubblicato (`geojson_pubblicato`,
* `impronta_pubblicata`, `pubblicata_at`).
*/ */
class TerritorioMappa extends Model class TerritorioMappa extends Model
{ {
public const STATO_BOZZA = 'bozza';
public const STATO_PUBBLICATA = 'pubblicata';
public const STATO_MODIFICATA = 'modificata';
protected $table = 'territorio_mappe'; protected $table = 'territorio_mappe';
protected $fillable = [ protected $fillable = [
'territorio_id', 'territorio_id',
'nome', 'nome',
'geojson', 'geojson',
'geojson_pubblicato',
'impronta_pubblicata',
'pdf_pubblicato',
'pubblicata_at',
'image_path', 'image_path',
'rendered_at', 'rendered_at',
]; ];
@@ -28,12 +43,19 @@ class TerritorioMappa extends Model
{ {
return [ return [
'rendered_at' => 'datetime', 'rendered_at' => 'datetime',
'pubblicata_at' => 'datetime',
]; ];
} }
/**
* withTrashed: un territorio nel cestino resta collegato. Senza, la
* relazione tornava null, l'editor mostrava la piantina come scollegata e
* il primo salvataggio cancellava il collegamento — che al ripristino del
* territorio non c'era piu'.
*/
public function territorio() public function territorio()
{ {
return $this->belongsTo(Territorio::class, 'territorio_id'); return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed();
} }
/** /**
@@ -42,15 +64,22 @@ class TerritorioMappa extends Model
*/ */
public function geometria(): ?array public function geometria(): ?array
{ {
$decoded = json_decode($this->geojson, true); $decoded = json_decode((string) $this->geojson, true);
return is_array($decoded) ? $decoded : null; return is_array($decoded) ? $decoded : null;
} }
/** Se è collegata a un territorio, il PDF si può generare. */ /** Collegata a un territorio vivo: è la condizione per pubblicare. */
public function collegata(): bool public function collegata(): bool
{ {
return $this->territorio_id !== null && $this->territorio !== null; return $this->territorio_id !== null
&& $this->territorio !== null
&& ! $this->territorio->trashed();
}
public function territorioNelCestino(): bool
{
return $this->territorio !== null && $this->territorio->trashed();
} }
/** /**
@@ -60,7 +89,7 @@ class TerritorioMappa extends Model
*/ */
public function etichetta(): string public function etichetta(): string
{ {
if ($this->collegata()) { if ($this->territorio !== null) {
return 'Terr. ' . $this->territorio->numero; return 'Terr. ' . $this->territorio->numero;
} }
@@ -68,4 +97,93 @@ class TerritorioMappa extends Model
return $nome !== '' ? $nome : 'Piantina #' . $this->id; return $nome !== '' ? $nome : 'Piantina #' . $this->id;
} }
// ─── Bozza e pubblicazione ──────────────────────────────────────────────
/**
* Impronta di tutto cio' che finisce nel PDF: confine, territorio e i
* suoi dati stampati sulla cartolina (numero, zona come «Località»,
* confini). Se cambia uno qualunque di questi, il PDF in vigore non
* corrisponde piu' e la piantina ha modifiche da pubblicare — anche quando
* il disegno e' rimasto lo stesso.
*/
public function impronta(): string
{
$territorio = $this->territorio;
// Il territorio entra con il numero, non con l'id: il numero è unico e
// il ripristino di un backup ricrea i territori con id nuovi, che
// facevano risultare da pubblicare ogni piantina appena ripristinata.
return hash('sha256', json_encode([
$this->geometria(),
$territorio ? trim((string) $territorio->numero) : null,
$territorio?->zona?->nome,
$territorio ? trim((string) $territorio->confini) : null,
]));
}
public function stato(): string
{
if ($this->pubblicata_at === null) {
return self::STATO_BOZZA;
}
return $this->impronta() === $this->impronta_pubblicata && $this->pdfInUsoDallaPiantina()
? self::STATO_PUBBLICATA
: self::STATO_MODIFICATA;
}
/**
* Il PDF in vigore del territorio è ancora quello prodotto dall'ultima
* pubblicazione. Se nel frattempo è stato sostituito per altre vie
* (ripristino di una versione, caricamento a mano, import), quello che
* arriva ai proclamatori non è più questo disegno, anche se l'impronta
* torna. Si confronta il percorso, che il backup conserva: prima si
* cercava l'id del territorio nel nome del file, e dopo un ripristino
* (id nuovi) non tornava più.
*/
public function pdfInUsoDallaPiantina(): bool
{
return $this->pdf_pubblicato !== null
&& $this->territorio !== null
&& $this->territorio->pdf_path === $this->pdf_pubblicato;
}
/** Il testo dell'etichetta di stato, uguale in ogni pagina. */
public function statoEtichetta(): string
{
return match ($this->stato()) {
self::STATO_BOZZA => 'Bozza',
self::STATO_PUBBLICATA => 'Pubblicata',
self::STATO_MODIFICATA => 'Modifiche da pubblicare',
};
}
/**
* Perché non si può pubblicare, o null se si può. Il testo va a schermo
* accanto al pulsante: dice cosa fare, non solo che manca qualcosa.
*/
public function motivoNonPubblicabile(): ?string
{
if ($this->geometria() === null) {
return 'Disegna il confine della piantina.';
}
if ($this->territorioNelCestino()) {
return 'Il territorio collegato è nel cestino: ripristinalo o collega un altro territorio.';
}
if (! $this->collegata()) {
return 'Collega la piantina a un territorio: il PDF è quello del territorio.';
}
return null;
}
/** Il confine pubblicato è diverso da quello in bozza: si può scartare. */
public function bozzaScartabile(): bool
{
return $this->geojson_pubblicato !== null
&& json_decode($this->geojson_pubblicato, true) != $this->geometria();
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@@ -9,7 +10,12 @@ use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\LogOptions;
class User extends Authenticatable /**
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
* (middleware `verified`, VerificaEmailController). Chi cambia email — dal
* profilo o perché la cambia l'amministratore — torna da confermare.
*/
class User extends Authenticatable implements MustVerifyEmail
{ {
use HasFactory, Notifiable, HasRoles, LogsActivity; use HasFactory, Notifiable, HasRoles, LogsActivity;
@@ -41,4 +47,32 @@ class User extends Authenticatable
->logOnlyDirty() ->logOnlyDirty()
->dontSubmitEmptyLogs(); ->dontSubmitEmptyLogs();
} }
protected static function booted(): void
{
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
static::updating(function (self $utente) {
if ($utente->isDirty('email') && ! $utente->isDirty('email_verified_at')) {
$utente->email_verified_at = null;
}
});
}
/**
* Manda l'email di conferma; false se il server di posta non risponde o
* rifiuta. Chi chiama decide cosa dire: l'utente è comunque salvato, e
* l'email si può richiedere di nuovo.
*/
public function inviaConfermaEmail(): bool
{
try {
$this->sendEmailVerificationNotification();
} catch (\Throwable $e) {
report($e);
return false;
}
return true;
}
} }

View File

@@ -2,8 +2,14 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Setting;
use App\Services\ConfigurazionePosta;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Livewire\Livewire; use Livewire\Livewire;
use RuntimeException; use RuntimeException;
@@ -17,6 +23,48 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
// Setting tiene le impostazioni in una variabile statica, svuotata
// solo quando le salva lo stesso processo. Il worker resta vivo fino a
// un'ora: senza questo, ogni job usava le impostazioni lette dal primo,
// anche dopo che l'amministratore le aveva cambiate dal browser o un
// ripristino le aveva sostituite.
Queue::before(function () {
Setting::flushCache();
// Anche il server di posta: cambiato dal browser, il worker
// continuava a usare quello del primo invio.
if ($this->app->resolved('mail.manager')) {
app(ConfigurazionePosta::class)->applica();
}
});
// L'email di conferma in italiano. Il link resta quello di Laravel
// (firmato, scade dopo auth.verification.expire minuti) e porta a
// VerificaEmailController::conferma, che funziona anche senza login.
VerifyEmail::toMailUsing(function ($utente, string $link) {
$minuti = (int) config('auth.verification.expire', 60);
return (new MailMessage)
->subject('Conferma il tuo indirizzo email — TerManager2')
->greeting('Ciao ' . $utente->name . ',')
->line('per usare TerManager2 conferma che questo indirizzo email è tuo.')
->action('Conferma l\'indirizzo', $link)
->line("Il link vale {$minuti} minuti. Se è scaduto, entra in TerManager2 e chiedine uno nuovo.")
->line('Se non sai di cosa si tratta, ignora questa email.')
->salutation('TerManager2');
});
// Le richieste di Livewire non ripassano dal gruppo di rotte della
// pagina: senza, una scheda aperta prima del blocco continuerebbe a
// lavorare con un'email non confermata.
Livewire::addPersistentMiddleware([EnsureEmailIsVerified::class]);
// Il server di posta configurato in Impostazioni prende il posto di
// quello del .env, appena Laravel crea il sistema di posta.
$this->app->afterResolving('mail.manager', function ($manager) {
app(ConfigurazionePosta::class)->applica($manager);
});
// Pin generated URLs to APP_URL. // Pin generated URLs to APP_URL.
// //
// Behind a trusted proxy, Symfony derives the port from // Behind a trusted proxy, Symfony derives the port from

View File

@@ -20,8 +20,15 @@ use ZipArchive;
* *
* manifest.json formato, data, conteggi (letto per validare il file) * manifest.json formato, data, conteggi (letto per validare il file)
* data.xml lo stesso XML dell'export "Esporta XML" * data.xml lo stesso XML dell'export "Esporta XML"
* piantine.json disegni e storico dei PDF (Piantine\PiantineBackup)
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso * storage/<percorso> ogni file del disco `public`, con lo stesso percorso
* relativo che sta in pdf_path/thumbnail_path/logo_path * relativo che sta in pdf_path/thumbnail_path/logo_path
* piantine/<percorso> l'area KML delle piantine (storage/app/piantine),
* che sta accanto al disco `public`, non dentro
*
* piantine.json e piantine/ esistono dal formato 2. Un archivio di formato 1
* si ripristina lo stesso: le tabelle delle piantine restano vuote e l'area
* KML in uso non si tocca (vedi scambiaFile()).
*/ */
class BackupArchive class BackupArchive
{ {
@@ -29,11 +36,23 @@ class BackupArchive
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di * Versione del formato. L'import rifiuta i pacchetti piu' recenti di
* quello che sa leggere invece di ripristinarli a meta'. * quello che sa leggere invece di ripristinarli a meta'.
*/ */
public const FORMAT_VERSION = 1; public const FORMAT_VERSION = 2;
private const ENTRY_MANIFEST = 'manifest.json'; private const ENTRY_MANIFEST = 'manifest.json';
private const ENTRY_DATA = 'data.xml'; private const ENTRY_DATA = 'data.xml';
private const ENTRY_PIANTINE = 'piantine.json';
private const PREFIX_STORAGE = 'storage/'; private const PREFIX_STORAGE = 'storage/';
private const PREFIX_PIANTINE = 'piantine/';
/**
* Sottocartelle dello staging, una per ogni cartella che il ripristino
* sostituisce (vedi radiciDaSostituire()).
*/
private const STAGING_PUBBLICO = 'pubblico';
private const STAGING_PIANTINE = 'piantine';
/** Cartella dell'area KML sul disco `local` (vedi PiantineArea). */
private const CARTELLA_PIANTINE = 'piantine';
/** /**
* Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza * Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza
@@ -65,12 +84,32 @@ class BackupArchive
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso. * Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
* *
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto). * @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
* @param array $piantine Contenuto di piantine.json (PiantineBackup::esporta()).
* Obbligatorio e non facoltativo di proposito: un
* chiamante che se ne dimentica produrrebbe archivi
* che al ripristino svuotano le piantine.
* @param callable|null $onProgress fn(int $fatti, int $totale): void * @param callable|null $onProgress fn(int $fatti, int $totale): void
*/ */
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string public function create(string $xml, array $piantine, string $destinazione, ?callable $onProgress = null): string
{ {
$disk = Storage::disk('public'); $disk = Storage::disk('public');
$files = $disk->allFiles(); $local = Storage::disk('local');
// Due cartelle sorgente, un solo elenco di terne: percorso relativo
// (per i messaggi), percorso sul disco, nome dell'entry.
$files = [];
foreach ($disk->allFiles() as $relativePath) {
$files[] = [$relativePath, $disk->path($relativePath), self::PREFIX_STORAGE . $relativePath];
}
$filePubblici = count($files);
// allFiles('piantine') restituisce gia' "piantine/area.kml": il
// percorso relativo al disco coincide con il nome dell'entry.
foreach ($local->allFiles(self::CARTELLA_PIANTINE) as $relativePath) {
$files[] = [$relativePath, $local->path($relativePath), $relativePath];
}
$filePiantine = count($files) - $filePubblici;
$totale = count($files); $totale = count($files);
$cartella = dirname($destinazione); $cartella = dirname($destinazione);
@@ -84,12 +123,14 @@ class BackupArchive
} }
$zip->addFromString(self::ENTRY_DATA, $xml); $zip->addFromString(self::ENTRY_DATA, $xml);
$zip->addFromString(self::ENTRY_PIANTINE, json_encode($piantine, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([ $zip->addFromString(self::ENTRY_MANIFEST, json_encode([
'format_version' => self::FORMAT_VERSION, 'format_version' => self::FORMAT_VERSION,
'application' => 'TerManager2', 'application' => 'TerManager2',
'generated_at' => now()->toIso8601String(), 'generated_at' => now()->toIso8601String(),
'app_url' => (string) config('app.url'), 'app_url' => (string) config('app.url'),
'files_count' => $totale, 'files_count' => $filePubblici,
'piantine_files_count' => $filePiantine,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
try { try {
@@ -110,12 +151,12 @@ class BackupArchive
try { try {
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use ( $this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
$disk, $files, $totale, $perLotto, $onProgress, $destinazione $files, $totale, $perLotto, $onProgress, $destinazione
) { ) {
$nelLotto = 0; $nelLotto = 0;
$fatti = 0; $fatti = 0;
foreach ($files as $relativePath) { foreach ($files as [$relativePath, $sorgente, $entry]) {
// addFile() legge dal disco durante close(): i PDF non // addFile() legge dal disco durante close(): i PDF non
// passano mai interi per la memoria del processo. // passano mai interi per la memoria del processo.
// //
@@ -133,7 +174,7 @@ class BackupArchive
// raggiunto mai, e l'amministratore leggerebbe la stringa // raggiunto mai, e l'amministratore leggerebbe la stringa
// grezza di PHP. Stesso problema, e stessa scelta, del // grezza di PHP. Stesso problema, e stessa scelta, del
// close() avvolto in chiudi(). // close() avvolto in chiudi().
if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) { if (! @$zip->addFile($sorgente, $entry)) {
throw new RuntimeException( throw new RuntimeException(
'Impossibile aggiungere "' . $relativePath . '" al backup: ' 'Impossibile aggiungere "' . $relativePath . '" al backup: '
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.' . 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
@@ -157,7 +198,7 @@ class BackupArchive
} }
try { try {
$this->verificaArchivio($destinazione, $totale); $this->verificaArchivio($destinazione, $filePubblici, $filePiantine);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@unlink($destinazione); @unlink($destinazione);
@@ -178,7 +219,7 @@ class BackupArchive
* non dei contenuti — quindi e' un controllo a buon mercato che copre * non dei contenuti — quindi e' un controllo a buon mercato che copre
* troncamenti, chiusure a meta' e lotti persi. * troncamenti, chiusure a meta' e lotti persi.
*/ */
private function verificaArchivio(string $path, int $fileAttesi): void private function verificaArchivio(string $path, int $fileAttesi, int $piantineAttesi): void
{ {
$zip = new ZipArchive(); $zip = new ZipArchive();
@@ -187,27 +228,31 @@ class BackupArchive
} }
$trovati = 0; $trovati = 0;
$piantineTrovati = 0;
for ($i = 0; $i < $zip->numFiles; $i++) { for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i); $nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) { if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
$trovati++; $trovati++;
} elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) {
$piantineTrovati++;
} }
} }
$haDati = $zip->locateName(self::ENTRY_DATA) !== false; $haDati = $zip->locateName(self::ENTRY_DATA) !== false;
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false; $haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
$haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false;
$zip->close(); $zip->close();
if (! $haDati || ! $haManifest) { if (! $haDati || ! $haManifest || ! $haPiantine) {
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.'); throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml, piantine.json o manifest.json.');
} }
if ($trovati !== $fileAttesi) { if ($trovati !== $fileAttesi || $piantineTrovati !== $piantineAttesi) {
throw new RuntimeException(sprintf( throw new RuntimeException(sprintf(
'Il backup appena creato contiene %d file invece di %d: archivio scartato.', 'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
$trovati, $trovati + $piantineTrovati,
$fileAttesi $fileAttesi + $piantineAttesi
)); ));
} }
} }
@@ -294,8 +339,15 @@ class BackupArchive
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i * l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
* dati correnti sono ancora intatti. * dati correnti sono ancora intatti.
* *
* E' anche la verifica completa dell'archivio: ogni entry si estrae per
* intero e se ne confrontano dimensione e CRC con quelli dichiarati. Un
* file troncato, un'entry corrotta o il disco che si riempie durante
* l'estrazione fermano tutto qui — mentre lo staging sta sullo stesso
* volume dei file veri, quindi se c'e' posto per estrarre c'e' posto
* anche per lo scambio, che e' solo una serie di rename().
*
* @param callable|null $onProgress fn(int $fatti, int $totale): void * @param callable|null $onProgress fn(int $fatti, int $totale): void
* @return array{xml: string, staging: string, files: int} * @return array{xml: string, piantine: array|null, formato: int, staging: string, pubblico: string, files: int, file_piantine: int}
*/ */
public function extract(string $zipPath, ?callable $onProgress = null): array public function extract(string $zipPath, ?callable $onProgress = null): array
{ {
@@ -334,12 +386,35 @@ class BackupArchive
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.'); throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.');
} }
// Dal formato 2 piantine.json e' obbligatorio: la sua assenza non
// e' "nessuna piantina" ma un archivio monco, e ripristinarlo
// svuoterebbe disegni e storico senza che nessuno l'abbia chiesto.
// Null resta riservato al formato 1, che le piantine non le aveva.
$piantine = null;
if ($formatVersion >= 2) {
$piantineRaw = $zip->getFromName(self::ENTRY_PIANTINE);
$piantine = $piantineRaw !== false ? json_decode($piantineRaw, true) : null;
if (! is_array($piantine)) {
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_PIANTINE . ' o non e\' leggibile.');
}
}
// Le entry che si estraggono, prefisso => sottocartella dello
// staging. L'area KML solo dal formato 2: un archivio vecchio non
// la porta, e non deve poterla portare per caso.
$prefissi = [self::PREFIX_STORAGE => self::STAGING_PUBBLICO];
if ($formatVersion >= 2) {
$prefissi[self::PREFIX_PIANTINE] = self::STAGING_PIANTINE;
}
if ($zip->numFiles > self::MAX_ENTRIES) { if ($zip->numFiles > self::MAX_ENTRIES) {
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.'); throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
} }
$totalBytes = 0; $totalBytes = 0;
$extracted = 0; $extracted = 0;
$estrattiPiantine = 0;
// Il totale si conta scorrendo i nomi, non come "numFiles meno le // Il totale si conta scorrendo i nomi, non come "numFiles meno le
// due entry di servizio": un archivio con una entry in piu' (una // due entry di servizio": un archivio con una entry in piu' (una
@@ -348,7 +423,7 @@ class BackupArchive
$daEstrarre = 0; $daEstrarre = 0;
for ($i = 0; $i < $zip->numFiles; $i++) { for ($i = 0; $i < $zip->numFiles; $i++) {
$nome = $zip->getNameIndex($i); $nome = $zip->getNameIndex($i);
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) { if ($nome !== false && ! str_ends_with($nome, '/') && $this->prefissoDi($nome, $prefissi) !== null) {
$daEstrarre++; $daEstrarre++;
} }
} }
@@ -361,11 +436,12 @@ class BackupArchive
continue; continue;
} }
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) { $prefisso = $this->prefissoDi($entryName, $prefissi);
if ($prefisso === null) {
continue; continue;
} }
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE)); $relativePath = substr($entryName, strlen($prefisso));
$safePath = $this->safeRelativePath($relativePath); $safePath = $this->safeRelativePath($relativePath);
if ($safePath === null) { if ($safePath === null) {
@@ -375,7 +451,8 @@ class BackupArchive
// La dimensione dichiarata si legge *prima* di estrarre: e' // La dimensione dichiarata si legge *prima* di estrarre: e'
// l'unico momento in cui si puo' rifiutare un'entry che, una // l'unico momento in cui si puo' rifiutare un'entry che, una
// volta decompressa, riempirebbe il disco. // volta decompressa, riempirebbe il disco.
$size = (int) ($zip->statIndex($index)['size'] ?? 0); $stat = $zip->statIndex($index);
$size = (int) ($stat['size'] ?? 0);
if ($size > self::MAX_ENTRY_BYTES) { if ($size > self::MAX_ENTRY_BYTES) {
throw new RuntimeException(sprintf( throw new RuntimeException(sprintf(
@@ -390,7 +467,7 @@ class BackupArchive
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.'); throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
} }
$destination = $staging . '/' . $safePath; $destination = $staging . '/' . $prefissi[$prefisso] . '/' . $safePath;
$directory = dirname($destination); $directory = dirname($destination);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) { if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
@@ -409,12 +486,40 @@ class BackupArchive
throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.'); throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.');
} }
stream_copy_to_stream($stream, $target); // Il risultato della copia si controlla, ed e' qui che il
// ripristino diventa sicuro: prima un disco pieno o un'entry
// corrotta lasciavano nello staging un file troncato senza
// nessun errore, e quel file finiva al posto del PDF buono.
// La @ per lo stesso motivo di addFile(): il warning di PHP
// ("CRC error") arriverebbe all'amministratore al posto del
// messaggio che dice quale file.
$copiati = @stream_copy_to_stream($stream, $target);
fclose($stream); fclose($stream);
fclose($target); $chiuso = fclose($target);
$extracted++; if ($copiati === false || $copiati !== $size || ! $chiuso) {
$onProgress && $onProgress($extracted, $daEstrarre); throw new RuntimeException(sprintf(
'Impossibile estrarre "%s" per intero (%s byte su %d): l\'archivio e\' danneggiato o lo spazio su disco e\' esaurito. Nessun dato e\' stato modificato.',
$safePath,
$copiati === false ? '0' : (string) $copiati,
$size
));
}
// Il CRC lo controlla gia' libzip a fine lettura, ma solo se la
// lettura arriva in fondo e come warning: ricalcolarlo sul file
// scritto costa una rilettura e non dipende da come libzip
// segnala l'errore.
if (isset($stat['crc']) && hash_file('crc32b', $destination) !== sprintf('%08x', $stat['crc'] & 0xFFFFFFFF)) {
throw new RuntimeException('Il file "' . $safePath . '" nell\'archivio e\' danneggiato (CRC non corrispondente). Nessun dato e\' stato modificato.');
}
if ($prefissi[$prefisso] === self::STAGING_PIANTINE) {
$estrattiPiantine++;
} else {
$extracted++;
}
$onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$zip->close(); $zip->close();
@@ -425,63 +530,180 @@ class BackupArchive
$zip->close(); $zip->close();
return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted]; return [
'xml' => $xml,
'piantine' => $piantine,
'formato' => $formatVersion,
'staging' => $staging,
'pubblico' => $staging . '/' . self::STAGING_PUBBLICO,
'files' => $extracted,
'file_piantine' => $estrattiPiantine,
];
}
/** Il prefisso fra quelli ammessi con cui comincia l'entry, o null. */
private function prefissoDi(string $entryName, array $prefissi): ?string
{
foreach (array_keys($prefissi) as $prefisso) {
if (str_starts_with($entryName, $prefisso)) {
return $prefisso;
}
}
return null;
} }
/** /**
* Sostituisce il contenuto del disco `public` con quello estratto. * Mette i file estratti al posto di quelli in uso, tenendo da parte i
* vecchi finche' il ripristino non e' confermato.
* *
* La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle * La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
* righe del database, che il ripristino ha appena riscritto per intero. * righe del database, che il ripristino ha appena riscritto per intero.
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati * Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge. * da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
* *
* @param callable|null $onProgress fn(int $fatti, int $totale): void * Prima si svuotava il disco e poi si ricopiava dallo staging file per
* file: un errore a meta' (disco pieno, permessi) lasciava il database
* nuovo, una parte dei file nuovi e nessuno dei vecchi — irrecuperabile.
* Adesso sono solo rename() sullo stesso volume, per voce di primo livello
* (territori-pdf/, territori-mappe/, logo...): istantanei, senza bisogno
* di spazio, e ognuno annotato in un registro che annullaScambio()
* ripercorre a ritroso. Se uno fallisce, quelli gia' fatti si disfano qui
* e si solleva: il chiamante annulla la transazione e l'installazione
* resta com'era.
*
* Si spostano le voci e non la cartella radice: a quella del disco
* `public` punta il symlink public/storage.
*
* @param array $estratto Il risultato di extract().
* @return array{file: int, accantonati: string, spostamenti: list<array{0: string, 1: string}>}
* Da passare a confermaScambio() a database confermato, o ad
* annullaScambio() se qualcosa fallisce dopo.
*/ */
public function restoreFiles(string $staging, ?callable $onProgress = null): int public function scambiaFile(array $estratto): array
{ {
$disk = Storage::disk('public'); // Fuori da backup-restore/ di proposito: quella cartella la ripulisce
// backup:cleanup dopo un giorno, e qui dentro, se l'annullamento
// stesso fallisse, resterebbero gli unici file buoni.
$scambio = [
'file' => $estratto['files'] + $estratto['file_piantine'],
'accantonati' => storage_path('app/backup-precedenti/' . Str::uuid()),
'spostamenti' => [],
];
$this->svuotaDiscoPubblico(); try {
foreach ($this->radiciDaSostituire($estratto['formato']) as $nome => $radice) {
$this->preparaCartella($radice);
$this->preparaCartella($scambio['accantonati'] . '/' . $nome);
if (! is_dir($staging)) { foreach ($this->vociDi($radice) as $voce) {
return 0; $this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio);
}
// Una sottocartella assente nello staging vuol dire "nessun
// file": la radice resta vuota, come nell'installazione
// salvata.
$nuovi = $estratto['staging'] . '/' . $nome;
foreach ($this->vociDi($nuovi) as $voce) {
$this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio);
}
}
} catch (\Throwable $e) {
$this->annullaScambio($scambio, $e);
throw $e;
} }
$restored = 0; return $scambio;
$totale = iterator_count(new \RecursiveIteratorIterator( }
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
));
$onProgress && $onProgress(0, $totale);
$iterator = new \RecursiveIteratorIterator( /** Il ripristino e' confermato: i file precedenti non servono piu'. */
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS) public function confermaScambio(array $scambio): void
); {
$this->deleteDirectory($scambio['accantonati']);
}
foreach ($iterator as $file) { /**
/** @var \SplFileInfo $file */ * Rimette ogni file dov'era prima di scambiaFile(), ripercorrendo gli
if (! $file->isFile()) { * spostamenti a ritroso: i nuovi tornano nello staging, i vecchi al loro
continue; * posto.
*
* Se anche questo fallisce non si cancella niente: i file precedenti
* restano nella cartella degli accantonati, e il messaggio dice dove, cosi'
* si possono rimettere a mano invece di darli per persi.
*/
public function annullaScambio(array $scambio, ?\Throwable $causa = null): void
{
try {
foreach (array_reverse($scambio['spostamenti']) as [$da, $a]) {
if (! @rename($a, $da)) {
throw new RuntimeException('impossibile rimettere "' . $da . '"');
}
} }
} catch (\Throwable $e) {
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1)); throw new RuntimeException(
($causa ? rtrim($causa->getMessage(), '. ') . '. ' : '')
$handle = fopen($file->getPathname(), 'rb'); . 'Ripristino dei file precedenti non riuscito (' . $e->getMessage() . '): '
if ($handle === false) { . 'li trovi in ' . $scambio['accantonati'] . ' e vanno rimessi a mano.',
continue; 0,
} $causa ?? $e
);
$disk->writeStream($relativePath, $handle);
if (is_resource($handle)) {
fclose($handle);
}
$restored++;
$onProgress && $onProgress($restored, $totale);
} }
return $restored; $this->deleteDirectory($scambio['accantonati']);
}
/**
* Le cartelle che il ripristino sostituisce: sottocartella dello staging
* => cartella vera.
*
* L'area KML solo con un archivio di formato 2, che la porta sempre (anche
* vuota, se l'installazione salvata non ne aveva). Un archivio di formato
* 1 non sa niente dell'area, e svuotarla per questo vorrebbe dire
* buttare un file che con i dati ripristinati non ha nessun legame: nessuna
* riga lo cita, e' la mappa della citta'.
*
* @return array<string, string>
*/
private function radiciDaSostituire(int $formato): array
{
$radici = [self::STAGING_PUBBLICO => rtrim(Storage::disk('public')->path(''), '/\\')];
if ($formato >= 2) {
$radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
}
return $radici;
}
/** @return list<string> Le voci di primo livello di una cartella (vuoto se non esiste). */
private function vociDi(string $cartella): array
{
if (! is_dir($cartella)) {
return [];
}
return array_values(array_diff(scandir($cartella) ?: [], ['.', '..']));
}
private function preparaCartella(string $cartella): void
{
if (! is_dir($cartella) && ! @mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
throw new RuntimeException('Impossibile preparare la cartella "' . $cartella . '" per il ripristino.');
}
}
private function sposta(string $da, string $a, array &$scambio): void
{
if (! @rename($da, $a)) {
throw new RuntimeException(sprintf(
'Impossibile spostare "%s" durante il ripristino. Di solito e\' un problema di permessi: '
. 'controlla che il volume storage appartenga a www-data. Nessun dato e\' stato modificato.',
basename($da)
));
}
$scambio['spostamenti'][] = [$da, $a];
} }
/** /**

View File

@@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
/** /**
@@ -23,6 +24,18 @@ class BackupOperationState
public const TIPO_RESTORE = 'restore'; public const TIPO_RESTORE = 'restore';
public const TIPO_RESET = 'reset'; public const TIPO_RESET = 'reset';
/**
* Gli import non sono operazioni di backup, ma occupano lo stesso posto:
* l'import XML svuota e riscrive le tabelle, l'import PDF sostituisce file
* e righe dei territori. Uno dei due in parallelo a un ripristino (o a un
* backup che sta copiando quegli stessi file) produce uno stato che nessuno
* ha chiesto. Il loro avanzamento non sta qui: l'import PDF ha
* TerritorioPdfImportState, l'import XML e' sincrono. Qui c'e' solo
* l'occupazione del posto.
*/
public const TIPO_IMPORT_PDF = 'import-pdf';
public const TIPO_IMPORT_XML = 'import-xml';
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */ /** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
public const FASE_ATTESA_DOWNLOAD = 'attesa-download'; public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
@@ -48,6 +61,16 @@ class BackupOperationState
private const CHIAVE_CORRENTE = 'backup-op:corrente'; private const CHIAVE_CORRENTE = 'backup-op:corrente';
private const TTL_CORRENTE = 3600; private const TTL_CORRENTE = 3600;
/**
* Mutex attorno a "il posto e' libero? allora lo prendo".
*
* corrente() seguito da initialize() sono due passi: due richieste che
* arrivano insieme vedono entrambe il posto libero, ed entrambe lo prendono
* — la seconda sovrascrive il puntatore della prima, e i due lavori girano
* in parallelo proprio come il posto unico doveva impedire.
*/
private const CHIAVE_AVVIO = 'backup-op:avvio';
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
{ {
$state = [ $state = [
@@ -89,16 +112,78 @@ class BackupOperationState
$stato = $this->get($id); $stato = $this->get($id);
// Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto // Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto
// e' libero, e il puntatore va tolto di mezzo. // e' libero. Il puntatore pero' qui non si tocca: corrente() la
// chiamano anche le pagine che si aggiornano, fuori dal mutex di
// occupa(), e cancellarlo poteva togliere quello che un'altra
// richiesta aveva appena scritto per un'operazione nuova. Lo
// sovrascrive initialize() al prossimo avvio.
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) { if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
Cache::forget(self::CHIAVE_CORRENTE);
return null; return null;
} }
return $id; return $id;
} }
/**
* Prende il posto unico se e' libero, in un passo solo.
*
* Restituisce null se il posto e' stato preso (e l'operazione inizializzata
* come farebbe initialize()), altrimenti l'id dell'operazione che lo occupa.
* Tutte le porte d'ingresso passano da qui proprio per il mutex: un
* controllo con corrente() e un initialize() separati lasciano aperta la
* finestra fra i due.
*
* $ttlPosto serve all'import XML, che e' sincrono: se il processo PHP muore a
* meta' (timeout, errore fatale) nessun failed() chiude l'operazione, e il
* posto resterebbe occupato per l'ora intera del TTL ordinario.
*/
public function occupa(string $id, string $tipo, string $fase, ?int $ttlPosto = null): ?string
{
try {
return Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id, $tipo, $fase, $ttlPosto) {
$inCorso = $this->corrente();
if ($inCorso !== null) {
return $inCorso;
}
$this->initialize($id, $tipo, $fase);
if ($ttlPosto !== null) {
Cache::put(self::CHIAVE_CORRENTE, $id, $ttlPosto);
}
return null;
});
} catch (LockTimeoutException) {
// Qualcun altro sta prendendo il posto proprio adesso: il mutex
// copre pochi millisecondi, quindi cinque secondi d'attesa vogliono
// dire che il posto non e' disponibile, non che va forzato.
return $this->corrente() ?? 'avvio-in-corso';
}
}
/**
* Il rifiuto spiegato: chi si vede negare un import deve sapere che cosa lo
* blocca, perche' l'operazione in corso puo' essere stata avviata da un
* altro amministratore in un'altra zona della pagina.
*/
public function messaggioOccupato(string $idInCorso): string
{
$tipo = $this->get($idInCorso)['tipo'] ?? null;
$cosa = match ($tipo) {
self::TIPO_BACKUP => ' (un backup)',
self::TIPO_RESTORE => ' (un ripristino)',
self::TIPO_RESET => ' (un azzeramento)',
self::TIPO_IMPORT_PDF => ' (un import dei PDF dei territori)',
self::TIPO_IMPORT_XML => ' (un import XML)',
default => '',
};
return 'C\'è già un\'operazione in corso' . $cosa . ': attendi che finisca prima di avviarne un\'altra.';
}
public function get(string $id): ?array public function get(string $id): ?array
{ {
return Cache::get($this->key($id)); return Cache::get($this->key($id));
@@ -213,8 +298,17 @@ class BackupOperationState
*/ */
private function liberaCorrente(string $id): void private function liberaCorrente(string $id): void
{ {
if (Cache::get(self::CHIAVE_CORRENTE) === $id) { // Leggi-e-cancella sotto lo stesso mutex di occupa(): fra i due passi
Cache::forget(self::CHIAVE_CORRENTE); // un'altra richiesta poteva prendere il posto, e il forget le toglieva
// il puntatore. Se il mutex non arriva si lascia stare: il puntatore a
// un'operazione chiusa vale già come posto libero (vedi corrente()).
try {
Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id) {
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
Cache::forget(self::CHIAVE_CORRENTE);
}
});
} catch (LockTimeoutException) {
} }
} }

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Mail\MailManager;
use Illuminate\Support\Facades\Mail;
use RuntimeException;
/**
* Il server di posta configurato dall'applicazione (Impostazioni → Posta).
*
* Se in Impostazioni c'è un server, vale quello; altrimenti resta quello del
* .env, come prima. La configurazione si applica quando Laravel crea il
* sistema di posta (AppServiceProvider) e prima di ogni job della coda,
* perché il worker vive a lungo e avrebbe tenuto quella del primo invio.
*/
class ConfigurazionePosta
{
public const CIFRATURA_AUTOMATICA = 'auto';
public const CIFRATURA_SSL = 'ssl';
public const CIFRATURA_NESSUNA = 'nessuna';
public const CIFRATURE = [
self::CIFRATURA_AUTOMATICA => 'Automatica (STARTTLS se il server la offre, di solito porta 587)',
self::CIFRATURA_SSL => 'SSL/TLS dall\'inizio (di solito porta 465)',
self::CIFRATURA_NESSUNA => 'Nessuna (solo per server interni)',
];
/** C'è un server di posta configurato dall'applicazione. */
public function configurataNellApp(): bool
{
return trim((string) Setting::instance()->mail_host) !== '';
}
/**
* Le email partono davvero: server dall'app, oppure un mailer del .env che
* non sia "log" o "array" (che le scrivono nel log o le buttano).
*/
public function funzionante(): bool
{
if ($this->configurataNellApp()) {
return true;
}
$mailer = (string) config('mail.default');
return ! in_array($mailer, ['log', 'array'], true)
&& ($mailer !== 'smtp' || trim((string) config('mail.mailers.smtp.host')) !== '');
}
/** Porta la configurazione dell'app nel sistema di posta di Laravel. */
public function applica(?MailManager $manager = null): void
{
try {
$impostazioni = Setting::instance();
} catch (\Throwable) {
// Database non ancora pronto (prima installazione, migrazioni in
// corso): resta il .env.
return;
}
if (trim((string) $impostazioni->mail_host) === '') {
return;
}
try {
$password = $impostazioni->mail_password;
} catch (DecryptException) {
// Cifrata con un'altra APP_KEY: meglio tentare senza password (e
// ricevere dal server un errore che dice "autenticazione") che
// far cadere ogni pagina che tocca la posta.
$password = null;
}
$cifratura = $impostazioni->mail_cifratura ?: self::CIFRATURA_AUTOMATICA;
config([
'mail.default' => 'smtp',
'mail.mailers.smtp' => [
'transport' => 'smtp',
'scheme' => $cifratura === self::CIFRATURA_SSL ? 'smtps' : 'smtp',
'host' => $impostazioni->mail_host,
'port' => $impostazioni->mail_port ?: ($cifratura === self::CIFRATURA_SSL ? 465 : 587),
'username' => $impostazioni->mail_username ?: null,
'password' => $password ?: null,
'timeout' => 20,
'local_domain' => config('mail.mailers.smtp.local_domain'),
// Senza, Symfony prova STARTTLS da solo appena il server lo offre.
'auto_tls' => $cifratura !== self::CIFRATURA_NESSUNA,
],
'mail.from.address' => $impostazioni->mail_from_address ?: config('mail.from.address'),
'mail.from.name' => $impostazioni->mail_from_name ?: ($impostazioni->congregazione_nome ?: config('mail.from.name')),
]);
// Un mailer già creato tiene la configurazione con cui è nato.
($manager ?? app('mail.manager'))->purge('smtp');
}
/**
* Invia l'email di prova. Lancia RuntimeException con un messaggio
* leggibile se il server rifiuta o non risponde.
*/
public function inviaProva(string $destinatario): void
{
$this->applica();
try {
Mail::raw(
"Questa è un'email di prova di TerManager2.\n\nSe la stai leggendo, il server di posta è configurato correttamente: "
. 'gli utenti riceveranno le email per confermare il loro indirizzo.',
fn ($messaggio) => $messaggio->to($destinatario)->subject('TerManager2 — email di prova'),
);
} catch (\Throwable $e) {
throw new RuntimeException('Il server di posta ha rifiutato l\'invio: ' . $e->getMessage(), 0, $e);
}
}
}

View File

@@ -36,15 +36,24 @@ class PiantinaPdfService
) { ) {
} }
/** Il PDF completo, come stringa binaria. */ /**
public function genera(Territorio $territorio, TerritorioMappa $mappa): string * Il PDF completo, come stringa binaria.
*
* $immagine è il percorso assoluto di un'immagine diversa da quella
* salvata nella piantina: l'anteprima della pubblicazione compone il PDF
* con l'immagine appena generata, senza averla ancora resa ufficiale.
*/
public function genera(Territorio $territorio, TerritorioMappa $mappa, ?string $immagine = null): string
{ {
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) { if ($immagine === null) {
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.'); if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
}
$immagine = Storage::disk('public')->path($mappa->image_path);
} }
$immagine = Storage::disk('public')->path($mappa->image_path); $dimensioni = @getimagesize($immagine);
$dimensioni = getimagesize($immagine);
if ($dimensioni === false) { if ($dimensioni === false) {
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.'); throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
} }
@@ -92,7 +101,7 @@ class PiantinaPdfService
($h - $imgH) / 2, ($h - $imgH) / 2,
$imgW, $imgW,
$imgH, $imgH,
$this->tipoImmagine($mappa->image_path) $this->tipoImmagine($immagine)
); );
return $pdf->Output('S'); return $pdf->Output('S');
@@ -179,13 +188,26 @@ class PiantinaPdfService
*/ */
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void
{ {
$binario = $this->genera($territorio, $mappa); $this->sostituisciConPdf($territorio, $this->genera($territorio, $mappa), $origine);
}
/**
* Rende ufficiale un PDF già composto, archiviando il precedente.
*
* Prima si scrive il nuovo file, poi si archivia il vecchio: nell'ordine
* inverso un errore di scrittura (disco pieno) lasciava pdf_path puntato
* a un file già spostato nello storico, e i link delle assegnazioni in 404.
*/
public function sostituisciConPdf(Territorio $territorio, string $binario, string $origine = 'piantine'): void
{
$disk = Storage::disk('public'); $disk = Storage::disk('public');
$this->archiviaCorrente($territorio, $origine);
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf'; $nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
$disk->put($nuovoPath, $binario); if (! $disk->put($nuovoPath, $binario)) {
throw new RuntimeException('Non è stato possibile scrivere il nuovo PDF sul disco: il PDF in vigore non è stato toccato.');
}
$this->archiviaCorrente($territorio, $origine);
$territorio->update([ $territorio->update([
'pdf_path' => $nuovoPath, 'pdf_path' => $nuovoPath,
@@ -235,8 +257,11 @@ class PiantinaPdfService
/** /**
* Sposta il PDF attuale del territorio (se esiste) nello storico. * Sposta il PDF attuale del territorio (se esiste) nello storico.
*
* Pubblica perché la usa anche l'import dei PDF da cartella: chi
* sostituisce un PDF lo archivia sempre allo stesso modo.
*/ */
private function archiviaCorrente(Territorio $territorio, string $origine): void public function archiviaCorrente(Territorio $territorio, string $origine): void
{ {
$disk = Storage::disk('public'); $disk = Storage::disk('public');

View File

@@ -3,6 +3,7 @@
namespace App\Services\Piantine; namespace App\Services\Piantine;
use App\Models\TerritorioMappa; use App\Models\TerritorioMappa;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use RuntimeException; use RuntimeException;
@@ -14,6 +15,11 @@ use RuntimeException;
*/ */
class PiantinaRenderer class PiantinaRenderer
{ {
/**
* Rendering e archiviazione come immagine della piantina.
*
* @return string il percorso sul disco pubblico
*/
public function render(TerritorioMappa $mappa): string public function render(TerritorioMappa $mappa): string
{ {
$geometry = $mappa->geometria(); $geometry = $mappa->geometria();
@@ -22,19 +28,38 @@ class PiantinaRenderer
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.'); throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
} }
[$immagine, $estensione] = $this->immagine($geometry);
return $this->archivia($mappa, $immagine, $estensione);
}
/**
* Solo il rendering, senza toccare niente: serve all'anteprima della
* pubblicazione, che non deve cambiare la piantina finché non si conferma.
*
* @return array{0: string, 1: string} contenuto e estensione ('jpg'|'png')
*/
public function immagine(array $geometry): array
{
$formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg'; $formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg';
$response = Http::timeout((int) config('piantine.renderer_timeout')) try {
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [ $response = Http::timeout((int) config('piantine.renderer_timeout'))
'geometry' => $geometry, ->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
'marginRatio' => (float) config('piantine.margin_ratio'), 'geometry' => $geometry,
'maxSide' => (int) config('piantine.max_side'), 'marginRatio' => (float) config('piantine.margin_ratio'),
'aspect' => (float) config('piantine.aspetto'), 'maxSide' => (int) config('piantine.max_side'),
'strokeWidth' => (float) config('piantine.tratto'), 'aspect' => (float) config('piantine.aspetto'),
'strokeOpacity' => (float) config('piantine.tratto_opacita'), 'strokeWidth' => (float) config('piantine.tratto'),
'format' => $formato, 'strokeOpacity' => (float) config('piantine.tratto_opacita'),
'quality' => (int) config('piantine.qualita'), 'format' => $formato,
]); 'quality' => (int) config('piantine.qualita'),
]);
} catch (ConnectionException $e) {
// Container fermo, nome non risolto o tempo scaduto: il messaggio
// di cURL in inglese non dice niente a chi sta pubblicando.
throw new RuntimeException('Il servizio che disegna le mappe non risponde (container map-renderer). Riprova fra qualche minuto; se continua, va controllato il server.', 0, $e);
}
if (! $response->successful()) { if (! $response->successful()) {
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status()); $dettaglio = $response->json('error') ?? ('HTTP ' . $response->status());
@@ -47,15 +72,28 @@ class PiantinaRenderer
throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.'); throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.');
} }
// Il vecchio file puo' avere un'estensione diversa da quella nuova (si return [$immagine, $formato === 'png' ? 'png' : 'jpg'];
// e' passati da PNG a JPEG): senza questa rimozione resterebbe sul }
// volume per sempre, senza che niente lo referenzi piu'.
if ($mappa->image_path && $mappa->image_path !== $this->percorso($mappa, $formato)) { /**
Storage::disk('public')->delete($mappa->image_path); * Salva l'immagine come quella della piantina, rimuovendo la precedente.
*
* Il nome cambia a ogni rendering: nginx e Cloudflare tengono in cache i
* file di /storage per giorni, e con un nome fisso il link mostrava
* l'immagine vecchia anche dopo averla rigenerata.
*/
public function archivia(TerritorioMappa $mappa, string $immagine, string $estensione): string
{
$disco = Storage::disk('public');
$path = 'territori-mappe/piantina-' . $mappa->id . '-' . now()->format('YmdHis') . '.' . $estensione;
if (! $disco->put($path, $immagine)) {
throw new RuntimeException('Non è stato possibile salvare l\'immagine della piantina sul disco.');
} }
$path = $this->percorso($mappa, $formato); if ($mappa->image_path && $mappa->image_path !== $path) {
Storage::disk('public')->put($path, $immagine); $disco->delete($mappa->image_path);
}
$mappa->update([ $mappa->update([
'image_path' => $path, 'image_path' => $path,
@@ -65,11 +103,6 @@ class PiantinaRenderer
return $path; return $path;
} }
private function percorso(TerritorioMappa $mappa, string $formato): string
{
return 'territori-mappe/piantina-' . $mappa->id . '.' . ($formato === 'png' ? 'png' : 'jpg');
}
/** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */ /** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */
private function immagineValida(string $dati, string $formato): bool private function immagineValida(string $dati, string $formato): bool
{ {

View File

@@ -12,10 +12,10 @@ use RuntimeException;
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati * attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
* restano al loro posto. * restano al loro posto.
* *
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo * Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non * impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal * ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la
* KML originale — che quindi conviene conservare fuori di qui. * contiene, e ripristinandolo l'area in uso resta com'e'.
*/ */
class PiantineArea class PiantineArea
{ {

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Services\Piantine;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
/**
* Le tabelle della sotto-app delle piantine dentro il backup completo:
* i disegni (`territorio_mappe`) e lo storico dei PDF sostituiti
* (`territorio_pdf_versioni`).
*
* Non passano dall'XML del gestionale di proposito: l'export XML e' il formato
* di scambio dei dati gestionali, e la sotto-app deve restare rimovibile senza
* toccarlo. Viaggiano in `piantine.json` accanto a `data.xml`; i file che
* citano (immagini in territori-mappe/, PDF in territori-pdf-versioni/) stanno
* sul disco `public` e sono gia' nel pacchetto con tutti gli altri.
*
* Il collegamento al territorio si salva come *numero*, non solo come id:
* l'import XML ricrea i territori con id nuovi (autoincremento), quindi l'id
* del backup dopo il ripristino punterebbe al territorio sbagliato o a nessuno.
* Il numero e' quello che l'import usa per riconoscere i territori, ed e'
* unico.
*/
class PiantineBackup
{
public const TABELLA_MAPPE = 'territorio_mappe';
public const TABELLA_VERSIONI = 'territorio_pdf_versioni';
/**
* Le righe delle due tabelle cosi' come sono nel database, piu' il numero
* del territorio collegato.
*
* Query builder e non Eloquent: si vuole la riga grezza, timestamp
* compresi, da reinserire identica — non i valori passati per i cast.
*
* @return array<string, list<array<string, mixed>>>
*/
public function esporta(): array
{
return [
self::TABELLA_MAPPE => $this->righe(self::TABELLA_MAPPE),
self::TABELLA_VERSIONI => $this->righe(self::TABELLA_VERSIONI),
];
}
/**
* Controlla la struttura di piantine.json prima che il ripristino tocchi
* qualunque cosa. Null e' ammesso: e' l'archivio di formato 1, che le
* piantine non le conteneva.
*/
public function valida(?array $dati): void
{
if ($dati === null) {
return;
}
foreach ([self::TABELLA_MAPPE, self::TABELLA_VERSIONI] as $tabella) {
if (! isset($dati[$tabella]) || ! is_array($dati[$tabella]) || ! array_is_list($dati[$tabella])) {
throw new RuntimeException('Archivio non valido: in piantine.json manca l\'elenco "' . $tabella . '".');
}
foreach ($dati[$tabella] as $posizione => $riga) {
if (! is_array($riga) || ! isset($riga['id']) || ! is_numeric($riga['id'])) {
throw new RuntimeException(sprintf(
'Archivio non valido: la riga %d di "%s" in piantine.json non e\' leggibile.',
$posizione + 1,
$tabella
));
}
}
}
}
/**
* Sostituisce il contenuto delle due tabelle con quello del backup.
*
* Va chiamato dentro la transazione del ripristino e *dopo* l'import XML:
* servono i territori gia' ricreati per ritrovare i collegamenti. DELETE e
* non TRUNCATE: su MariaDB TRUNCATE e' DDL e fa commit implicito, cioe'
* chiuderebbe a meta' la transazione che deve poter annullare tutto.
*
* Con $dati null (archivio di formato 1) le tabelle si svuotano soltanto.
* Tenere le righe dell'installazione corrente vorrebbe dire piantine che
* citano immagini del disco appena sostituito — file che non ci sono piu'
* — accanto ai dati di un'altra epoca: proprio lo stato misto che il
* ripristino deve evitare. Lo storico, del resto, lo svuotava gia' la
* cascata sulla cancellazione dei territori.
*
* @return array{mappe: int, versioni: int, issues: list<array{entity: string, legacy_id: string, reason: string}>}
*/
public function ripristina(?array $dati): array
{
DB::table(self::TABELLA_VERSIONI)->delete();
DB::table(self::TABELLA_MAPPE)->delete();
$esito = ['mappe' => 0, 'versioni' => 0, 'issues' => []];
if ($dati === null) {
return $esito;
}
// withTrashed implicito: il query builder non conosce i soft delete, e
// una piantina collegata a un territorio nel cestino deve restarlo.
$territori = DB::table('territori')->pluck('id', 'numero')
->mapWithKeys(fn ($id, $numero) => [trim((string) $numero) => (int) $id])
->all();
$colonneMappe = array_flip(Schema::getColumnListing(self::TABELLA_MAPPE));
$collegati = [];
foreach ($dati[self::TABELLA_MAPPE] as $riga) {
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
$territorioId = null;
if (($riga['territorio_id'] ?? null) !== null) {
$territorioId = $territori[$numero] ?? null;
// territorio_id e' unico: due piantine sullo stesso numero
// (possibile solo se il backup aveva territori con numero
// ripetuto, che l'import scarta) non possono restare collegate
// entrambe.
if ($territorioId !== null && isset($collegati[$territorioId])) {
$territorioId = null;
}
if ($territorioId === null) {
// Il disegno non si butta: resta scollegato, con il numero
// come etichetta per riconoscerlo.
if (trim((string) ($riga['nome'] ?? '')) === '' && $numero !== '') {
$riga['nome'] = 'Terr. ' . $numero;
}
$esito['issues'][] = $this->problema('piantina', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: piantina ripristinata scollegata');
} else {
$collegati[$territorioId] = true;
}
}
$riga['territorio_id'] = $territorioId;
DB::table(self::TABELLA_MAPPE)->insert(array_intersect_key($riga, $colonneMappe));
$esito['mappe']++;
}
$colonneVersioni = array_flip(Schema::getColumnListing(self::TABELLA_VERSIONI));
foreach ($dati[self::TABELLA_VERSIONI] as $riga) {
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
$territorioId = $territori[$numero] ?? null;
// Una versione senza territorio non e' ripristinabile da nessuna
// schermata (e la colonna non ammette NULL): si scarta, e lo si dice.
if ($territorioId === null) {
$esito['issues'][] = $this->problema('versione PDF', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: versione non ripristinata');
continue;
}
$riga['territorio_id'] = $territorioId;
DB::table(self::TABELLA_VERSIONI)->insert(array_intersect_key($riga, $colonneVersioni));
$esito['versioni']++;
}
return $esito;
}
private function righe(string $tabella): array
{
return DB::table($tabella)
->leftJoin('territori', 'territori.id', '=', $tabella . '.territorio_id')
->orderBy($tabella . '.id')
->get([$tabella . '.*', 'territori.numero as territorio_numero'])
->map(fn (object $riga) => (array) $riga)
->all();
}
/** Stessa forma delle segnalazioni dell'import XML, per mostrarle insieme. */
private function problema(string $entita, array $riga, string $motivo): array
{
return ['entity' => $entita, 'legacy_id' => (string) ($riga['id'] ?? ''), 'reason' => $motivo];
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Services\Piantine;
use App\Jobs\PreparaAnteprimaPiantina;
use App\Models\TerritorioMappa;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Pubblicazione di una piantina: dalla bozza al PDF ufficiale del territorio.
*
* Tre passi, perché il PDF pubblicato viene stampato e consegnato e va visto
* prima di sostituire quello in vigore:
*
* 1. avvia() — mette in coda la preparazione dell'anteprima. In coda e
* non nella richiesta: il primo rendering di un territorio
* scarica centinaia di tessere e può superare i 100 secondi
* dopo i quali Cloudflare chiude la connessione.
* 2. prepara() — (dal job) genera immagine e PDF in una cartella privata,
* senza toccare né la piantina né il territorio.
* 3. conferma() — rende ufficiali i file dell'anteprima. Se nel frattempo la
* piantina o i dati stampati sono cambiati, rifiuta: si
* pubblicherebbe qualcosa di diverso da quello che si è visto.
*
* Lo stato di ogni anteprima vive in cache sotto un token casuale; i file
* stanno sul disco locale, mai su quello pubblico servito da nginx.
*/
class PubblicazionePiantina
{
public const IN_CORSO = 'in_corso';
public const PRONTA = 'pronta';
public const ERRORE = 'errore';
/** Quanto resta valida un'anteprima non confermata. */
private const DURATA_SECONDI = 7200;
private const CARTELLA = 'piantine-anteprime';
public function __construct(
private PiantinaRenderer $renderer,
private PiantinaPdfService $pdf,
) {
}
/** Mette in coda l'anteprima e ne restituisce il token. */
public function avvia(TerritorioMappa $mappa): string
{
$mappa->loadMissing('territorio.zona');
if ($motivo = $mappa->motivoNonPubblicabile()) {
throw new RuntimeException($motivo);
}
$this->pulisciVecchie();
$token = Str::random(40);
$this->scrivi($token, ['stato' => self::IN_CORSO, 'mappa_id' => $mappa->id]);
PreparaAnteprimaPiantina::dispatch($token);
return $token;
}
/** Lo stato dell'anteprima, o null se il token non esiste o è scaduto. */
public function stato(string $token): ?array
{
return Cache::get($this->chiave($token));
}
/** Il lavoro vero, eseguito dal job. */
public function prepara(string $token): void
{
$stato = $this->stato($token);
if (! $stato || $stato['stato'] !== self::IN_CORSO) {
return;
}
try {
$mappa = TerritorioMappa::with('territorio.zona')->find($stato['mappa_id']);
if (! $mappa) {
throw new RuntimeException('La piantina non esiste più.');
}
if ($motivo = $mappa->motivoNonPubblicabile()) {
throw new RuntimeException($motivo);
}
// L'impronta si prende adesso, sullo stesso stato da cui escono
// immagine e PDF: è quello che la conferma dovrà ritrovare.
$impronta = $mappa->impronta();
[$immagine, $estensione] = $this->renderer->immagine($mappa->geometria());
$disco = Storage::disk('local');
$percorsoImmagine = self::CARTELLA . "/{$token}.{$estensione}";
$percorsoPdf = self::CARTELLA . "/{$token}.pdf";
if (! $disco->put($percorsoImmagine, $immagine)) {
throw new RuntimeException('Non è stato possibile salvare l\'immagine dell\'anteprima.');
}
$binario = $this->pdf->genera($mappa->territorio, $mappa, $disco->path($percorsoImmagine));
if (! $disco->put($percorsoPdf, $binario)) {
throw new RuntimeException('Non è stato possibile salvare il PDF dell\'anteprima.');
}
$this->scrivi($token, $stato + [
'impronta' => $impronta,
'immagine' => $percorsoImmagine,
'estensione' => $estensione,
'pdf' => $percorsoPdf,
], self::PRONTA);
} catch (\Throwable $e) {
report($e);
$this->segnaErrore($token, $e->getMessage());
}
}
public function segnaErrore(string $token, string $messaggio): void
{
$stato = $this->stato($token);
if (! $stato) {
return;
}
$this->scrivi($token, $stato + ['messaggio' => $messaggio], self::ERRORE);
}
/** Percorso assoluto del PDF di un'anteprima pronta, o null. */
public function pdfAnteprima(string $token): ?string
{
$stato = $this->stato($token);
if (($stato['stato'] ?? null) !== self::PRONTA || ! Storage::disk('local')->exists($stato['pdf'])) {
return null;
}
return Storage::disk('local')->path($stato['pdf']);
}
/**
* Rende ufficiale l'anteprima: PDF del territorio sostituito (il
* precedente va nello storico), immagine della piantina aggiornata, bozza
* segnata come pubblicata.
*/
public function conferma(string $token): TerritorioMappa
{
$stato = $this->stato($token);
if (($stato['stato'] ?? null) !== self::PRONTA) {
throw new RuntimeException('L\'anteprima non è più disponibile: generala di nuovo.');
}
$mappa = TerritorioMappa::with('territorio.zona')->find($stato['mappa_id']);
if (! $mappa || ! $mappa->collegata()) {
throw new RuntimeException('La piantina non è più collegata a un territorio: non si può pubblicare.');
}
if ($mappa->impronta() !== $stato['impronta']) {
throw new RuntimeException('La piantina o i dati del territorio sono cambiati dopo l\'anteprima: rigenerala per pubblicare la versione attuale.');
}
$disco = Storage::disk('local');
if (! $disco->exists($stato['pdf']) || ! $disco->exists($stato['immagine'])) {
throw new RuntimeException('I file dell\'anteprima non ci sono più: generala di nuovo.');
}
$this->pdf->sostituisciConPdf($mappa->territorio, $disco->get($stato['pdf']), 'piantine');
$this->renderer->archivia($mappa, $disco->get($stato['immagine']), $stato['estensione']);
$mappa->update([
'geojson_pubblicato' => $mappa->geojson,
'impronta_pubblicata' => $stato['impronta'],
// Il PDF appena messo in vigore: finché il territorio usa questo
// file, la piantina è "Pubblicata" (vedi pdfInUsoDallaPiantina()).
'pdf_pubblicato' => $mappa->territorio->fresh()->pdf_path,
'pubblicata_at' => now(),
]);
activity()
->causedBy(auth()->user())
->performedOn($mappa->territorio)
->log('piantina_pubblicata');
$this->annulla($token);
return $mappa->fresh('territorio');
}
/** Butta l'anteprima: file e stato. */
public function annulla(string $token): void
{
$stato = $this->stato($token);
if ($stato) {
Storage::disk('local')->delete(array_filter([$stato['immagine'] ?? null, $stato['pdf'] ?? null]));
}
Cache::forget($this->chiave($token));
}
private function scrivi(string $token, array $dati, ?string $nuovoStato = null): void
{
if ($nuovoStato !== null) {
$dati['stato'] = $nuovoStato;
}
Cache::put($this->chiave($token), $dati, self::DURATA_SECONDI);
}
private function chiave(string $token): string
{
return 'piantine:anteprima:' . $token;
}
/**
* Le anteprime aperte e mai confermate né annullate (finestra chiusa)
* lascerebbero file per sempre: chi ne avvia una nuova pulisce quelle
* scadute.
*/
private function pulisciVecchie(): void
{
$disco = Storage::disk('local');
$limite = now()->subSeconds(self::DURATA_SECONDI)->getTimestamp();
foreach ($disco->files(self::CARTELLA) as $file) {
if ($disco->lastModified($file) < $limite) {
$disco->delete($file);
}
}
}
}

View File

@@ -0,0 +1,329 @@
<?php
namespace App\Services\Registro;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Territorio;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
use setasign\Fpdi\Fpdi;
/**
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del territorio")
* compilato per un anno di servizio.
*
* Il PDF è il modulo originale (resources/pdf/S-13_I.pdf) importato com'è, con
* i dati scritti sopra alle coordinate della sua griglia: il foglio stampato è
* quello ufficiale, non una sua ricostruzione. La matrice è salvata in PDF 1.4
* senza object stream, l'unico formato che il parser gratuito di FPDI legge;
* se il modulo viene aggiornato va riconvertito allo stesso modo (qpdf
* --object-streams=disable --force-version=1.4, o pikepdf).
*
* Le regole di compilazione, come sul cartaceo:
*
* - Una riga per ogni territorio attivo, anche se nell'anno non è mai uscito:
* il registro serve proprio a vedere quali territori restano fermi.
*
* - Quattro gruppi "Assegnato a" per riga. Alla quinta assegnazione la riga
* si cancella: la data di rientro della quarta passa nella colonna
* "Restituito l'ultima volta il" e la quinta va nel primo gruppo. Sul foglio
* c'è quindi solo il ciclo in corso; lo storico completo resta nel database.
*
* - Nel primo ciclo dell'anno il riporto è l'ultima restituzione avvenuta
* prima dell'inizio dell'anno, come chiede la nota a piè del modulo.
*
* - Un'assegnazione a cavallo d'anno (uscita prima dell'inizio, rientrata
* dopo o ancora fuori) compare su entrambi i fogli: su quello vecchio senza
* data di rientro, perché a fine anno il territorio era ancora fuori; su
* quello nuovo nel primo gruppo, con la data di assegnazione originale e
* quella di rientro.
*/
class ModuloS13
{
/** Blocchi-territorio per foglio, come sul modulo. */
public const RIGHE_PER_PAGINA = 20;
/** Gruppi "Assegnato a" per riga. */
public const ASSEGNAZIONI_PER_RIGA = 4;
// Geometria della griglia in mm, misurata sulla matrice. Le y sono il
// bordo superiore delle linee: quelle spesse separano i territori, quella
// sottile divide la riga dei nomi da quella delle date.
/** Linea spessa sotto l'intestazione, cioè sopra il primo territorio. */
private const Y_PRIMA_RIGA = 49.28;
/** Distanza fra due territori. */
private const PASSO_RIGA = 11.042;
/** Dalla linea spessa alla sottile che separa nomi e date. */
private const Y_META_RIGA = 5.68;
/** Spessore delle linee spesse (1,56pt) e di quelle sottili (0,48pt). */
private const LINEA_SPESSA = 0.55;
private const LINEA_SOTTILE = 0.17;
/** Colonne fisse: [x iniziale, x finale] al netto dei bordi. */
private const COL_NUMERO = [12.14, 24.06];
private const COL_RIPORTO = [24.23, 46.11];
/** Inizio di ciascun gruppo "Assegnato a", divisione interna e fine. */
private const GRUPPI = [
[46.62, 65.41, 84.03],
[84.54, 103.05, 121.74],
[122.25, 140.69, 159.38],
[159.89, 178.40, 196.74],
];
/** Riga "Anno di servizio: ____": estremi della linea e sua quota. */
private const ANNO = [49.96, 72.74, 34.38];
/** Corpo massimo e minimo dei nomi: sotto il minimo si tronca. */
private const CORPO_NOME_MAX = 8.0;
private const CORPO_NOME_MIN = 5.0;
/** Il PDF compilato, come stringa binaria. */
public function genera(AnnoTeocratico $anno): string
{
$pdf = new Fpdi();
$pdf->SetAutoPageBreak(false);
$pdf->SetMargins(0, 0, 0);
$pdf->setSourceFile(resource_path('pdf/S-13_I.pdf'));
$modulo = $pdf->importPage(1);
$pdf->SetTextColor(0, 0, 0);
foreach ($this->pagine($this->righe($anno)) as $righe) {
$pdf->AddPage('P', 'A4');
$pdf->useTemplate($modulo);
[$x0, $x1, $y] = self::ANNO;
$pdf->SetFont('Helvetica', '', 10);
$pdf->SetXY($x0, $y - 4.6);
$pdf->Cell($x1 - $x0, 4.4, $this->latin($anno->label), 0, 0, 'C');
foreach ($righe as $indice => $riga) {
$this->scriviRiga($pdf, $indice, $riga);
}
}
return $pdf->Output('S');
}
/**
* Righe del modulo: una per territorio attivo, in ordine naturale di
* numero ("1", "1 A", "2", "10").
*
* Ogni riga: ['numero' => string, 'riporto' => string, 'assegnazioni' =>
* quattro celle ['nome', 'assegnato_il', 'restituito_il'], le libere vuote].
*
* @return Collection<int, array>
*/
public function righe(AnnoTeocratico $anno): Collection
{
$inizio = $anno->start_date->toDateString();
$fine = $anno->end_date->copy()->endOfDay();
$territori = Territorio::where('attivo', true)->get(['id', 'numero']);
// Le assegnazioni del foglio, scelte per data e non per
// anno_teocratico_id (che una correzione a mano o un import possono
// lasciare incoerente): quelle uscite nell'anno, più quelle uscite
// prima e ancora fuori al suo inizio. Confronti con stringhe Y-m-d e
// solo con < e >=: valgono sia sulle colonne DATE di MariaDB sia sulle
// "Y-m-d H:i:s" di SQLite, e usano l'indice (whereDate non lo userebbe).
$assegnazioni = Assegnazione::with('proclamatore')
->whereIn('territorio_id', $territori->pluck('id'))
->where('assigned_at', '<', $anno->end_date->copy()->addDay()->toDateString())
->where(fn ($q) => $q
->where('assigned_at', '>=', $inizio)
->orWhereNull('returned_at')
->orWhere('returned_at', '>=', $inizio))
->orderBy('assigned_at')
->orderBy('id')
->get()
->groupBy('territorio_id');
// Riporto del primo ciclo: l'ultima restituzione prima dell'anno.
$rientriPrecedenti = Assegnazione::query()
->whereIn('territorio_id', $territori->pluck('id'))
->where('returned_at', '<', $inizio)
->groupBy('territorio_id')
->selectRaw('territorio_id, MAX(returned_at) as ultimo')
->pluck('ultimo', 'territorio_id');
return $territori
->map(function (Territorio $territorio) use ($assegnazioni, $rientriPrecedenti, $fine) {
$gruppo = ($assegnazioni->get($territorio->id) ?? collect())->values();
$inizioCiclo = $gruppo->isEmpty()
? 0
: intdiv($gruppo->count() - 1, self::ASSEGNAZIONI_PER_RIGA) * self::ASSEGNAZIONI_PER_RIGA;
$riporto = $inizioCiclo === 0
? $this->data($rientriPrecedenti->get($territorio->id))
: $this->rientroEntro($gruppo->get($inizioCiclo - 1), $fine);
$celle = $gruppo
->slice($inizioCiclo, self::ASSEGNAZIONI_PER_RIGA)
->map(fn (Assegnazione $a) => [
'nome' => $this->nomeBreve($a->proclamatore),
'assegnato_il' => $this->data($a->assigned_at),
'restituito_il' => $this->rientroEntro($a, $fine),
])
->values()
->all();
return [
'numero' => (string) $territorio->numero,
'riporto' => $riporto,
'assegnazioni' => $this->completaCelle($celle),
];
})
->sort(fn (array $a, array $b) => strnatcasecmp($a['numero'], $b['numero']))
->values();
}
/**
* Fogli da venti righe, l'ultimo completato con righe vuote: sul modulo le
* righe libere restano disponibili per la compilazione a mano. Senza
* territori esce comunque un foglio, in bianco.
*
* @return Collection<int, Collection<int, array>>
*/
public function pagine(Collection $righe): Collection
{
$vuota = ['numero' => '', 'riporto' => '', 'assegnazioni' => $this->completaCelle([])];
$pagine = $righe->isEmpty()
? collect([collect()])
: $righe->chunk(self::RIGHE_PER_PAGINA)->map->values()->values();
return $pagine->map(fn (Collection $pagina) => collect(
array_pad($pagina->all(), self::RIGHE_PER_PAGINA, $vuota)
));
}
private function scriviRiga(Fpdi $pdf, int $indice, array $riga): void
{
$alto = self::Y_PRIMA_RIGA + $indice * self::PASSO_RIGA + self::LINEA_SPESSA;
$meta = self::Y_PRIMA_RIGA + $indice * self::PASSO_RIGA + self::Y_META_RIGA;
$basso = self::Y_PRIMA_RIGA + ($indice + 1) * self::PASSO_RIGA;
// Numero e riporto occupano il blocco intero, nomi e date mezzo.
$pdf->SetFont('Helvetica', 'B', 10);
$this->cella($pdf, self::COL_NUMERO, $alto, $basso, $riga['numero']);
$pdf->SetFont('Helvetica', '', 8);
$this->cella($pdf, self::COL_RIPORTO, $alto, $basso, $riga['riporto']);
foreach ($riga['assegnazioni'] as $g => $a) {
[$inizio, $divisione, $fine] = self::GRUPPI[$g];
$this->scriviNome($pdf, [$inizio, $fine], $alto, $meta, $a['nome']);
$pdf->SetFont('Helvetica', '', 7.5);
$this->cella($pdf, [$inizio, $divisione], $meta + self::LINEA_SOTTILE, $basso, $a['assegnato_il']);
$this->cella($pdf, [$divisione, $fine], $meta + self::LINEA_SOTTILE, $basso, $a['restituito_il']);
}
}
/**
* I nomi stanno su una riga sola: il corpo scende finché il nome entra
* nella cella, e sotto il minimo leggibile si tronca con i puntini, che
* rendono visibile l'accorciamento invece di sbordare sulla griglia.
*/
private function scriviNome(Fpdi $pdf, array $colonna, float $alto, float $basso, string $nome): void
{
if ($nome === '') {
return;
}
$testo = $this->latin($nome);
$spazio = $colonna[1] - $colonna[0] - 1.2;
$pdf->SetFont('Helvetica', '', self::CORPO_NOME_MAX);
$larghezza = $pdf->GetStringWidth($testo);
if ($larghezza > $spazio) {
$corpo = max(self::CORPO_NOME_MIN, floor(self::CORPO_NOME_MAX * $spazio / $larghezza * 10) / 10);
$pdf->SetFont('Helvetica', '', $corpo);
while ($pdf->GetStringWidth($testo) > $spazio && strlen($testo) > 1) {
$testo = rtrim(substr(rtrim($testo, "\x85"), 0, -1)) . "\x85";
}
}
$this->cella($pdf, $colonna, $alto, $basso, $testo, giaConvertito: true);
}
/** Testo centrato in un rettangolo della griglia. */
private function cella(Fpdi $pdf, array $colonna, float $alto, float $basso, string $testo, bool $giaConvertito = false): void
{
if ($testo === '') {
return;
}
$pdf->SetXY($colonna[0], $alto);
$pdf->Cell(
$colonna[1] - $colonna[0],
$basso - $alto,
$giaConvertito ? $testo : $this->latin($testo),
0,
0,
'C'
);
}
/**
* Data di rientro così come la si leggeva alla fine dell'anno: se il
* territorio è rientrato dopo, sul foglio di quell'anno risulta ancora
* fuori.
*/
private function rientroEntro(?Assegnazione $assegnazione, CarbonInterface $fine): string
{
$rientro = $assegnazione?->returned_at;
return $rientro && $rientro->lte($fine) ? $this->data($rientro) : '';
}
/**
* Cognome e iniziale del nome ("Rossi M."): la cella è stretta, e col nome
* per intero i nomi lunghi scendevano a un corpo che su carta non si legge.
*/
private function nomeBreve(?Proclamatore $proclamatore): string
{
if (! $proclamatore) {
return '';
}
$nome = trim((string) $proclamatore->nome);
$iniziale = $nome !== '' ? mb_strtoupper(mb_substr($nome, 0, 1)) . '.' : '';
return trim(trim((string) $proclamatore->cognome) . ' ' . $iniziale);
}
private function data(mixed $valore): string
{
if (! $valore) {
return '';
}
return ($valore instanceof CarbonInterface ? $valore : \Illuminate\Support\Carbon::parse($valore))
->format('d/m/Y');
}
private function completaCelle(array $celle): array
{
return array_pad($celle, self::ASSEGNAZIONI_PER_RIGA, ['nome' => '', 'assegnato_il' => '', 'restituito_il' => '']);
}
/** FPDF ragiona in windows-1252: gli accenti UTF-8 vanno convertiti. */
private function latin(string $testo): string
{
$convertito = @iconv('UTF-8', 'windows-1252//TRANSLIT', $testo);
return $convertito !== false ? $convertito : $testo;
}
}

View File

@@ -19,15 +19,106 @@ class TerritorioPdfImportDispatcher
public function __construct( public function __construct(
protected TerritorioPdfImportState $stateService, protected TerritorioPdfImportState $stateService,
protected BackupOperationState $postoUnico,
) { ) {
} }
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array /**
* Prende il posto unico per questo import, o solleva spiegando chi lo occupa.
*
* L'import PDF sostituisce file e righe dei territori: in parallelo a un
* ripristino scriverebbe su tabelle che l'altro sta riscrivendo, e durante
* un backup l'archivio fotograferebbe meta' PDF vecchi e meta' nuovi (o
* fallirebbe su un file cancellato fra l'elenco e la compressione). Il posto
* si libera nel job, a fine lavoro o in failed().
*/
public function occupaPosto(string $importId): void
{
$inCorso = $this->postoUnico->occupa($importId, BackupOperationState::TIPO_IMPORT_PDF, 'In coda');
if ($inCorso !== null) {
throw new RuntimeException($this->postoUnico->messaggioOccupato($inCorso));
}
}
/** Libera il posto preso da un import che non e' arrivato alla coda. */
public function liberaPosto(string $importId, string $motivo): void
{
$this->postoUnico->markFailed($importId, $motivo);
}
/**
* Vero per i file che non vanno nemmeno considerati: quelli che non sono
* PDF e quelli di sistema o nascosti.
*
* Una cartella vera non contiene solo PDF: Windows ci lascia Thumbs.db e
* desktop.ini, macOS .DS_Store e — dentro gli ZIP — una copia "._nome.pdf"
* di ogni file sotto __MACOSX/. Prima uno solo di questi faceva fallire la
* validazione dell'intero caricamento; peggio, "._1.pdf" ha l'estensione
* giusta e sarebbe finito come mappa del territorio 1.
*/
public static function daIgnorare(string $percorso): bool
{
$segmenti = array_values(array_filter(preg_split('#[/\\\\]+#', $percorso) ?: [], fn ($s) => $s !== ''));
if ($segmenti === []) {
return true;
}
foreach ($segmenti as $segmento) {
if (str_starts_with($segmento, '.') || strcasecmp($segmento, '__MACOSX') === 0) {
return true;
}
}
// "~$" e' il prefisso dei file temporanei di Office.
$nome = end($segmenti);
return str_starts_with($nome, '~$')
|| strtolower(pathinfo($nome, PATHINFO_EXTENSION)) !== 'pdf';
}
/**
* Mette in coda l'import. Il posto unico deve essere gia' stato preso con
* occupaPosto(): se l'invio alla coda fallisce lo si libera qui.
*
* @param array<int, string> $ignorati nomi dei file scartati da daIgnorare(), solo per il log
* @param array<int, array{file: string, message: string}> $scartati PDF rifiutati dalla validazione
*/
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog, array $ignorati = [], array $scartati = []): array
{ {
$state = $this->stateService->initialize($importId, count($storedFiles)); $state = $this->stateService->initialize($importId, count($storedFiles));
$this->stateService->appendLog($importId, $initialLog); $this->stateService->appendLog($importId, $initialLog);
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId); if ($ignorati !== []) {
$elenco = array_slice($ignorati, 0, 10);
$altri = count($ignorati) - count($elenco);
$this->stateService->appendLog(
$importId,
'Ignorati ' . count($ignorati) . ' file che non sono PDF da importare (file di sistema, nascosti o di altro tipo): '
. implode(', ', $elenco) . ($altri > 0 ? ' e altri ' . $altri : '') . '.'
);
}
foreach ($scartati as $scartato) {
$this->stateService->appendLog($importId, '[SKIP] ' . $scartato['file'] . ' - ' . $scartato['message']);
$this->stateService->addIssue($importId, [
'file' => $scartato['file'],
'type' => 'invalid-file',
'message' => $scartato['message'],
]);
}
try {
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
} catch (\Throwable $exception) {
// Il job non partira' mai, quindi non sara' lui a liberare il posto.
$this->stateService->markFailed($importId);
$this->liberaPosto($importId, 'Import PDF non messo in coda: ' . $exception->getMessage());
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw $exception;
}
return $this->stateService->get($importId) ?? $state; return $this->stateService->get($importId) ?? $state;
} }
@@ -35,6 +126,27 @@ class TerritorioPdfImportDispatcher
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
{ {
$importId = (string) Str::uuid(); $importId = (string) Str::uuid();
// Il posto si prende prima di salvare ed estrarre: e' la parte lenta, e
// farla per poi sentirsi rispondere "c'e' un ripristino in corso" sarebbe
// lavoro buttato.
$this->occupaPosto($importId);
try {
return $this->estraiEAccoda($zipFile, $importId, $actorId);
} catch (\Throwable $exception) {
// Se il job e' partito il posto e' suo e lo libera lui; altrimenti
// resterebbe occupato da un import che non esiste.
if ($this->stateService->get($importId) === null) {
$this->liberaPosto($importId, 'Import PDF non avviato: ' . $exception->getMessage());
}
throw $exception;
}
}
private function estraiEAccoda(UploadedFile $zipFile, string $importId, ?int $actorId): string
{
$zipStoredPath = $zipFile->storeAs( $zipStoredPath = $zipFile->storeAs(
'bulk-territori-imports/' . $importId, 'bulk-territori-imports/' . $importId,
'archivio-' . $importId . '.zip', 'archivio-' . $importId . '.zip',
@@ -45,10 +157,13 @@ class TerritorioPdfImportDispatcher
$zip = new ZipArchive(); $zip = new ZipArchive();
if ($zip->open($zipAbsolutePath) !== true) { if ($zip->open($zipAbsolutePath) !== true) {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw new RuntimeException('Impossibile aprire il file ZIP.'); throw new RuntimeException('Impossibile aprire il file ZIP.');
} }
$storedFiles = []; $storedFiles = [];
$ignorati = [];
$entryIndex = 0; $entryIndex = 0;
try { try {
@@ -59,7 +174,8 @@ class TerritorioPdfImportDispatcher
continue; continue;
} }
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') { if (self::daIgnorare($entryName)) {
$ignorati[] = basename($entryName);
continue; continue;
} }
@@ -111,6 +227,8 @@ class TerritorioPdfImportDispatcher
} }
if ($storedFiles === []) { if ($storedFiles === []) {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
throw new RuntimeException('Lo ZIP non contiene file PDF validi.'); throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
} }
@@ -118,7 +236,8 @@ class TerritorioPdfImportDispatcher
$importId, $importId,
$storedFiles, $storedFiles,
$actorId, $actorId,
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.' 'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.',
$ignorati
); );
return $importId; return $importId;

View File

@@ -12,6 +12,13 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
// '*' is deliberate. The container is only reachable through Traefik
// (no published port in Dokploy), and nginx's real_ip module has
// already rewritten REMOTE_ADDR to the client's address. Restricting
// this to private ranges would therefore stop Laravel from honouring
// the X-Forwarded-Proto that nginx sets server-side, and every signed
// URL and secure cookie would break behind HTTPS.
//
// X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client // X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client
// set an arbitrary Host and have route()/url() emit links pointing at // set an arbitrary Host and have route()/url() emit links pointing at
// an attacker's domain — including the public assignment PDF links, // an attacker's domain — including the public assignment PDF links,
@@ -39,7 +46,16 @@ return Application::configure(basePath: dirname(__DIR__))
// Dopo il ripristino di un backup ogni account resta bloccato finche' // Dopo il ripristino di un backup ogni account resta bloccato finche'
// non imposta una password nuova. In coda al gruppo web, quindi la // non imposta una password nuova. In coda al gruppo web, quindi la
// sessione e' gia' avviata e l'utente risolto quando gira. // sessione e' gia' avviata e l'utente risolto quando gira.
//
// AuthenticateSession prima: ogni sessione ricorda l'impronta della
// password, e quando la password cambia (profilo, cambio obbligatorio,
// l'amministratore che la reimposta) le sessioni aperte altrove si
// chiudono alla richiesta successiva. Quella che ha fatto il cambio
// resta aperta: il middleware ci registra l'impronta nuova. Senza,
// session()->regenerate() cambiava solo l'id della sessione corrente e
// le altre restavano valide su Redis.
$middleware->web(append: [ $middleware->web(append: [
\Illuminate\Session\Middleware\AuthenticateSession::class,
\App\Http\Middleware\RequirePasswordChange::class, \App\Http\Middleware\RequirePasswordChange::class,
]); ]);

26
config/queue.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/*
* Solo la connessione redis: il resto resta quello del framework (Laravel 11
* unisce queue.connections con i propri valori predefiniti).
*/
return [
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
// Più lungo del job più lungo (import PDF: 900 s). Con i 90 s del
// framework un backup, un ripristino o un import ancora in corso
// tornava disponibile in coda e poteva partire una seconda volta
// sugli stessi dati — per esempio dopo un riavvio del worker.
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 960),
'block_for' => null,
'after_commit' => false,
],
],
];

View File

@@ -0,0 +1,56 @@
<?php
use App\Models\TerritorioMappa;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Bozza e pubblicazione delle piantine.
*
* `geojson` diventa la bozza, su cui l'editor salva da solo a ogni modifica.
* Accanto si tiene quello che è stato pubblicato: il confine (per poter
* scartare la bozza), l'impronta del contenuto finito nel PDF (per sapere se
* da allora qualcosa è cambiato) e il momento della pubblicazione.
*
* Le piantine esistenti nascono "pubblicate" solo se il PDF in vigore del
* loro territorio è uno di quelli composti dalle piantine
* (territori-pdf/piantina-{territorio}-…): è l'unico caso in cui si sa che quel
* PDF viene da questo disegno. Tutte le altre partono come bozza.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->longText('geojson_pubblicato')->nullable()->after('geojson');
$table->string('impronta_pubblicata', 64)->nullable()->after('geojson_pubblicato');
$table->timestamp('pubblicata_at')->nullable()->after('impronta_pubblicata');
});
TerritorioMappa::with('territorio.zona')
->whereNotNull('territorio_id')
->whereNotNull('image_path')
->get()
->each(function (TerritorioMappa $mappa) {
$pdf = (string) $mappa->territorio?->pdf_path;
if (! str_starts_with($pdf, 'territori-pdf/piantina-' . $mappa->territorio_id . '-')) {
return;
}
$mappa->forceFill([
'geojson_pubblicato' => $mappa->geojson,
'impronta_pubblicata' => $mappa->impronta(),
'pubblicata_at' => $mappa->rendered_at ?? now(),
])->saveQuietly();
});
}
public function down(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->dropColumn(['geojson_pubblicato', 'impronta_pubblicata', 'pubblicata_at']);
});
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Toglie dal registro attività i nomi dei proclamatori scritti in chiaro.
*
* Assegnazioni e rientri registravano "proclamatore" => nome e cognome:
* dati che nel database sono cifrati, e che lì restavano leggibili anche
* dopo l'anonimizzazione. Al loro posto va l'id del proclamatore, preso
* dall'assegnazione a cui la voce si riferisce; il registro mostra il nome
* attuale. Se l'assegnazione non c'è più il nome si toglie e basta.
*/
return new class extends Migration
{
public function up(): void
{
$tabella = config('activitylog.table_name', 'activity_log');
DB::table($tabella)
->whereIn('description', ['assigned', 'returned'])
->where('properties', 'like', '%"proclamatore"%')
->orderBy('id')
->each(function ($voce) use ($tabella) {
$proprieta = json_decode((string) $voce->properties, true);
if (! is_array($proprieta) || ! array_key_exists('proclamatore', $proprieta)) {
return;
}
unset($proprieta['proclamatore']);
$idProclamatore = $voce->subject_id
? DB::table('assegnazioni')->where('id', $voce->subject_id)->value('proclamatore_id')
: null;
if ($idProclamatore) {
$proprieta['proclamatore_id'] = (int) $idProclamatore;
}
DB::table($tabella)->where('id', $voce->id)->update([
'properties' => json_encode($proprieta, JSON_UNESCAPED_UNICODE),
]);
});
}
public function down(): void
{
// I nomi tolti non si rimettono: era proprio il punto.
}
};

View File

@@ -0,0 +1,68 @@
<?php
use App\Models\TerritorioMappa;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* La piantina ricorda il percorso del PDF che ha pubblicato.
*
* Prima "Pubblicata" si riconosceva dall'id del territorio, sia
* nell'impronta sia nel nome del file (territori-pdf/piantina-{id}-…): il
* ripristino di un backup ricrea i territori con id nuovi, e ogni piantina
* pubblicata tornava "da pubblicare". Ora l'impronta usa il numero e lo stato
* confronta il percorso del PDF in vigore con quello registrato qui; entrambi
* attraversano il ripristino intatti.
*
* Le piantine già pubblicate restano tali solo se lo erano davvero: impronta
* corrispondente (con la formula di prima, o con la nuova se la migrazione
* precedente gira adesso su un'installazione nuova) e PDF in vigore composto
* da questa piantina.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->string('pdf_pubblicato')->nullable()->after('impronta_pubblicata');
});
TerritorioMappa::with('territorio.zona')
->whereNotNull('pubblicata_at')
->whereNotNull('territorio_id')
->get()
->each(function (TerritorioMappa $mappa) {
$territorio = $mappa->territorio;
$pdf = (string) $territorio?->pdf_path;
$improntaVecchia = hash('sha256', json_encode([
$mappa->geometria(),
$mappa->territorio_id,
$territorio ? trim((string) $territorio->numero) : null,
$territorio?->zona?->nome,
$territorio ? trim((string) $territorio->confini) : null,
]));
$improntaNuova = $mappa->impronta();
$eraPubblicata = in_array($mappa->impronta_pubblicata, [$improntaVecchia, $improntaNuova], true)
&& str_starts_with($pdf, 'territori-pdf/piantina-' . $mappa->territorio_id . '-');
if (! $eraPubblicata) {
return;
}
$mappa->forceFill([
'impronta_pubblicata' => $improntaNuova,
'pdf_pubblicato' => $pdf,
])->saveQuietly();
});
}
public function down(): void
{
Schema::table('territorio_mappe', function (Blueprint $table) {
$table->dropColumn('pdf_pubblicato');
});
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Tre cose che vanno insieme, perché la terza ha bisogno della prima.
*
* 1. Il server di posta si configura dall'applicazione (Impostazioni → Posta)
* e non più solo dal .env: servono le email di conferma degli utenti.
* La password è cifrata con APP_KEY (cast `encrypted` sul modello).
* 2. I proclamatori possono avere email e telefono, facoltativi. Cifrati come
* nome e cognome: sono dati personali, e il database non deve mostrarli
* in chiaro a chi ne legge una copia.
* 3. Gli utenti devono confermare la loro email. Decisione dell'utente
* (13/10/2026): anche gli account già esistenti, amministratore compreso.
* La pagina della posta resta raggiungibile da un amministratore non
* ancora confermato, e c'è `php artisan utenti:conferma-email` per i casi
* in cui la posta non funziona affatto.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->string('mail_host')->nullable();
$table->unsignedInteger('mail_port')->nullable();
$table->string('mail_cifratura', 10)->nullable();
$table->string('mail_username')->nullable();
$table->text('mail_password')->nullable();
$table->string('mail_from_address')->nullable();
$table->string('mail_from_name')->nullable();
});
Schema::table('proclamatori', function (Blueprint $table) {
// text: il valore cifrato è molto più lungo dell'indirizzo.
$table->text('email')->nullable();
$table->text('telefono')->nullable();
});
DB::table('users')->update(['email_verified_at' => null]);
}
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn([
'mail_host', 'mail_port', 'mail_cifratura', 'mail_username',
'mail_password', 'mail_from_address', 'mail_from_name',
]);
});
Schema::table('proclamatori', function (Blueprint $table) {
$table->dropColumn(['email', 'telefono']);
});
}
};

View File

@@ -71,6 +71,11 @@ services:
- --tries=3 - --tries=3
- --timeout=600 - --timeout=600
- --max-time=3600 - --max-time=3600
# Al riavvio (ogni deploy) il worker riceve SIGTERM e finisce il job in
# corso prima di uscire; Docker pero' di default aspetta 10 secondi e poi
# lo uccide. Un ripristino o un import interrotto cosi' lascia i dati a
# meta'. 16 minuti coprono il job piu' lungo (900 s).
stop_grace_period: 16m
# Sostituisce l'healthcheck dell'immagine, che interroga nginx: qui non c'e'. # Sostituisce l'healthcheck dell'immagine, che interroga nginx: qui non c'e'.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"] test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"]
@@ -166,9 +171,11 @@ services:
- ${REDIS_PASSWORD:?manca REDIS_PASSWORD nel .env} - ${REDIS_PASSWORD:?manca REDIS_PASSWORD nel .env}
- --maxmemory - --maxmemory
- 256mb - 256mb
# Qui vivono sessioni e cache: meglio sfrattare chiavi che esaurire memoria. # Qui vivono sessioni, cache e la coda. volatile-lru sfratta solo le
# chiavi con scadenza (sessioni, cache): con allkeys-lru, a memoria
# piena, potevano sparire anche i job in coda, senza errori.
- --maxmemory-policy - --maxmemory-policy
- allkeys-lru - volatile-lru
volumes: volumes:
- redis_data:/data - redis_data:/data
networks: networks:

View File

@@ -38,8 +38,16 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# Uploaded territory PDFs and thumbnails (public/storage -> storage/app/public). # Territory files (PDFs, archived versions, thumbnails, map images) are NOT
# Served directly by nginx rather than round-tripping through PHP. # public: anyone could download /storage/territori-pdf/territorio-12-34.pdf
# without logging in, the names are guessable, and the public cache header
# below let Cloudflare keep them for a week. Logged-in users get them from
# TerritorioFileController, publishers from the expiring assignment link.
location ^~ /storage/territori- {
return 404;
}
# What is left on the public disk (the congregation logo).
location /storage/ { location /storage/ {
expires 7d; expires 7d;
add_header Cache-Control "public"; add_header Cache-Control "public";

View File

@@ -78,6 +78,21 @@ ok "Storage directories ready."
KEY_FILE="/var/www/html/storage/app/.app_key" KEY_FILE="/var/www/html/storage/app/.app_key"
if [ -n "${APP_KEY:-}" ]; then if [ -n "${APP_KEY:-}" ]; then
# The persisted copy is the backup of the key the data was encrypted
# with. If the environment now carries a different one (a typo in
# Dokploy, a value pasted from another install), overwriting the file
# would destroy the only copy of the right key: refuse to start instead.
# A deliberate rotation keeps the old key aside and needs an explicit flag.
if [ -s "$KEY_FILE" ] && [ "$(cat "$KEY_FILE")" != "$APP_KEY" ]; then
if [ "${APP_KEY_ROTATE:-}" = "1" ]; then
BACKUP_FILE="${KEY_FILE}.prev-$(date +%Y%m%d-%H%M%S)"
cp "$KEY_FILE" "$BACKUP_FILE"
warn "APP_KEY_ROTATE=1: the previous key was saved to $BACKUP_FILE."
warn "Data encrypted with it stays unreadable until re-encrypted."
else
die "APP_KEY in the environment differs from the key persisted in $KEY_FILE. Starting would make the encrypted personal data unreadable. Restore the previous APP_KEY in the environment, or set APP_KEY_ROTATE=1 if the change is deliberate (the old key is kept aside)."
fi
fi
ok "APP_KEY provided via environment." ok "APP_KEY provided via environment."
printf '%s' "$APP_KEY" > "$KEY_FILE" printf '%s' "$APP_KEY" > "$KEY_FILE"
elif [ -s "$KEY_FILE" ]; then elif [ -s "$KEY_FILE" ]; then

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4a45a8"/><text x="16" y="21.5" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="14" font-weight="700" fill="#fff" text-anchor="middle">T2</text></svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -557,6 +557,142 @@ html { scroll-behavior: smooth; }
background: var(--tm-superficie-alt); background: var(--tm-superficie-alt);
} }
/* ─── Numero di telefono che apre WhatsApp ───────────────────────────────── */
.tm-whatsapp {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid #b7e1c6;
border-radius: 999px;
background: #eaf7ef;
font-size: 13px;
font-weight: 600;
color: #17703f;
text-decoration: none;
white-space: nowrap;
}
.tm-whatsapp:hover { background: #d8f0e1; }
.tm-whatsapp:focus-visible { outline: 2px solid #17703f; outline-offset: 2px; }
.tm-whatsapp svg { width: 16px; height: 16px; flex: none; }
.tm-whatsapp-compatto { padding: 5px; }
.tm-whatsapp-grande {
padding: 10px 18px;
font-size: 15px;
}
.tm-whatsapp-grande svg { width: 20px; height: 20px; }
.tm-whatsapp-numero { font-weight: 500; opacity: .75; }
/* ─── Assegnazione appena fatta: il link da mandare ──────────────────────── */
.tm-da-inviare {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 48rem;
margin-bottom: 20px;
padding: 18px 20px;
border: 2px solid var(--tm-accento);
border-radius: var(--tm-raggio);
background: var(--tm-accento-tenue);
box-shadow: var(--tm-ombra-2);
}
.tm-da-inviare-titolo {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--tm-accento-scuro);
}
.tm-da-inviare-link {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.tm-da-inviare-link code {
flex: 1 1 260px;
min-width: 0;
padding: 8px 12px;
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
font-size: 13px;
color: var(--tm-testo);
word-break: break-all;
cursor: text;
}
.tm-da-inviare-azioni { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.tm-da-inviare-scelta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--tm-bordo);
}
.tm-da-inviare-scelta p { margin: 0; font-size: 14px; font-weight: 600; color: var(--tm-testo); }
.tm-da-inviare-scelta > div { display: flex; flex-wrap: wrap; gap: 8px; }
/* ─── Ricerca e filtri sopra un elenco ───────────────────────────────────── */
.tm-filtri {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.tm-filtri-cerca {
flex: 1 1 220px;
min-width: 0;
padding: 7px 11px;
border: 1px solid var(--tm-bordo-forte);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
font-size: 14px;
color: var(--tm-testo);
}
.tm-filtri-cerca:focus {
outline: 2px solid var(--tm-accento);
outline-offset: 1px;
}
.tm-filtri-schede {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tm-filtri-scheda {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--tm-bordo);
border-radius: 999px;
background: var(--tm-superficie);
font-size: 13px;
font-weight: 500;
color: var(--tm-testo-medio);
cursor: pointer;
}
.tm-filtri-scheda:hover { border-color: var(--tm-bordo-forte); }
.tm-filtri-scheda:focus-visible {
outline: 2px solid var(--tm-accento);
outline-offset: 2px;
}
.tm-filtri-scheda-attiva {
border-color: var(--tm-accento);
background: var(--tm-accento-tenue);
color: var(--tm-accento-scuro);
}
.tm-filtri-scheda .tm-conteggio {
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
}
/* ─── Etichette di stato ──────────────────────────────────────────────────── */ /* ─── Etichette di stato ──────────────────────────────────────────────────── */
.tm-tag { .tm-tag {
@@ -989,9 +1125,9 @@ html { scroll-behavior: smooth; }
.tm-editor-barra { .tm-editor-barra {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
flex: none; flex: none;
padding: 9px 14px; padding: 8px 14px;
background: var(--tm-superficie); background: var(--tm-superficie);
border-bottom: 1px solid var(--tm-bordo); border-bottom: 1px solid var(--tm-bordo);
box-shadow: var(--tm-ombra-1); box-shadow: var(--tm-ombra-1);
@@ -1026,6 +1162,8 @@ html { scroll-behavior: smooth; }
gap: 9px; gap: 9px;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
}
.tm-editor-titolo h1 {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
@@ -1034,6 +1172,11 @@ html { scroll-behavior: smooth; }
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.tm-editor-salvato {
font-size: 12px;
color: var(--tm-testo-tenue);
white-space: nowrap;
}
.tm-editor-azioni { .tm-editor-azioni {
display: flex; display: flex;
@@ -1042,10 +1185,26 @@ html { scroll-behavior: smooth; }
flex: none; flex: none;
} }
.tm-editor-mappa { /* Le tre zone: elenco, mappa, pannello. Elenco e pannello galleggiano sopra
la mappa invece di restringerla: se togliessero spazio, aprirli per cambiare
un nome sposterebbe il disegno sotto il puntatore. */
.tm-editor-corpo {
position: relative; position: relative;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
/* Lo spazio che elenco e pannello aperti tolgono ai lati: barra degli
strumenti e controlli della mappa stanno nella parte libera, invece di
finire sotto (o sopra) un pannello. */
--libero-sx: 12px;
--libero-dx: 12px;
}
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(270px, 100vw - 24px) + 24px); }
.tm-editor-corpo.con-pannello { --libero-dx: calc(min(370px, 100vw - 24px) + 24px); }
.tm-mappa .leaflet-left { left: calc(var(--libero-sx) - 12px); }
.tm-mappa .leaflet-right { right: calc(var(--libero-dx) - 12px); }
.tm-editor-mappa {
position: absolute;
inset: 0;
} }
.tm-mappa { .tm-mappa {
position: absolute; position: absolute;
@@ -1056,7 +1215,7 @@ html { scroll-behavior: smooth; }
.tm-editor-nota { .tm-editor-nota {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 14px; bottom: 18px;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 500; z-index: 500;
max-width: min(92vw, 460px); max-width: min(92vw, 460px);
@@ -1072,16 +1231,132 @@ html { scroll-behavior: smooth; }
} }
.tm-editor-nota a { color: inherit; font-weight: 600; } .tm-editor-nota a { color: inherit; font-weight: 600; }
/* Il pannello galleggia sopra la mappa invece di restringerla: se togliesse /* ── Barra degli strumenti di disegno ── */
spazio, aprirlo per cambiare un nome sposterebbe il disegno sotto il .tm-strumenti {
puntatore. */
.tm-editor-pannello {
position: absolute; position: absolute;
top: 12px; top: 12px;
right: 12px; left: var(--libero-sx);
right: var(--libero-dx);
z-index: 650;
display: flex;
align-items: center;
gap: 6px;
width: fit-content;
margin-inline: auto;
padding: 5px;
border: 1px solid var(--tm-bordo);
border-radius: 12px;
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
overflow-x: auto;
}
.tm-strumenti:empty { display: none; }
.tm-strumento {
flex: none;
min-height: 34px;
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: none;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--tm-testo-medio);
white-space: nowrap;
cursor: pointer;
transition: background-color .15s ease, color .15s ease;
}
.tm-strumento:hover { background: var(--tm-superficie-alt); color: var(--tm-testo); }
.tm-strumento:focus-visible { outline: 2px solid var(--tm-accento); outline-offset: 1px; }
.tm-strumento-principale { background: var(--tm-accento); color: #fff; }
.tm-strumento-principale:hover { background: var(--tm-accento-scuro); color: #fff; }
.tm-strumenti-modo {
flex: none;
padding: 0 8px 0 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--tm-pericolo);
}
/* ── Elenco ── */
.tm-editor-elenco {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px; bottom: 12px;
z-index: 600; z-index: 600;
width: min(360px, calc(100vw - 24px)); width: min(270px, calc(100vw - 24px));
display: flex;
flex-direction: column;
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio);
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
overflow: hidden;
}
.tm-editor-elenco-cerca {
flex: none;
padding: 10px;
border-bottom: 1px solid var(--tm-bordo);
}
.tm-editor-elenco-cerca input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--tm-bordo-forte);
border-radius: 8px;
font: inherit;
font-size: 13.5px;
}
.tm-editor-elenco-righe {
flex: 1 1 auto;
margin: 0;
padding: 6px;
list-style: none;
overflow-y: auto;
}
.tm-editor-elenco-voce {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 7px 10px;
border: 0;
border-radius: 8px;
background: none;
font: inherit;
font-size: 13.5px;
text-align: left;
color: var(--tm-testo);
cursor: pointer;
}
.tm-editor-elenco-voce:hover { background: var(--tm-superficie-alt); }
.tm-editor-elenco-voce.is-selezionata { background: var(--tm-accento-tenue); color: var(--tm-accento-scuro); font-weight: 600; }
.tm-editor-elenco-stato {
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--tm-testo-tenue);
}
.tm-stato-bozza, .tm-stato-modificata { color: var(--tm-attesa); }
.tm-stato-pubblicata { color: var(--tm-ok); }
.tm-editor-elenco-vuoto {
padding: 14px 10px;
font-size: 13px;
color: var(--tm-testo-tenue);
}
/* ── Pannello della piantina ── */
.tm-editor-pannello {
position: absolute;
top: 12px;
right: 12px;
bottom: 12px;
z-index: 600;
width: min(370px, calc(100vw - 24px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--tm-bordo); border: 1px solid var(--tm-bordo);
@@ -1090,7 +1365,6 @@ html { scroll-behavior: smooth; }
box-shadow: var(--tm-ombra-3); box-shadow: var(--tm-ombra-3);
overflow: hidden; overflow: hidden;
} }
.tm-editor-pannello[hidden] { display: none; }
.tm-editor-pannello-testata { .tm-editor-pannello-testata {
display: flex; display: flex;
@@ -1098,7 +1372,7 @@ html { scroll-behavior: smooth; }
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
flex: none; flex: none;
padding: 13px 16px; padding: 12px 16px;
border-bottom: 1px solid var(--tm-bordo); border-bottom: 1px solid var(--tm-bordo);
} }
.tm-editor-pannello-testata h2 { .tm-editor-pannello-testata h2 {
@@ -1130,7 +1404,7 @@ html { scroll-behavior: smooth; }
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
gap: 10px; gap: 10px;
padding-top: 16px; padding-top: 16px;
border-top: 1px solid var(--tm-bordo); border-top: 1px solid var(--tm-bordo);
@@ -1141,14 +1415,164 @@ html { scroll-behavior: smooth; }
line-height: 1.55; line-height: 1.55;
color: var(--tm-testo-tenue); color: var(--tm-testo-tenue);
} }
.tm-stato-piantina p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--tm-testo-medio); }
.tm-azioni-riga {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.tm-azioni-riga:empty { display: none; }
.tm-passi {
margin: 0;
padding-left: 20px;
font-size: 13.5px;
line-height: 1.6;
color: var(--tm-testo-medio);
}
.tm-collegamento-piccolo {
align-self: flex-start;
padding: 0;
border-top: 0;
border-left: 0;
border-right: 0;
background: none;
font: inherit;
font-size: 12.5px;
cursor: pointer;
}
.tm-solo-stretto { display: none; }
/* Tablet: l'elenco si apre a richiesta e copre la mappa per il tempo della
scelta. */
@media (max-width: 1099px) {
.tm-editor-elenco { width: min(300px, calc(100vw - 24px)); }
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(300px, 100vw - 24px) + 24px); }
}
/* Telefono e tablet in verticale: il pannello sale dal basso. */
@media (max-width: 720px) { @media (max-width: 720px) {
.tm-editor-barra { flex-wrap: wrap; } .tm-editor-barra { flex-wrap: wrap; }
.tm-editor-titolo { order: 3; flex-basis: 100%; } .tm-editor-titolo { order: 3; flex-basis: 100%; }
.tm-editor-pannello { top: auto; height: min(70dvh, 460px); } .tm-editor-azioni .tm-btn span { display: none; }
.tm-solo-stretto { display: inline-flex; }
.tm-editor-pannello {
top: auto;
left: 0;
right: 0;
bottom: 0;
width: auto;
height: min(62dvh, 520px);
border-radius: var(--tm-raggio) var(--tm-raggio) 0 0;
}
/* Elenco e pannello qui coprono la mappa per intero o dal basso: la
barra resta in alto, sopra il foglio del pannello. */
.tm-editor-corpo,
.tm-editor-corpo.con-elenco,
.tm-editor-corpo.con-pannello { --libero-sx: 12px; --libero-dx: 12px; }
/* A destra dei pulsanti dello zoom, e su più righe invece di uscire
dallo schermo. */
.tm-strumenti { left: 58px; flex-wrap: wrap; }
.tm-mappa .leaflet-left { left: 0; }
/* Il foglio copre crediti e scala della mappa finché è aperto, invece di
averli stampati sopra i campi. */
.tm-editor-pannello { z-index: 1001; }
} }
/* ─── Avvisi galleggianti (solo telaio a schermo intero) ───────────────────── */ /* Dita, non puntatore: vertici e maniglie di leaflet-draw più grandi, così
si prendono senza zoomare al massimo. */
@media (pointer: coarse) {
.leaflet-editing-icon,
.leaflet-touch-icon {
width: 22px !important;
height: 22px !important;
margin-left: -11px !important;
margin-top: -11px !important;
}
.tm-strumento { min-height: 42px; padding: 8px 14px; }
}
/* ─── Finestra di pubblicazione ───────────────────────────────────────────── */
.tm-modale-sfondo {
position: fixed;
inset: 0;
z-index: 800;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(28, 26, 23, .45);
}
.tm-modale {
display: flex;
flex-direction: column;
width: 100%;
max-width: 460px;
max-height: calc(100dvh - 32px);
border-radius: var(--tm-raggio);
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
overflow: hidden;
}
.tm-modale-larga { max-width: 820px; }
.tm-modale-testata {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--tm-bordo);
}
.tm-modale-testata h2 { margin: 0; font-size: 15px; font-weight: 600; }
.tm-modale-corpo {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
overflow-y: auto;
}
.tm-modale-piede {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px;
border-top: 1px solid var(--tm-bordo);
}
.tm-anteprima-pdf {
width: 100%;
height: min(62dvh, 640px);
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie-alt);
}
.tm-attesa {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 18px 4px;
}
.tm-attesa-titolo { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
.tm-attesa-giro {
flex: none;
width: 22px;
height: 22px;
border: 3px solid var(--tm-accento-tenue);
border-top-color: var(--tm-accento);
border-radius: 50%;
animation: tm-giro .9s linear infinite;
}
@keyframes tm-giro { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .tm-attesa-giro { animation-duration: 3s; } }
.tm-nota-errore {
border-color: #edc9c6;
background: var(--tm-pericolo-tenue);
color: var(--tm-pericolo);
}
/* ─── Avvisi galleggianti (componente <x-avvisi>) ───────────────────── */
.tm-avvisi { .tm-avvisi {
position: fixed; position: fixed;

View File

@@ -1,10 +1,22 @@
// Editor delle piantine (pagina /piantine/editor, solo amministratore). // Editor delle piantine (pagina /piantine/editor, solo amministratore).
// //
// Mappa Leaflet su tessere OpenStreetMap: mostra l'area del KML, le piantine // Mappa Leaflet su tessere OpenStreetMap con l'area del KML e le piantine, più
// già salvate e permette di disegnarne di nuove con leaflet-draw. Parla con il // l'elenco con ricerca a sinistra. Disegno e modifica dei vertici usano i
// componente Livewire attraverso eventi: // gestori di leaflet-draw, comandati da una barra di strumenti nostra: pulsanti
// JS → Livewire: 'poligono-disegnato', 'piantina-selezionata-mappa' // grandi e con il testo, che si usano anche al tablet.
// Livewire → JS: 'piantine-dati-aggiornati' //
// Modalità, sempre esplicite:
// vista si guarda, si sceglie una piantina (clic sulla mappa o in elenco)
// disegno si traccia una piantina NUOVA
// ridisegno si traccia da capo il confine di quella aperta (con conferma)
// vertici si trascinano i vertici di quella aperta
//
// Con il componente Livewire si parla per eventi:
// JS → Livewire: 'piantina-selezionata-mappa', 'geometria-modificata'
// Livewire → JS: 'piantina-aggiornata', 'piantina-rimossa',
// 'piantina-selezionata', 'editor-disegna', 'editor-indirizzo'
// I dati di tutte le piantine si chiedono una volta sola ($wire.datiMappa());
// dopo arriva solo la piantina cambiata.
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
@@ -12,12 +24,13 @@ import 'leaflet-draw';
import 'leaflet-draw/dist/leaflet.draw.css'; import 'leaflet-draw/dist/leaflet.draw.css';
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false }; const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
// Una piantina collegata a un territorio è "a posto"; una scollegata è un const STILI = {
// lavoro ancora aperto, e si vede senza dover aprire il pannello. pubblicata: { color: '#2f6f4e', weight: 2, fillColor: '#2f6f4e', fillOpacity: 0.08 },
const STILE_COLLEGATA = { color: '#8a8578', weight: 2, fillColor: '#8a8578', fillOpacity: 0.1 }; modificata: { color: '#a06a22', weight: 2, fillColor: '#c98b3a', fillOpacity: 0.1 },
const STILE_SCOLLEGATA = { color: '#b4762a', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 }; bozza: { color: '#a06a22', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 },
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, fillColor: '#605cb2', fillOpacity: 0.18 }; };
const STILE_IN_ATTESA = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 }; const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, dashArray: null, fillColor: '#605cb2', fillOpacity: 0.18 };
const STILE_DISEGNO = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
function initPiantine() { function initPiantine() {
const contenitore = document.getElementById('piantine-map'); const contenitore = document.getElementById('piantine-map');
@@ -26,143 +39,294 @@ function initPiantine() {
} }
contenitore.dataset.inizializzato = '1'; contenitore.dataset.inizializzato = '1';
let dati = { area: null, piantine: [], selezionata: null }; const radice = contenitore.closest('[wire\\:id]');
try { const componente = () => window.Livewire.find(radice.getAttribute('wire:id'));
dati = JSON.parse(document.getElementById('piantine-dati').textContent);
} catch {
// si parte comunque con la mappa vuota
}
// Etichette in italiano per la barra di disegno.
L.drawLocal.draw.toolbar.buttons.polygon = 'Disegna il confine di una piantina';
L.drawLocal.draw.toolbar.actions = { title: 'Annulla il disegno', text: 'Annulla' };
L.drawLocal.draw.toolbar.finish = { title: 'Concludi il disegno', text: 'Concludi' };
L.drawLocal.draw.toolbar.undo = { title: 'Rimuovi l\'ultimo punto', text: 'Ultimo punto' };
L.drawLocal.draw.handlers.polygon.tooltip = { L.drawLocal.draw.handlers.polygon.tooltip = {
start: 'Clicca sulla mappa per iniziare il confine.', start: 'Tocca o clicca sulla mappa per il primo punto del confine.',
cont: 'Continua a cliccare lungo il confine.', cont: 'Continua lungo il confine.',
end: 'Clicca sul primo punto per chiudere il poligono.', end: 'Chiudi sul primo punto, o premi «Concludi».',
}; };
L.drawLocal.edit.handlers.edit.tooltip = { text: 'Trascina i vertici per correggere il confine.', subtext: '' };
const mappa = L.map(contenitore, { zoomControl: true }); const mappa = L.map(contenitore, { zoomControl: true });
L.control.scale({ imperial: false }).addTo(mappa); L.control.scale({ imperial: false }).addTo(mappa);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(mappa); }).addTo(mappa);
const gruppo = L.featureGroup().addTo(mappa);
const layers = new Map(); // mappaId → { dati, layer }
let layerArea = null; let layerArea = null;
const layerSalvate = L.featureGroup().addTo(mappa); let selezionata = null;
let layerInAttesa = null; let modo = 'vista';
let gestoreDisegno = null;
let poligonoInModifica = null;
mappa.addControl(new L.Control.Draw({ // ── Disegno delle piantine ──────────────────────────────────────────────
draw: {
polygon: { allowIntersection: false, showArea: false, shapeOptions: STILE_IN_ATTESA },
polyline: false,
rectangle: false,
circle: false,
circlemarker: false,
marker: false,
},
edit: false,
}));
function stileDi(piantina, selezionata) { function stileDi(dati) {
if (selezionata) return STILE_SELEZIONATA; if (dati.mappaId === selezionata) return STILE_SELEZIONATA;
return piantina.collegata ? STILE_COLLEGATA : STILE_SCOLLEGATA; return { dashArray: null, ...(STILI[dati.stato] ?? STILI.bozza) };
} }
function ridisegna(nuoviDati, adattaVista) { function disegna(dati) {
dati = nuoviDati; rimuovi(dati.mappaId, false);
if (!dati.geometry) return;
if (layerArea) { let layer;
mappa.removeLayer(layerArea); try {
layerArea = null; layer = L.geoJSON({ type: 'Feature', properties: {}, geometry: dati.geometry }, { style: stileDi(dati) });
} catch {
return; // una geometria rovinata non deve fermare le altre
} }
layer.bindTooltip(`${dati.etichetta} · ${dati.statoEtichetta}`, { sticky: true });
layer.on('click', () => {
if (modo !== 'vista') return;
componente().dispatch('piantina-selezionata-mappa', { mappaId: dati.mappaId });
});
gruppo.addLayer(layer);
layers.set(dati.mappaId, { dati, layer });
}
function rimuovi(mappaId, aggiornaElenco = true) {
const voce = layers.get(mappaId);
if (voce) {
gruppo.removeLayer(voce.layer);
layers.delete(mappaId);
}
if (aggiornaElenco) disegnaElenco();
}
function ristila() {
for (const { dati, layer } of layers.values()) {
layer.setStyle(stileDi(dati));
}
}
function inquadra(mappaId) {
const voce = layers.get(mappaId);
if (voce) mappa.fitBounds(voce.layer.getBounds().pad(0.25));
}
// ── Elenco con ricerca ──────────────────────────────────────────────────
const elenco = document.getElementById('piantine-elenco');
const cerca = document.getElementById('piantine-cerca');
function disegnaElenco() {
if (!elenco) return;
const filtro = (cerca?.value ?? '').trim().toLowerCase();
const voci = [...layers.values()].map((v) => v.dati)
.filter((d) => !filtro || d.etichetta.toLowerCase().includes(filtro))
.sort((a, b) => a.etichetta.localeCompare(b.etichetta, 'it', { numeric: true }));
elenco.replaceChildren(...voci.map((d) => {
const riga = document.createElement('li');
const pulsante = document.createElement('button');
pulsante.type = 'button';
pulsante.className = 'tm-editor-elenco-voce' + (d.mappaId === selezionata ? ' is-selezionata' : '');
pulsante.setAttribute('aria-current', d.mappaId === selezionata ? 'true' : 'false');
const nome = document.createElement('span');
nome.textContent = d.etichetta;
const stato = document.createElement('span');
stato.className = 'tm-editor-elenco-stato tm-stato-' + d.stato;
stato.textContent = d.statoEtichetta;
pulsante.append(nome, stato);
pulsante.addEventListener('click', () => {
if (modo !== 'vista') return;
componente().dispatch('piantina-selezionata-mappa', { mappaId: d.mappaId });
});
riga.append(pulsante);
return riga;
}));
if (!voci.length) {
const vuoto = document.createElement('li');
vuoto.className = 'tm-editor-elenco-vuoto';
vuoto.textContent = filtro ? 'Nessuna piantina trovata.' : 'Nessuna piantina ancora.';
elenco.append(vuoto);
}
}
cerca?.addEventListener('input', disegnaElenco);
// ── Barra degli strumenti ───────────────────────────────────────────────
const barra = document.getElementById('piantine-strumenti');
function pulsante(testo, azione, principale = false) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tm-strumento' + (principale ? ' tm-strumento-principale' : '');
b.textContent = testo;
b.addEventListener('click', azione);
return b;
}
function aggiornaBarra() {
if (!barra) return;
const comandi = [];
if (modo === 'vista') {
comandi.push(pulsante('Disegna nuova piantina', () => avviaDisegno(false), selezionata === null));
if (selezionata !== null && layers.has(selezionata)) {
comandi.push(pulsante('Modifica vertici', avviaVertici));
comandi.push(pulsante('Ridisegna confine', () => {
if (window.confirm('Ridisegnare da capo il confine di questa piantina? Quello attuale viene sostituito.')) {
avviaDisegno(true);
}
}));
}
} else if (modo === 'disegno' || modo === 'ridisegno') {
const titolo = document.createElement('span');
titolo.className = 'tm-strumenti-modo';
titolo.textContent = modo === 'disegno' ? 'Nuova piantina' : 'Nuovo confine';
comandi.push(titolo);
comandi.push(pulsante('Togli ultimo punto', () => gestoreDisegno?.deleteLastVertex()));
comandi.push(pulsante('Concludi', () => gestoreDisegno?.completeShape(), true));
comandi.push(pulsante('Annulla', fermaDisegno));
} else if (modo === 'vertici') {
const titolo = document.createElement('span');
titolo.className = 'tm-strumenti-modo';
titolo.textContent = 'Trascina i vertici';
comandi.push(titolo);
comandi.push(pulsante('Fine modifica', fermaVertici, true));
}
barra.replaceChildren(...comandi);
}
// ── Disegno ─────────────────────────────────────────────────────────────
let ridisegno = false;
function avviaDisegno(diQuellaAperta) {
fermaVertici(false);
ridisegno = diQuellaAperta;
modo = diQuellaAperta ? 'ridisegno' : 'disegno';
gestoreDisegno = new L.Draw.Polygon(mappa, { allowIntersection: false, showArea: false, shapeOptions: STILE_DISEGNO });
gestoreDisegno.enable();
aggiornaBarra();
}
function fermaDisegno() {
gestoreDisegno?.disable();
gestoreDisegno = null;
modo = 'vista';
aggiornaBarra();
}
mappa.on(L.Draw.Event.CREATED, (evento) => {
const geometry = evento.layer.toGeoJSON().geometry;
const nuova = !ridisegno;
fermaDisegno();
componente().dispatch('geometria-modificata', { geometry, nuova });
});
// Il gestore si spegne da solo anche con Esc: la barra deve seguirlo.
mappa.on(L.Draw.Event.DRAWSTOP, () => {
if (modo === 'disegno' || modo === 'ridisegno') {
gestoreDisegno = null;
modo = 'vista';
aggiornaBarra();
}
});
// ── Modifica dei vertici ────────────────────────────────────────────────
function avviaVertici() {
const voce = layers.get(selezionata);
if (!voce) return;
poligonoInModifica = voce.layer.getLayers()[0];
if (!poligonoInModifica?.editing) return;
poligonoInModifica.editing.enable();
modo = 'vertici';
aggiornaBarra();
}
function fermaVertici(salva = true) {
if (!poligonoInModifica) {
if (modo === 'vertici') { modo = 'vista'; aggiornaBarra(); }
return;
}
const poligono = poligonoInModifica;
poligonoInModifica = null;
const modificato = poligono.edited;
poligono.editing.disable();
modo = 'vista';
aggiornaBarra();
if (salva && modificato) {
componente().dispatch('geometria-modificata', { geometry: poligono.toGeoJSON().geometry, nuova: false });
}
}
// ── Eventi dal server ───────────────────────────────────────────────────
function seleziona(mappaId, centra) {
if (modo === 'vertici') fermaVertici();
selezionata = mappaId ?? null;
ristila();
disegnaElenco();
aggiornaBarra();
if (centra && selezionata !== null) inquadra(selezionata);
}
function registra() {
window.Livewire.on('piantina-aggiornata', ({ piantina }) => {
disegna(piantina);
disegnaElenco();
aggiornaBarra();
});
window.Livewire.on('piantina-rimossa', ({ mappaId }) => {
if (selezionata === mappaId) selezionata = null;
rimuovi(mappaId);
aggiornaBarra();
});
window.Livewire.on('piantina-selezionata', ({ mappaId, inquadra: centra }) => seleziona(mappaId, centra));
window.Livewire.on('editor-disegna', () => avviaDisegno(false));
window.Livewire.on('editor-indirizzo', ({ url }) => {
if (window.location.href !== url) window.history.replaceState(window.history.state, '', url);
});
}
async function carica() {
const dati = await componente().datiMappa();
if (dati.area) { if (dati.area) {
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa); layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
} }
selezionata = dati.selezionata ?? null;
dati.piantine.forEach(disegna);
disegnaElenco();
aggiornaBarra();
layerSalvate.clearLayers(); if (selezionata !== null && layers.has(selezionata)) {
let layerSelezionata = null; inquadra(selezionata);
} else if (layerArea) {
for (const piantina of dati.piantine) { mappa.fitBounds(layerArea.getBounds().pad(0.08));
const selezionata = dati.selezionata === piantina.mappaId; } else if (layers.size) {
const layer = L.geoJSON( mappa.fitBounds(gruppo.getBounds().pad(0.08));
{ type: 'Feature', properties: {}, geometry: piantina.geometry },
{ style: stileDi(piantina, selezionata) }
);
layer.bindTooltip(piantina.etichetta, { sticky: true });
layer.on('click', () => {
window.Livewire.dispatch('piantina-selezionata-mappa', { mappaId: piantina.mappaId });
});
layerSalvate.addLayer(layer);
if (selezionata) {
layerSelezionata = layer;
}
}
// Aprire una piantina dall'elenco deve portarci sopra; un salvataggio
// no, altrimenti la vista salta via mentre si sta ancora lavorando.
if (adattaVista && layerSelezionata) {
mappa.fitBounds(layerSelezionata.getBounds().pad(0.25));
return;
}
if (!adattaVista) {
return;
}
const riferimento = layerArea ?? (dati.piantine.length ? layerSalvate : null);
if (riferimento) {
mappa.fitBounds(riferimento.getBounds().pad(0.08));
} else { } else {
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
} }
} }
ridisegna(dati, true); mappa.setView([41.9, 12.5], 6);
mappa.on(L.Draw.Event.CREATED, (evento) => { const avvia = () => { registra(); carica(); };
if (layerInAttesa) { if (window.Livewire?.find && radice && window.Livewire.find(radice.getAttribute('wire:id'))) {
mappa.removeLayer(layerInAttesa); avvia();
} } else {
layerInAttesa = evento.layer; document.addEventListener('livewire:initialized', avvia, { once: true });
layerInAttesa.setStyle(STILE_IN_ATTESA);
layerInAttesa.addTo(mappa);
window.Livewire.dispatch('poligono-disegnato', { geometry: evento.layer.toGeoJSON().geometry });
});
let registrato = false;
document.addEventListener('livewire:init', registraEventi);
if (window.Livewire) {
registraEventi();
} }
function registraEventi() { // Pannelli che si aprono e chiudono cambiano le misure del contenitore:
if (registrato) { // senza, Leaflet resta convinto di quelle di partenza e le tessere si
return; // disallineano dal puntatore.
} new ResizeObserver(() => mappa.invalidateSize()).observe(contenitore);
registrato = true;
window.Livewire.on('piantine-dati-aggiornati', ({ dati: nuovi, inquadra }) => {
// Dopo un salvataggio il poligono provvisorio è diventato
// definitivo (o è stato scartato): via il tracciato rosso.
if (layerInAttesa) {
mappa.removeLayer(layerInAttesa);
layerInAttesa = null;
}
ridisegna(nuovi, Boolean(inquadra));
});
}
// La mappa nasce dentro un contenitore che il pannello laterale può
// coprire: senza questo, Leaflet resta convinto delle misure di partenza e
// le tessere si disallineano dal puntatore.
window.addEventListener('resize', () => mappa.invalidateSize());
setTimeout(() => mappa.invalidateSize(), 60);
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {

Binary file not shown.

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Link scaduto — TerManager2</title> <title>Link scaduto — TerManager2</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
<title>Territorio {{ $assignment->territorio?->numero }}</title> <title>Territorio {{ $assignment->territorio?->numero }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer">
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View File

@@ -48,9 +48,19 @@
sanno a quale account associare la credenziale che salvano. --}} sanno a quale account associare la credenziale che salvano. --}}
<input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}"> <input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}">
@unless($forced)
{{-- Dopo un ripristino non si chiede: la password attuale e'
quella del backup, che si puo' non ricordare. --}}
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label>
<input type="password" name="current_password" id="current_password" required autofocus autocomplete="current-password"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
</div>
@endunless
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label> <label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label>
<input type="password" name="password" id="password" required autofocus autocomplete="new-password" <input type="password" name="password" id="password" required @if($forced) autofocus @endif autocomplete="new-password"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm"> class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<p class="mt-1 text-xs text-gray-500">Almeno 8 caratteri.</p> <p class="mt-1 text-xs text-gray-500">Almeno 8 caratteri.</p>
</div> </div>

View File

@@ -0,0 +1,50 @@
<x-layouts.guest title="Conferma il tuo indirizzo email">
<div class="bg-white rounded-xl shadow-lg border border-gray-200 p-6 space-y-5">
<div>
<h1 class="text-xl font-bold text-gray-900">Conferma il tuo indirizzo email</h1>
<p class="mt-1 text-sm text-gray-500">
Account: <span class="font-medium text-gray-700 break-all">{{ $user->email }}</span>
</p>
</div>
@if(session('success'))
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="rounded-lg bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-800">
{{ session('error') }}
</div>
@endif
<p class="text-sm text-gray-700">
Per usare TerManager2 devi confermare che questo indirizzo è tuo: apri il link nell'email che ti
abbiamo mandato. Se non l'hai ricevuta, o il link è scaduto, chiedine una nuova.
</p>
@unless($postaFunzionante)
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Il server di posta non è ancora configurato: l'email non può partire.
@can('settings.manage')
<a href="{{ route('posta.edit') }}" class="font-semibold underline">Configuralo qui</a>.
@else
Avvisa un amministratore.
@endcan
</div>
@endunless
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<x-btn variante="primary" type="submit" class="w-full">Inviami l'email di conferma</x-btn>
</form>
<div class="flex items-center justify-between gap-2 text-xs text-gray-500 pt-1 border-t border-gray-100">
<span>L'indirizzo è sbagliato? <a href="{{ route('profile') }}" class="text-indigo-700 underline">Correggilo nel profilo</a>.</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-btn variante="ghost" dimensione="sm" type="submit">Esci</x-btn>
</form>
</div>
</div>
</x-layouts.guest>

View File

@@ -0,0 +1,50 @@
{{--
Avvisi galleggianti: raccolgono l'evento "avviso" che i componenti
Livewire lanciano con il trait Avvisa, e che arriva anche dopo un'azione
che ridisegna solo il componente.
sessione: true per mostrare anche i messaggi flash della richiesta (il
telaio a schermo intero, che non ha una fascia messaggi sua). Il telaio
normale li mostra già in testa alla pagina, quindi lì resta false.
Gli avvisi di conferma si chiudono da soli; quelli di errore restano
finché non li si chiude, perché vanno letti.
--}}
@props(['sessione' => false])
@php
$iniziali = [];
if ($sessione && session()->has('success')) {
$iniziali[] = ['tipo' => 'ok', 'testo' => session('success')];
}
if ($sessione && session()->has('error')) {
$iniziali[] = ['tipo' => 'errore', 'testo' => session('error')];
}
@endphp
<div class="tm-avvisi" role="status" aria-live="polite"
x-data="{
avvisi: [],
prossimo: 0,
aggiungi(tipo, testo) {
const id = ++this.prossimo;
this.avvisi.push({ id, tipo, testo });
if (tipo !== 'errore') setTimeout(() => this.togli(id), 4500);
},
togli(id) { this.avvisi = this.avvisi.filter(a => a.id !== id); },
}"
x-init="@js($iniziali).forEach(a => aggiungi(a.tipo, a.testo))"
@avviso.window="aggiungi($event.detail.tipo, $event.detail.testo)">
<template x-for="avviso in avvisi" :key="avviso.id">
<div class="tm-avviso" :class="avviso.tipo === 'errore' ? 'tm-avviso-errore' : 'tm-avviso-ok'"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0">
<svg x-show="avviso.tipo !== 'errore'" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<svg x-show="avviso.tipo === 'errore'" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><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>
<span x-text="avviso.testo"></span>
<button type="button" @click="togli(avviso.id)" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">&times;</button>
</div>
</template>
</div>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'TerManager2' }}</title> <title>{{ $title ?? 'TerManager2' }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>
@@ -17,13 +18,16 @@
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <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 h-16 items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<x-btn tono="velato" icona class="lg:hidden" {{-- Nascosto dal contenitore: vedi il profilo qui sotto. --}}
<div class="lg:hidden">
<x-btn tono="velato" icona
x-data x-on:click="$dispatch('toggle-sidebar')" x-data x-on:click="$dispatch('toggle-sidebar')"
aria-label="Apri il menu di navigazione"> 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"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg> </svg>
</x-btn> </x-btn>
</div>
@php $settings = \App\Models\Setting::first(); @endphp @php $settings = \App\Models\Setting::first(); @endphp
@if($settings && $settings->logo_path) @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)"> <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)">
@@ -35,14 +39,21 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{{-- Il contenitore si nasconde, non il pulsante: .tm-btn sta
fuori dai layer di Tailwind e il suo display vince
su "hidden", così su telefono nome ed email
spingevano "Esci" fuori dallo schermo. Il profilo
resta raggiungibile dal menu. --}}
<div class="hidden sm:block">
<x-btn tono="velato" dimensione="sm" :href="route('profile')" title="Il mio profilo" <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' : '' }}"> class="{{ 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> <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="flex flex-col leading-tight text-left">
<span class="text-sm font-medium">{{ auth()->user()->name }}</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 class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
</span> </span>
</x-btn> </x-btn>
</div>
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<x-btn tono="velato" dimensione="sm" type="submit"> <x-btn tono="velato" dimensione="sm" type="submit">
@@ -212,6 +223,11 @@
</main> </main>
</div> </div>
</div> </div>
{{-- Gli avvisi delle azioni Livewire (trait Avvisa). I flash della
richiesta restano nella fascia in testa al contenuto, qui sopra. --}}
<x-avvisi />
@livewireScripts @livewireScripts
</body> </body>
</html> </html>

View File

@@ -5,8 +5,8 @@
Serve a una cosa sola: la mappa dell'editor delle piantine, che deve Serve a una cosa sola: la mappa dell'editor delle piantine, che deve
prendersi tutta la finestra. I messaggi non spingono il contenuto verso il prendersi tutta la finestra. I messaggi non spingono il contenuto verso il
basso come nelle altre pagine — comparirebbe una fascia che sposta la mappa basso come nelle altre pagine — comparirebbe una fascia che sposta la mappa
mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro, mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro
e si tolgono da soli. (<x-avvisi>, che raccoglie anche quelli lanciati dalle azioni Livewire).
--}} --}}
<html lang="it" class="h-full tm-app"> <html lang="it" class="h-full tm-app">
<head> <head>
@@ -14,6 +14,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'TerManager2' }}</title> <title>{{ $title ?? 'TerManager2' }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>
@@ -22,33 +23,7 @@
{{ $slot }} {{ $slot }}
</main> </main>
<div class="tm-avvisi" role="status" aria-live="polite"> <x-avvisi :sessione="true" />
@if (session()->has('success'))
<div x-data="{ mostra: true }" x-show="mostra" x-cloak
x-init="setTimeout(() => mostra = false, 4500)"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="tm-avviso tm-avviso-ok">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<span>{{ session('success') }}</span>
<button type="button" @click="mostra = false" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">&times;</button>
</div>
@endif
@if (session()->has('error'))
<div x-data="{ mostra: true }" x-show="mostra" x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="tm-avviso tm-avviso-errore">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><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>
<span>{{ session('error') }}</span>
<button type="button" @click="mostra = false" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">&times;</button>
</div>
@endif
</div>
@livewireScripts @livewireScripts
</body> </body>

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'TerManager2' }}</title> <title>{{ $title ?? 'TerManager2' }}</title>
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>

View File

@@ -0,0 +1,26 @@
{{--
Il numero di un proclamatore che, cliccato, apre WhatsApp (wa.me: app sul
telefono, WhatsApp Web sul computer). Niente se il numero manca o non è
utilizzabile.
proclamatore: il Proclamatore.
messaggio: testo già scritto nella chat (facoltativo).
compatto: solo l'icona, per le righe di un elenco.
--}}
@props(['proclamatore', 'messaggio' => null, 'compatto' => false])
@php($url = $proclamatore?->whatsappUrl($messaggio))
@if($url)
<a href="{{ $url }}" target="_blank" rel="noopener"
{{ $attributes->merge(['class' => 'tm-whatsapp' . ($compatto ? ' tm-whatsapp-compatto' : '')]) }}
title="Scrivi su WhatsApp a {{ $proclamatore->nome_completo }}"
@if($compatto) aria-label="Scrivi su WhatsApp a {{ $proclamatore->nome_completo }}" @endif>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
@unless($compatto)
<span>{{ $proclamatore->telefono }}</span>
@endunless
</a>
@elseif(! $compatto && trim((string) $proclamatore?->telefono) !== '')
<span {{ $attributes }}>{{ $proclamatore->telefono }}</span>
@endif

View File

@@ -8,6 +8,69 @@
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p> <p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p>
</div> </div>
{{-- Appena assegnato: il link da mandare al proclamatore, in evidenza, e
la scelta "inviato / non ancora". Resta finché non si sceglie
(Assegna::DA_INVIARE in sessione). --}}
@foreach($daInviare as $nuova)
@php
$link = $nuova->shortPdfUrl();
$messaggio = $link
? "Ciao {$nuova->proclamatore->nome}, ti è stato assegnato il territorio N° {$nuova->territorio->numero}. Ecco la piantina: {$link}"
: null;
$whatsapp = $messaggio ? $nuova->proclamatore->whatsappUrl($messaggio) : null;
@endphp
<section class="tm-da-inviare" wire:key="da-inviare-{{ $nuova->id }}" aria-labelledby="da-inviare-{{ $nuova->id }}">
<h2 id="da-inviare-{{ $nuova->id }}" class="tm-da-inviare-titolo">
Territorio N° {{ $nuova->territorio->numero }} assegnato a {{ $nuova->proclamatore->nome_completo }}
</h2>
@if($link)
<div x-data="{ copiato: false }" class="tm-da-inviare-link">
<code x-ref="link" x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
<x-btn variante="secondary" tono="tenue" dimensione="sm"
x-on:click="navigator.clipboard.writeText($refs.link.textContent).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) })">
<span x-show="! copiato">Copia link</span>
<span x-show="copiato" x-cloak>Copiato</span>
</x-btn>
</div>
<div class="tm-da-inviare-azioni">
@if($whatsapp)
<a href="{{ $whatsapp }}" target="_blank" rel="noopener" class="tm-whatsapp tm-whatsapp-grande">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
<span>Invia su WhatsApp</span>
<span class="tm-whatsapp-numero">{{ $nuova->proclamatore->telefono }}</span>
</a>
@else
<p class="text-sm text-gray-600">
{{ $nuova->proclamatore->nome_completo }} non ha un numero di telefono: copia il link e mandalo come preferisci.
@can('settings.manage')
<a href="{{ route('proclamatori.edit', $nuova->proclamatore) }}" class="text-indigo-700 underline">Aggiungi il numero</a>
@endcan
</p>
@endif
</div>
<div class="tm-da-inviare-scelta">
<p>Il link è stato inviato?</p>
<div>
<x-btn variante="primary" wire:click="esitoInvio({{ $nuova->id }}, true)">Sì, inviato</x-btn>
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Non ancora</x-btn>
</div>
</div>
@else
<p class="text-sm text-gray-600">
Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio.
</p>
<div class="tm-da-inviare-scelta">
<div>
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Ho capito</x-btn>
</div>
</div>
@endif
</section>
@endforeach
<div x-data="{ formOpen: true }"> <div x-data="{ formOpen: true }">
{{-- Toggle form --}} {{-- Toggle form --}}
@@ -158,6 +221,11 @@
<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> <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> </x-btn>
@if($a->proclamatore)
<x-whatsapp :proclamatore="$a->proclamatore" compatto class="flex-none"
:messaggio="'Ciao ' . $a->proclamatore->nome . ', ti è stato assegnato il territorio N° ' . $a->territorio?->numero . '. Ecco la piantina: ' . $pdfUrl" />
@endif
{{-- Flag Link Inviato --}} {{-- Flag Link Inviato --}}
{{-- Interruttore di stato, non un comando: lo stato "inviato" {{-- Interruttore di stato, non un comando: lo stato "inviato"
si legge dalla variante (primary quando è acceso) e si legge dalla variante (primary quando è acceso) e

View File

@@ -83,7 +83,11 @@
</div> </div>
@else @else
@foreach($activity->properties->toArray() as $k => $v) @foreach($activity->properties->toArray() as $k => $v)
<div>{{ $k }}: {{ is_string($v) ? $v : json_encode($v) }}</div> @if($k === 'proclamatore_id')
<div>proclamatore: {{ $nomiProclamatori[$v] ?? '#' . $v }}</div>
@else
<div>{{ $k }}: {{ is_string($v) ? $v : json_encode($v) }}</div>
@endif
@endforeach @endforeach
@endif @endif
</div> </div>

View File

@@ -5,6 +5,14 @@
<p class="text-gray-500 text-sm mt-1">Accedi per continuare</p> <p class="text-gray-500 text-sm mt-1">Accedi per continuare</p>
</div> </div>
{{-- Per esempio dopo aver aperto il link di conferma dell'email senza
essere entrati. --}}
@if(session('success'))
<div class="mb-5 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('login.store') }}" class="space-y-5"> <form method="POST" action="{{ route('login.store') }}" class="space-y-5">
@csrf @csrf
<div> <div>

View File

@@ -1,7 +1,27 @@
<div class="tm-editor"> @php
use App\Models\TerritorioMappa;
$stato = $mappa?->stato();
$motivo = $mappa?->motivoNonPubblicabile();
$tagStato = [
TerritorioMappa::STATO_BOZZA => 'tm-tag-attesa',
TerritorioMappa::STATO_MODIFICATA => 'tm-tag-attesa',
TerritorioMappa::STATO_PUBBLICATA => 'tm-tag-ok',
];
@endphp
{{--
Editor a tre zone: elenco (a sinistra), mappa, pannello della piantina (a
destra; sotto i 720px sale dal basso). Elenco e mappa li disegna
resources/js/piantine.js e stanno in wire:ignore: Livewire ridisegna solo
testata e pannello. Elenco e pannello si aprono e chiudono in Alpine, senza
giri sul server.
--}}
<div class="tm-editor"
x-data="{ elenco: window.matchMedia('(min-width: 1100px)').matches, pannello: true }"
@piantina-selezionata.window="if ($event.detail.mappaId) pannello = true">
@vite('resources/js/piantine.js') @vite('resources/js/piantine.js')
{{-- ── Barra strumenti ───────────────────────────────────────────────── --}} {{-- ── Testata ───────────────────────────────────────────────────────── --}}
<header class="tm-editor-barra"> <header class="tm-editor-barra">
<a href="{{ route('piantine.index') }}" class="tm-editor-torna"> <a href="{{ route('piantine.index') }}" class="tm-editor-torna">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
@@ -10,188 +30,228 @@
<span class="tm-editor-sep" aria-hidden="true"></span> <span class="tm-editor-sep" aria-hidden="true"></span>
<h1 class="tm-editor-titolo"> <x-btn variante="ghost" dimensione="sm" icona @click="elenco = ! elenco" ::aria-pressed="elenco"
{{ $mappa?->etichetta() ?? 'Nuova piantina' }} aria-label="Mostra o nascondi l'elenco delle piantine" title="Elenco delle piantine">
@if($geometriaInAttesa) <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
<span class="tm-tag tm-tag-attesa">disegno non salvato</span> </x-btn>
@elseif($mappa && ! $mappa->collegata())
<span class="tm-tag">scollegata</span> <div class="tm-editor-titolo">
<h1>{{ $mappa?->etichetta() ?? 'Editor delle piantine' }}</h1>
@if($mappa)
<span class="tm-tag {{ $tagStato[$stato] }}">{{ $mappa->statoEtichetta() }}</span>
<span class="tm-editor-salvato" aria-live="polite">
<span wire:loading.delay.shortest>Salvataggio…</span>
<span wire:loading.remove>
@if($salvatoAlle) Salvato alle {{ $salvatoAlle }} @endif
</span>
</span>
@endif @endif
</h1> </div>
<div class="tm-editor-azioni"> <div class="tm-editor-azioni">
<x-btn variante="secondary" dimensione="sm" wire:click="nuova"> <x-btn variante="secondary" dimensione="sm" wire:click="nuova">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova <span>Nuova piantina</span>
</x-btn>
<x-btn variante="secondary" dimensione="sm" wire:click="$toggle('pannelloAperto')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
Scheda
</x-btn> </x-btn>
@if($mappa) @if($mappa)
<x-btn variante="secondary" dimensione="sm" <x-btn variante="ghost" dimensione="sm" class="tm-solo-stretto" @click="pannello = ! pannello">
wire:click="generaImmagine" wire:loading.attr="disabled" wire:target="generaImmagine"> Scheda
<span wire:loading.remove wire:target="generaImmagine">Genera immagine</span>
<span wire:loading wire:target="generaImmagine">Rendering…</span>
</x-btn> </x-btn>
@if($mappa->collegata() && $mappa->image_path) <x-btn variante="primary" dimensione="sm" wire:click="pubblica"
<x-btn variante="secondary" dimensione="sm" :disabled="$motivo !== null || $stato === TerritorioMappa::STATO_PUBBLICATA"
:href="route('piantine.anteprima', $mappa)" target="_blank" rel="noopener"> :title="$motivo ?? ($stato === TerritorioMappa::STATO_PUBBLICATA ? 'Il PDF del territorio corrisponde già a questa piantina.' : 'Anteprima del PDF e pubblicazione')">
<svg viewBox="0 0 24 24" fill="none" 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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
Anteprima PDF <span>Pubblica</span>
</x-btn> </x-btn>
@endif
@endif @endif
<x-btn variante="primary" dimensione="sm" wire:click="salva"
wire:loading.attr="disabled" wire:target="salva">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Salva
</x-btn>
</div> </div>
</header> </header>
{{-- ── Mappa ─────────────────────────────────────────────────────────── --}} <div class="tm-editor-corpo" :class="{ 'con-elenco': elenco, 'con-pannello': pannello }">
<div class="tm-editor-mappa"> {{-- ── Elenco ────────────────────────────────────────────────────── --}}
<div wire:ignore id="piantine-map" class="tm-mappa"></div> <aside class="tm-editor-elenco" x-show="elenco" x-cloak aria-label="Elenco delle piantine" wire:ignore>
<div class="tm-editor-elenco-cerca">
<input type="search" id="piantine-cerca" placeholder="Cerca per numero o nome" autocomplete="off"
aria-label="Cerca una piantina">
</div>
<ul id="piantine-elenco" class="tm-editor-elenco-righe"></ul>
</aside>
@unless($areaCaricata) {{-- ── Mappa ─────────────────────────────────────────────────────── --}}
<p class="tm-editor-nota"> <div class="tm-editor-mappa">
Nessuna area città caricata: la mappa parte senza perimetro di riferimento. <div wire:ignore id="piantine-map" class="tm-mappa"></div>
Il KML si carica dalla <a href="{{ route('piantine.index') }}">pagina di gestione</a>. <div wire:ignore id="piantine-strumenti" class="tm-strumenti" role="toolbar" aria-label="Strumenti di disegno"></div>
</p>
@endunless
{{-- Pannello: scorre sopra la mappa, non la restringe. --}} @unless($areaCaricata)
<aside class="tm-editor-pannello" @if(! $pannelloAperto) hidden @endif <p class="tm-editor-nota">
aria-label="Scheda della piantina"> Nessuna area città caricata: la mappa parte senza perimetro di riferimento.
Il KML si carica dalla <a href="{{ route('piantine.index') }}">pagina di gestione</a>.
</p>
@endunless
</div>
{{-- ── Pannello della piantina ───────────────────────────────────── --}}
<aside class="tm-editor-pannello" x-show="pannello" x-cloak aria-label="La piantina aperta">
<div class="tm-editor-pannello-testata"> <div class="tm-editor-pannello-testata">
<h2>Scheda</h2> <h2>{{ $mappa ? 'La piantina' : 'Come si comincia' }}</h2>
<button type="button" wire:click="chiudiPannello" class="tm-editor-chiudi" aria-label="Chiudi il pannello">&times;</button> <button type="button" @click="pannello = false" class="tm-editor-chiudi" aria-label="Chiudi il pannello">&times;</button>
</div> </div>
<div class="tm-editor-pannello-corpo"> <div class="tm-editor-pannello-corpo">
@if($geometriaInAttesa) @if(! $mappa)
<div class="tm-nota tm-nota-attesa"> <ol class="tm-passi">
<p>C'è un poligono disegnato non ancora salvato.</p> <li>Apri una piantina dall'elenco o cliccandola sulla mappa,</li>
<x-btn variante="ghost" dimensione="sm" wire:click="scartaDisegno">Scarta il disegno</x-btn> <li>oppure premi <strong>Nuova piantina</strong> e traccia il confine punto per punto.</li>
</div> <li>Collega la piantina a un territorio e controlla i dati.</li>
@elseif(! $mappa) <li>Premi <strong>Pubblica</strong>: vedi il PDF e lo confermi.</li>
</ol>
<p class="tm-editor-aiuto"> <p class="tm-editor-aiuto">
Usa lo strumento poligono in alto a sinistra sulla mappa per tracciare il confine. Tutto quello che fai qui si salva da solo come bozza. Il PDF del territorio cambia
La scheda del territorio si compila qui sotto, anche dopo. solo quando pubblichi.
</p> </p>
@endif @else
{{-- ── Pubblicazione ─────────────────────────────────── --}}
{{-- ── La piantina ───────────────────────────────────────── --}} <section class="tm-gruppo tm-stato-piantina">
<fieldset class="tm-gruppo"> @if($stato === TerritorioMappa::STATO_BOZZA)
<legend>La piantina</legend> <p>Mai pubblicata: il territorio non ha ancora il PDF di questa piantina.</p>
@elseif($stato === TerritorioMappa::STATO_MODIFICATA)
<div class="tm-campo"> <p>
<label for="nome">Nome interno</label> Il PDF in uso è quello pubblicato il {{ $mappa->pubblicata_at->format('d/m/Y') }}.
<input type="text" id="nome" wire:model="nome" autocomplete="off" Le modifiche fatte da allora non sono ancora nel PDF.
placeholder="Es. Zona porto — lato nord">
<p class="tm-campo-aiuto">
Serve solo a ritrovarla in elenco finché non è collegata a un territorio.
Non viene stampato.
</p>
</div>
</fieldset>
{{-- ── Il territorio ─────────────────────────────────────── --}}
<fieldset class="tm-gruppo">
<legend>Il territorio</legend>
<div class="tm-campo">
<label for="territorioScelto">Collegamento</label>
<select id="territorioScelto" wire:model.live="territorioScelto">
<option value="">— nessuno, piantina scollegata —</option>
<option value="nuovo">+ Crea un territorio nuovo</option>
@foreach($territori as $territorio)
<option value="{{ $territorio->id }}">Territorio {{ $territorio->numero }}</option>
@endforeach
</select>
@if($territorioScelto === '')
<p class="tm-campo-aiuto">
Senza territorio la piantina si disegna e si renderizza lo stesso; per il PDF
sul modulo S-12 serve il collegamento.
</p> </p>
@endif
</div>
@if($territorioScelto !== '')
<div class="tm-campo">
<label for="numero">Numero</label>
<input type="text" id="numero" wire:model="numero" autocomplete="off"
maxlength="20" placeholder="Es. 42">
@error('numero') <p class="tm-campo-errore">{{ $message }}</p> @enderror
</div>
<div class="tm-campo">
<label for="zonaId">Zona <span class="tm-campo-nota">stampata come «Località» sulla cartolina</span></label>
<select id="zonaId" wire:model="zonaId">
<option value="">— nessuna —</option>
@foreach($zone as $zona)
<option value="{{ $zona->id }}">{{ $zona->nome }}</option>
@endforeach
</select>
<div class="tm-campo-riga tm-campo-crea">
<input type="text" wire:model="nuovaZona" autocomplete="off"
placeholder="Zona che non c'è ancora"
aria-label="Nome della nuova zona"
wire:keydown.enter.prevent="creaZona">
<x-btn variante="secondary" dimensione="sm" wire:click="creaZona">Crea</x-btn>
</div>
@error('nuovaZona') <p class="tm-campo-errore">{{ $message }}</p> @enderror
</div>
<div class="tm-campo">
<label for="tipologiaId">Tipologia</label>
<select id="tipologiaId" wire:model="tipologiaId">
<option value="">— nessuna —</option>
@foreach($tipologie as $tipologia)
<option value="{{ $tipologia->id }}">{{ $tipologia->nome }}</option>
@endforeach
</select>
<div class="tm-campo-riga tm-campo-crea">
<input type="text" wire:model="nuovaTipologia" autocomplete="off"
placeholder="Tipologia che non c'è ancora"
aria-label="Nome della nuova tipologia"
wire:keydown.enter.prevent="creaTipologia">
<x-btn variante="secondary" dimensione="sm" wire:click="creaTipologia">Crea</x-btn>
</div>
@error('nuovaTipologia') <p class="tm-campo-errore">{{ $message }}</p> @enderror
</div>
<div class="tm-campo">
<label for="confini">Confini</label>
<textarea id="confini" wire:model="confini" rows="6"
placeholder="Nord: …&#10;Est: …&#10;Sud: …&#10;Ovest: …"></textarea>
@error('confini') <p class="tm-campo-errore">{{ $message }}</p> @enderror
<p class="tm-campo-aiuto">
Vengono stampati sulla prima pagina della cartolina, nello spazio centrale
del modulo. Il corpo del testo si adatta da solo alla lunghezza.
</p>
</div>
@endif
</fieldset>
@if($mappa)
<div class="tm-editor-pannello-piede">
@if($mappa->image_path)
<a href="{{ asset('storage/' . $mappa->image_path) }}" target="_blank" rel="noopener"
class="tm-collegamento">
Immagine del {{ $mappa->rendered_at?->format('d/m/Y H:i') }}
</a>
@else @else
<span class="tm-editor-aiuto">Immagine non ancora generata.</span> <p>Il PDF del territorio corrisponde a questa piantina, pubblicata il {{ $mappa->pubblicata_at->format('d/m/Y') }}.</p>
@endif @endif
@if($motivo)
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">Per pubblicare: {{ lcfirst($motivo) }}</p>
@endif
<div class="tm-azioni-riga">
@if($mappa->collegata() && $mappa->territorio->pdf_path)
<a href="{{ $mappa->territorio->pdfUrl() }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
@endif
@if($mappa->bozzaScartabile())
<x-btn variante="ghost" dimensione="sm" wire:click="scartaModifiche"
wire:confirm="Riportare il confine a quello pubblicato? Il disegno attuale va perso.">
Ripristina il confine pubblicato
</x-btn>
@endif
</div>
</section>
{{-- ── Territorio ────────────────────────────────────── --}}
<fieldset class="tm-gruppo">
<legend>Territorio</legend>
<div class="tm-campo">
<label for="territorioScelto">Collegata a</label>
<select id="territorioScelto" wire:model.live="territorioScelto">
<option value="">— nessun territorio —</option>
<option value="nuovo">+ Crea un territorio nuovo</option>
@foreach($territori as $territorio)
<option value="{{ $territorio->id }}">
Territorio {{ $territorio->numero }}@if($territorio->trashed()) (nel cestino)@elseif(! $territorio->attivo) (disattivato)@endif
</option>
@endforeach
</select>
@error('territorioScelto') <p class="tm-campo-errore">{{ $message }}</p> @enderror
@if($mappa->territorioNelCestino())
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">
Il territorio è nel cestino: il collegamento resta, ma per pubblicare va ripristinato.
</p>
@endif
</div>
@if($territorioScelto !== '')
@if($territorioScelto === 'nuovo')
<p class="tm-campo-aiuto">Compila i dati e premi <strong>Crea territorio</strong>.</p>
@else
<p class="tm-campo-aiuto">
Questi sono i dati del territorio in tutto il gestionale: le modifiche valgono
subito. Nel PDF entrano alla prossima pubblicazione.
</p>
@endif
@php($modello = $territorioScelto === 'nuovo' ? 'wire:model' : 'wire:model.blur')
<div class="tm-campo">
<label for="numero">Numero</label>
<input type="text" id="numero" {{ $modello }}="numero" autocomplete="off" maxlength="20" placeholder="Es. 42">
@error('numero') <p class="tm-campo-errore">{{ $message }}</p> @enderror
</div>
<div class="tm-campo" x-data="{ crea: false }">
<label for="zonaId">Zona <span class="tm-campo-nota">stampata come «Località»</span></label>
<select id="zonaId" {{ $territorioScelto === 'nuovo' ? 'wire:model' : 'wire:model.live' }}="zonaId">
<option value="">— nessuna —</option>
@foreach($zone as $zona)
<option value="{{ $zona->id }}">{{ $zona->nome }}</option>
@endforeach
</select>
@error('zonaId') <p class="tm-campo-errore">{{ $message }}</p> @enderror
<button type="button" class="tm-collegamento tm-collegamento-piccolo" @click="crea = ! crea" x-show="! crea">+ Zona che non c'è ancora</button>
<div class="tm-campo-riga tm-campo-crea" x-show="crea" x-cloak>
<input type="text" wire:model="nuovaZona" autocomplete="off" placeholder="Nome della nuova zona"
aria-label="Nome della nuova zona" wire:keydown.enter.prevent="creaZona">
<x-btn variante="secondary" dimensione="sm" wire:click="creaZona">Crea</x-btn>
</div>
@error('nuovaZona') <p class="tm-campo-errore">{{ $message }}</p> @enderror
</div>
<div class="tm-campo" x-data="{ crea: false }">
<label for="tipologiaId">Tipologia</label>
<select id="tipologiaId" {{ $territorioScelto === 'nuovo' ? 'wire:model' : 'wire:model.live' }}="tipologiaId">
<option value="">— nessuna —</option>
@foreach($tipologie as $tipologia)
<option value="{{ $tipologia->id }}">{{ $tipologia->nome }}</option>
@endforeach
</select>
@error('tipologiaId') <p class="tm-campo-errore">{{ $message }}</p> @enderror
<button type="button" class="tm-collegamento tm-collegamento-piccolo" @click="crea = ! crea" x-show="! crea">+ Tipologia che non c'è ancora</button>
<div class="tm-campo-riga tm-campo-crea" x-show="crea" x-cloak>
<input type="text" wire:model="nuovaTipologia" autocomplete="off" placeholder="Nome della nuova tipologia"
aria-label="Nome della nuova tipologia" wire:keydown.enter.prevent="creaTipologia">
<x-btn variante="secondary" dimensione="sm" wire:click="creaTipologia">Crea</x-btn>
</div>
@error('nuovaTipologia') <p class="tm-campo-errore">{{ $message }}</p> @enderror
</div>
<div class="tm-campo">
<label for="confini">Confini</label>
<textarea id="confini" {{ $modello }}="confini" rows="6"
placeholder="Nord: …&#10;Est: …&#10;Sud: …&#10;Ovest: …"></textarea>
@error('confini') <p class="tm-campo-errore">{{ $message }}</p> @enderror
<p class="tm-campo-aiuto">Stampati sulla prima pagina del PDF; il testo si adatta da solo allo spazio.</p>
</div>
@if($territorioScelto === 'nuovo')
<x-btn variante="primary" dimensione="sm" wire:click="creaTerritorio"
wire:loading.attr="disabled" wire:target="creaTerritorio">
Crea territorio
</x-btn>
@endif
@endif
</fieldset>
{{-- ── Nome interno, solo finché è scollegata ───────── --}}
@if(! $mappa->territorio)
<fieldset class="tm-gruppo">
<legend>Nome interno</legend>
<div class="tm-campo">
<label for="nome" class="tm-solo-lettori">Nome interno</label>
<input type="text" id="nome" wire:model.blur="nome" autocomplete="off" maxlength="120"
placeholder="Es. Zona porto — lato nord">
<p class="tm-campo-aiuto">Serve a ritrovarla in elenco finché non ha un territorio. Non viene stampato.</p>
</div>
</fieldset>
@endif
<div class="tm-editor-pannello-piede">
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="elimina" <x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="elimina"
wire:confirm="Eliminare {{ $mappa->etichetta() }}? Poligono e immagine vengono rimossi; il territorio e il suo PDF restano."> wire:confirm="Eliminare {{ $mappa->etichetta() }}? Il disegno va perso; il territorio e il suo PDF restano com'erano.">
Elimina piantina Elimina piantina
</x-btn> </x-btn>
</div> </div>
@@ -200,5 +260,5 @@
</aside> </aside>
</div> </div>
<script type="application/json" id="piantine-dati">@json($datiMappaIniziali)</script> <livewire:piantine.pubblicazione />
</div> </div>

View File

@@ -1,10 +1,21 @@
@php
use App\Models\TerritorioMappa;
$tagStato = [
TerritorioMappa::STATO_BOZZA => 'tm-tag-attesa',
TerritorioMappa::STATO_MODIFICATA => 'tm-tag-attesa',
TerritorioMappa::STATO_PUBBLICATA => 'tm-tag-ok',
];
@endphp
<div> <div>
<header class="tm-pagina-testata"> <header class="tm-pagina-testata">
<div> <div>
<h1 class="tm-pagina-titolo">Piantine dei territori</h1> <h1 class="tm-pagina-titolo">Piantine dei territori</h1>
<p class="tm-pagina-nota"> <p class="tm-pagina-nota">
Il disegno dei confini si fa nell'editor a schermo intero. Qui si tiene l'area della A che punto è ogni piantina. Il disegno e i dati del territorio si modificano nell'editor,
città, si generano immagini e PDF sul modulo S-12, e si ripescano le versioni sostituite. dove le modifiche si salvano da sole come bozza; il PDF del territorio cambia solo quando
si pubblica, dopo averne visto l'anteprima.
</p> </p>
</div> </div>
<x-btn variante="primary" :href="route('piantine.editor')"> <x-btn variante="primary" :href="route('piantine.editor')">
@@ -13,8 +24,101 @@
</x-btn> </x-btn>
</header> </header>
{{-- ── Piantine ──────────────────────────────────────────────────────── --}}
<section class="tm-card">
<div class="tm-card-testata">
<h2 class="tm-card-titolo">Piantine <span class="tm-conteggio">{{ $totale }}</span></h2>
</div>
@if($totale === 0)
<div class="tm-vuoto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
<p>Nessuna piantina disegnata.</p>
<x-btn variante="primary" dimensione="sm" :href="route('piantine.editor')">Disegna la prima</x-btn>
</div>
@else
<div class="tm-filtri">
<input type="search" wire:model.live.debounce.300ms="cerca" class="tm-filtri-cerca"
placeholder="Cerca numero, nome o zona…" aria-label="Cerca una piantina">
<div class="tm-filtri-schede" role="group" aria-label="Filtra per stato">
@foreach(\App\Livewire\Piantine\PiantineManager::FILTRI as $chiave => $etichetta)
<button type="button" wire:click="$set('filtro', '{{ $chiave }}')"
class="tm-filtri-scheda {{ $filtro === $chiave ? 'tm-filtri-scheda-attiva' : '' }}"
aria-pressed="{{ $filtro === $chiave ? 'true' : 'false' }}">
{{ $etichetta }} <span class="tm-conteggio">{{ $conteggi[$chiave] }}</span>
</button>
@endforeach
</div>
</div>
@if($elenco->isEmpty())
<p class="tm-campo-aiuto">Nessuna piantina corrisponde alla ricerca.</p>
@else
<ul class="tm-elenco">
@foreach($elenco as $piantina)
@php
$stato = $piantina->stato();
$motivo = $piantina->motivoNonPubblicabile();
@endphp
<li class="tm-elenco-riga" wire:key="piantina-{{ $piantina->id }}">
<div class="tm-elenco-intestazione">
<a href="{{ route('piantine.editor', $piantina->id) }}" class="tm-elenco-nome">
{{ $piantina->etichetta() }}
</a>
<span class="tm-tag {{ $tagStato[$stato] }}">{{ $piantina->statoEtichetta() }}</span>
@if($piantina->territorioNelCestino())
<span class="tm-tag tm-tag-attesa">Territorio nel cestino</span>
@elseif(! $piantina->collegata())
<span class="tm-tag">Scollegata</span>
@elseif(! $piantina->territorio->zona_id)
<span class="tm-tag tm-tag-attesa" title="La zona viene stampata come «Località»: senza, l'intestazione della cartolina resta vuota">Zona mancante</span>
@endif
<span class="tm-elenco-stato">
@if($piantina->territorio?->zona)
{{ $piantina->territorio->zona->nome }} ·
@endif
{{ $piantina->pubblicata_at
? 'pubblicata il ' . $piantina->pubblicata_at->format('d/m/Y H:i')
: 'mai pubblicata' }}
</span>
</div>
<div class="tm-elenco-azioni">
<x-btn variante="secondary" dimensione="sm" :href="route('piantine.editor', $piantina->id)">
Apri
</x-btn>
@if($stato !== TerritorioMappa::STATO_PUBBLICATA)
<x-btn variante="primary" tono="tenue" dimensione="sm"
wire:click="pubblica({{ $piantina->id }})"
:disabled="$motivo !== null" :title="$motivo">
Pubblica
</x-btn>
@endif
@if($piantina->collegata() && $piantina->territorio->pdf_path)
<a href="{{ $piantina->territorio->pdfUrl() }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm"
wire:click="elimina({{ $piantina->id }})"
wire:confirm="Eliminare {{ $piantina->etichetta() }}? Il disegno viene rimosso; il territorio e il suo PDF restano com'erano.">
Elimina
</x-btn>
</div>
@if($motivo && $stato !== TerritorioMappa::STATO_PUBBLICATA)
<p class="tm-campo-aiuto">{{ $motivo }}</p>
@endif
</li>
@endforeach
</ul>
@endif
@endif
</section>
{{-- ── Area della città ──────────────────────────────────────────────── --}} {{-- ── Area della città ──────────────────────────────────────────────── --}}
<section class="tm-card tm-card-primaria {{ $config['kml_nome'] ? '' : 'tm-card-manca' }}"> <section class="tm-card {{ $config['kml_nome'] ? '' : 'tm-card-manca' }}">
<div class="tm-card-testata tm-card-testata-icona"> <div class="tm-card-testata tm-card-testata-icona">
<span class="tm-card-icona" aria-hidden="true"> <span class="tm-card-icona" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
@@ -29,8 +133,7 @@
@endif @endif
</h2> </h2>
<p class="tm-card-nota"> <p class="tm-card-nota">
Il perimetro di riferimento su cui si disegnano le piantine: è il primo passo, Il perimetro di riferimento che l'editor mostra sotto le piantine.
senza l'editor parte su una mappa senza contorni.
</p> </p>
</div> </div>
</div> </div>
@@ -59,110 +162,15 @@
</form> </form>
</section> </section>
{{-- ── Piantine ──────────────────────────────────────────────────────── --}}
<section class="tm-card">
<div class="tm-card-testata">
<h2 class="tm-card-titolo">Piantine <span class="tm-conteggio">{{ $piantine->count() }}</span></h2>
<p class="tm-card-nota">
Una piantina può vivere scollegata: per il PDF sul modulo S-12 serve invece un territorio.
</p>
</div>
@if($piantine->isEmpty())
<div class="tm-vuoto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
<p>Nessuna piantina disegnata.</p>
<x-btn variante="primary" dimensione="sm" :href="route('piantine.editor')">Disegna la prima</x-btn>
</div>
@else
<ul class="tm-elenco">
@foreach($piantine as $piantina)
<li class="tm-elenco-riga" wire:key="piantina-{{ $piantina->id }}">
<div class="tm-elenco-intestazione">
<a href="{{ route('piantine.editor', $piantina->id) }}" class="tm-elenco-nome">
{{ $piantina->etichetta() }}
</a>
@unless($piantina->collegata())
<span class="tm-tag">scollegata</span>
@endunless
@if($piantina->collegata() && ! $piantina->territorio->zona_id)
<span class="tm-tag tm-tag-attesa" title="La zona viene stampata come «Località»: senza, l'intestazione della cartolina resta vuota">Zona mancante</span>
@endif
<span class="tm-elenco-stato {{ $piantina->image_path ? 'tm-elenco-stato-ok' : '' }}">
{{ $piantina->image_path
? 'immagine del ' . $piantina->rendered_at?->format('d/m/Y H:i')
: 'immagine da generare' }}
</span>
</div>
<div class="tm-elenco-azioni">
<x-btn variante="secondary" tono="tenue" dimensione="sm"
wire:click="generaImmagine({{ $piantina->id }})"
wire:loading.attr="disabled" wire:target="generaImmagine({{ $piantina->id }})">
<span wire:loading.remove wire:target="generaImmagine({{ $piantina->id }})">Genera immagine</span>
<span wire:loading wire:target="generaImmagine({{ $piantina->id }})">Rendering…</span>
</x-btn>
@if($piantina->image_path)
<a href="{{ asset('storage/' . $piantina->image_path) }}" target="_blank" rel="noopener" class="tm-collegamento">Immagine</a>
@endif
@if($piantina->collegata())
@if($piantina->image_path)
<a href="{{ route('piantine.anteprima', $piantina) }}" target="_blank" rel="noopener" class="tm-collegamento">Anteprima PDF</a>
<x-btn variante="primary" tono="tenue" dimensione="sm"
wire:click="generaPdf({{ $piantina->id }})"
wire:loading.attr="disabled" wire:target="generaPdf({{ $piantina->id }})"
wire:confirm="Sostituire il PDF del territorio {{ $piantina->territorio->numero }}? Quello attuale finisce nello storico versioni.">
<span wire:loading.remove wire:target="generaPdf({{ $piantina->id }})">Genera PDF</span>
<span wire:loading wire:target="generaPdf({{ $piantina->id }})">Composizione…</span>
</x-btn>
@endif
<x-btn variante="ghost" dimensione="sm" wire:click="scollega({{ $piantina->id }})"
wire:confirm="Scollegare questa piantina dal territorio {{ $piantina->territorio->numero }}? Il PDF del territorio resta com'è.">
Scollega
</x-btn>
@else
<x-btn variante="secondary" dimensione="sm" wire:click="apriCollegamento({{ $piantina->id }})">
Collega a un territorio
</x-btn>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm"
wire:click="eliminaPiantina({{ $piantina->id }})"
wire:confirm="Eliminare {{ $piantina->etichetta() }}? Poligono e immagine vengono rimossi; il PDF del territorio resta com'è.">
Elimina
</x-btn>
</div>
@if($collegamentoAperto === $piantina->id)
<div class="tm-elenco-pannello">
<div class="tm-campo-riga">
<select wire:model="territorioDaCollegare" aria-label="Territorio a cui collegare la piantina">
<option value="">— scegli il territorio —</option>
@foreach($territori as $territorio)
<option value="{{ $territorio->id }}">{{ $territorio->numero }}</option>
@endforeach
</select>
<x-btn variante="primary" dimensione="sm" wire:click="collega({{ $piantina->id }})">Collega</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiCollegamento">Annulla</x-btn>
</div>
<p class="tm-campo-aiuto">
Il territorio da creare non c'è? Si crea dall'editor, senza perdere il disegno.
</p>
</div>
@endif
</li>
@endforeach
</ul>
@endif
</section>
{{-- ── Storico versioni PDF ──────────────────────────────────────────── --}} {{-- ── Storico versioni PDF ──────────────────────────────────────────── --}}
<section class="tm-card"> <section class="tm-card">
<div class="tm-card-testata"> <div class="tm-card-testata">
<h2 class="tm-card-titolo">Storico versioni PDF</h2> <h2 class="tm-card-titolo">Storico versioni PDF <span class="tm-conteggio">{{ $versioni->total() }}</span></h2>
<p class="tm-card-nota">I PDF sostituiti da una piantina. Il ripristino è sempre reversibile.</p> <p class="tm-card-nota">
I PDF dei territori sostituiti: da una pubblicazione, da un caricamento (dalla scheda o
dall'import da cartella) o da un ripristino. Ripristinare una versione
rimette in uso quel PDF: la piantina del territorio torna "da pubblicare".
</p>
</div> </div>
@if($versioni->isEmpty()) @if($versioni->isEmpty())
@@ -188,7 +196,7 @@
<td data-label="Sostituito il">{{ $versione->sostituito_il?->format('d/m/Y H:i') }}</td> <td data-label="Sostituito il">{{ $versione->sostituito_il?->format('d/m/Y H:i') }}</td>
<td data-label="Origine">{{ $versione->origine }}</td> <td data-label="Origine">{{ $versione->origine }}</td>
<td class="cell-actions"> <td class="cell-actions">
<a href="{{ asset('storage/' . $versione->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">Apri</a> <a href="{{ route('territori.file.versione', $versione) }}" target="_blank" rel="noopener" class="tm-collegamento">Apri</a>
<x-btn variante="secondary" tono="tenue" dimensione="sm" <x-btn variante="secondary" tono="tenue" dimensione="sm"
wire:click="ripristinaVersione({{ $versione->id }})" wire:click="ripristinaVersione({{ $versione->id }})"
wire:confirm="Ripristinare questa versione per il territorio {{ $versione->territorio?->numero }}? Il PDF attuale finisce a sua volta nello storico."> wire:confirm="Ripristinare questa versione per il territorio {{ $versione->territorio?->numero }}? Il PDF attuale finisce a sua volta nello storico.">
@@ -205,6 +213,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="mt-4">{{ $versioni->links() }}</div>
@endif @endif
</section> </section>
<livewire:piantine.pubblicazione />
</div> </div>

View File

@@ -0,0 +1,54 @@
<div>
@if($mappaId)
<div class="tm-modale-sfondo" wire:keydown.escape.window="chiudi">
<div class="tm-modale tm-modale-larga" role="dialog" aria-modal="true" aria-labelledby="pubblicazione-titolo"
@if($fase === \App\Services\Piantine\PubblicazionePiantina::IN_CORSO) wire:poll.2s="aggiorna" @endif>
<header class="tm-modale-testata">
<h2 id="pubblicazione-titolo">Pubblica {{ $etichetta }}</h2>
<button type="button" wire:click="chiudi" class="tm-editor-chiudi" aria-label="Chiudi senza pubblicare">&times;</button>
</header>
<div class="tm-modale-corpo">
@if($fase === \App\Services\Piantine\PubblicazionePiantina::IN_CORSO)
<div class="tm-attesa">
<span class="tm-attesa-giro" aria-hidden="true"></span>
<div>
<p class="tm-attesa-titolo">Preparazione dell'anteprima…</p>
<p class="tm-editor-aiuto">
Immagine ad alta definizione e PDF sul modulo S-12. La prima volta per un
territorio può richiedere fino a un paio di minuti. Niente è ancora cambiato.
</p>
</div>
</div>
@elseif($fase === \App\Services\Piantine\PubblicazionePiantina::PRONTA)
<p class="tm-editor-aiuto">
Controlla il PDF. Confermando diventa il PDF del territorio, quello che arriva con i
link delle assegnazioni; il precedente resta nello storico e si può ripristinare.
</p>
<iframe src="{{ route('piantine.anteprima', $token) }}" class="tm-anteprima-pdf"
title="Anteprima del PDF da pubblicare"></iframe>
<a href="{{ route('piantine.anteprima', $token) }}" target="_blank" rel="noopener" class="tm-collegamento">
Apri l'anteprima in una nuova scheda
</a>
@else
<div class="tm-nota tm-nota-errore">
<p>{{ $messaggio ?: 'Qualcosa non ha funzionato.' }}</p>
</div>
@endif
</div>
<footer class="tm-modale-piede">
@if($fase === \App\Services\Piantine\PubblicazionePiantina::PRONTA)
<x-btn variante="primary" wire:click="conferma" wire:loading.attr="disabled" wire:target="conferma">
<span wire:loading.remove wire:target="conferma">Conferma e pubblica</span>
<span wire:loading wire:target="conferma">Pubblicazione…</span>
</x-btn>
@elseif($fase === \App\Services\Piantine\PubblicazionePiantina::ERRORE)
<x-btn variante="secondary" wire:click="riprova">Riprova</x-btn>
@endif
<x-btn variante="ghost" wire:click="chiudi">Annulla</x-btn>
</footer>
</div>
</div>
@endif
</div>

View File

@@ -21,6 +21,22 @@
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div>
<label for="telefono" class="block text-sm font-medium text-gray-700">Telefono</label>
<input wire:model="telefono" type="tel" id="telefono" autocomplete="off" placeholder="333 1234567"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<p class="text-xs text-gray-500 mt-1">Facoltativo. Serve a mandare il link del territorio su WhatsApp. Senza prefisso si considera un numero italiano.</p>
@error('telefono') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input wire:model="email" type="email" id="email" autocomplete="off"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<p class="text-xs text-gray-500 mt-1">Facoltativa.</p>
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"> <input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="attivo" class="text-sm text-gray-700">Attivo</label> <label for="attivo" class="text-sm text-gray-700">Attivo</label>

View File

@@ -62,6 +62,7 @@
<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 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"> <td class="px-4 py-3 text-sm text-right cell-actions">
<div class="flex flex-wrap items-center justify-end gap-1"> <div class="flex flex-wrap items-center justify-end gap-1">
<x-whatsapp :proclamatore="$proclamatore" compatto />
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona <x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('proclamatori.show', $proclamatore)" :href="route('proclamatori.show', $proclamatore)"
title="Dettaglio" aria-label="Dettaglio del proclamatore"> title="Dettaglio" aria-label="Dettaglio del proclamatore">

View File

@@ -6,6 +6,14 @@
Torna alla lista Torna alla lista
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
@if($proclamatore->telefono || $proclamatore->email)
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm">
<x-whatsapp :proclamatore="$proclamatore" />
@if($proclamatore->email)
<a href="mailto:{{ $proclamatore->email }}" class="text-indigo-700 hover:underline break-all">{{ $proclamatore->email }}</a>
@endif
</div>
@endif
</div> </div>
@can('settings.manage') @can('settings.manage')
<x-btn variante="secondary" :href="route('proclamatori.edit', $proclamatore)"> <x-btn variante="secondary" :href="route('proclamatori.edit', $proclamatore)">

View File

@@ -58,12 +58,6 @@
<h3 class="text-lg font-semibold text-gray-900 mb-1">Cambia password</h3> <h3 class="text-lg font-semibold text-gray-900 mb-1">Cambia password</h3>
<p class="text-sm text-gray-500 mb-4">Per motivi di sicurezza conferma la password attuale.</p> <p class="text-sm text-gray-500 mb-4">Per motivi di sicurezza conferma la password attuale.</p>
@if(session('password_success'))
<div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
{{ session('password_success') }}
</div>
@endif
<form wire:submit="updatePassword" class="space-y-4"> <form wire:submit="updatePassword" class="space-y-4">
<div> <div>
<label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label> <label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label>

View File

@@ -8,7 +8,7 @@
Nuova voce Nuova voce
</x-btn> </x-btn>
<x-btn variante="secondary" wire:click="openS13" <x-btn variante="secondary" wire:click="openS13"
title="Scarica il modulo S-13 compilato con le ultime 4 assegnazioni di un anno di servizio"> title="Scarica il modulo S-13 compilato per 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> <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 Modulo S-13
</x-btn> </x-btn>
@@ -77,7 +77,7 @@
<td data-label="Territorio" class="px-3 py-2"> <td data-label="Territorio" class="px-3 py-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if($a->territorio?->thumbnail_path) @if($a->territorio?->thumbnail_path)
<img src="{{ asset('storage/' . $a->territorio->thumbnail_path) }}" <img src="{{ $a->territorio->miniaturaUrl() }}"
alt="Thumbnail territorio {{ $a->territorio?->numero }}" alt="Thumbnail territorio {{ $a->territorio?->numero }}"
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none"> class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none">
@endif @endif
@@ -210,18 +210,8 @@
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror @error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div> </div>
{{-- Anno teocratico --}} {{-- L'anno di servizio non si sceglie: lo ricava il salvataggio
<div> dalla data di assegnazione. --}}
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Anno teocratico *</label>
<select wire:model="form_anno_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
<option value="">-- seleziona --</option>
@foreach($anni as $anno)
<option value="{{ $anno->id }}">{{ $anno->label }}</option>
@endforeach
</select>
@error('form_anno_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div>
{{-- Campagna --}} {{-- Campagna --}}
<div> <div>
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Campagna</label> <label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Campagna</label>

View File

@@ -0,0 +1,105 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Server di posta</h1>
<p class="text-sm text-gray-500 mt-1 max-w-2xl">
Da qui partono le email per confermare l'indirizzo degli utenti: finché non lo confermano, il loro
account non funziona. Serve un account di posta che permetta l'invio via SMTP.
</p>
</div>
@if($nonConfermato)
<div class="mb-6 max-w-2xl rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Il tuo indirizzo non è ancora confermato: il resto dell'applicazione è bloccato finché non lo fai.
Configura qui la posta, poi <a href="{{ route('verification.notice') }}" class="font-semibold underline">richiedi l'email di conferma</a>.
</div>
@endif
<div class="mb-6 max-w-2xl rounded-lg border px-4 py-3 text-sm
{{ $funzionante ? 'border-green-200 bg-green-50 text-green-800' : 'border-red-200 bg-red-50 text-red-800' }}">
@if($configurataNellApp)
In uso il server configurato qui sotto.
@elseif($funzionante)
In uso il server indicato nelle impostazioni del server (.env). Compilando questa pagina lo sostituisci.
@else
<strong>Nessun server di posta:</strong> le email non partono e nessun utente può confermare il proprio indirizzo.
@endif
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-2xl">
<form wire:submit="salva" class="space-y-5">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="sm:col-span-2">
<label for="host" class="block text-sm font-medium text-gray-700">Server SMTP *</label>
<input wire:model="host" type="text" id="host" placeholder="smtp.gmail.com" autocomplete="off"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('host') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="porta" class="block text-sm font-medium text-gray-700">Porta</label>
<input wire:model="porta" type="number" id="porta" min="1" max="65535" placeholder="587"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('porta') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label for="cifratura" class="block text-sm font-medium text-gray-700">Cifratura</label>
<select wire:model="cifratura" id="cifratura"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@foreach($cifrature as $valore => $etichetta)
<option value="{{ $valore }}">{{ $etichetta }}</option>
@endforeach
</select>
@error('cifratura') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="utente" class="block text-sm font-medium text-gray-700">Utente</label>
<input wire:model="utente" type="text" id="utente" autocomplete="off"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('utente') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input wire:model="password" type="password" id="password" autocomplete="new-password"
placeholder="{{ $passwordSalvata ? 'Salvata — lascia vuoto per non cambiarla' : '' }}"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<p class="text-xs text-gray-500 mt-1">Conservata cifrata. Con Gmail serve una "password per le app".</p>
@error('password') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="mittente" class="block text-sm font-medium text-gray-700">Indirizzo del mittente *</label>
<input wire:model="mittente" type="email" id="mittente" placeholder="territori@esempio.it"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('mittente') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="nomeMittente" class="block text-sm font-medium text-gray-700">Nome del mittente</label>
<input wire:model="nomeMittente" type="text" id="nomeMittente" placeholder="Il nome della congregazione"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('nomeMittente') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
<x-btn variante="primary" type="submit" wire:loading.attr="disabled" wire:target="salva">Salva</x-btn>
<x-btn variante="secondary" wire:click="inviaProva" wire:loading.attr="disabled" wire:target="inviaProva"
:disabled="! $configurataNellApp">
<span wire:loading.remove wire:target="inviaProva">Invia email di prova</span>
<span wire:loading wire:target="inviaProva">Invio…</span>
</x-btn>
@if($configurataNellApp)
<x-btn variante="ghost" wire:click="rimuovi"
wire:confirm="Rimuovere il server configurato qui? Varranno di nuovo le impostazioni del server (.env).">
Rimuovi
</x-btn>
@endif
</div>
<p class="text-xs text-gray-500">L'email di prova arriva al tuo indirizzo, {{ auth()->user()->email }}.</p>
</form>
</div>
</div>

View File

@@ -13,13 +13,15 @@
<p class="text-xs text-gray-500 mt-2">Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.</p> <p class="text-xs text-gray-500 mt-2">Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.</p>
</div> </div>
<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">Server di posta</p>
<div class="flex flex-wrap gap-2">
<x-btn variante="secondary" :href="route('posta.edit')">Configura la posta</x-btn>
</div>
<p class="text-xs text-gray-500 mt-2">Serve alle email con cui gli utenti confermano il loro indirizzo.</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
@if (session()->has('success'))
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 3000)" x-transition.duration.500ms
class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-700 border border-green-200">
{{ session('success') }}
</div>
@endif
<form wire:submit="save" class="space-y-5"> <form wire:submit="save" class="space-y-5">
<div> <div>

View File

@@ -1,15 +1,13 @@
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900">Utenti</h1> <h1 class="text-2xl font-bold text-gray-900">Utenti</h1>
<p class="text-sm text-gray-500 mt-1">Crea utenti e assegna un ruolo applicativo.</p> <p class="text-sm text-gray-500 mt-1">
Crea utenti e assegna un ruolo applicativo. Ogni nuovo utente riceve un'email per confermare il suo
indirizzo: finché non lo conferma, l'account non funziona.
<a href="{{ route('posta.edit') }}" class="text-indigo-700 underline">Server di posta</a>
</p>
</div> </div>
@if (session()->has('success'))
<div class="rounded-lg bg-green-50 p-3 text-sm text-green-700 border border-green-200">{{ session('success') }}</div>
@endif
@if (session()->has('error'))
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">{{ session('error') }}</div>
@endif
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Nuovo utente</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">Nuovo utente</h2>
@@ -112,7 +110,15 @@
</td> </td>
@else @else
<td data-label="Nome" class="px-4 py-2 text-gray-900">{{ $user->name }}</td> <td data-label="Nome" class="px-4 py-2 text-gray-900">{{ $user->name }}</td>
<td data-label="Email" class="px-4 py-2 text-gray-700">{{ $user->email }}</td> <td data-label="Email" class="px-4 py-2 text-gray-700">
{{ $user->email }}
@if($user->hasVerifiedEmail())
<span class="ml-1 inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">confermata</span>
@else
<span class="ml-1 inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
title="L'account non funziona finché la persona non apre il link nell'email">da confermare</span>
@endif
</td>
<td data-label="Ruoli" class="px-4 py-2"> <td data-label="Ruoli" class="px-4 py-2">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
@forelse($user->roles as $role) @forelse($user->roles as $role)
@@ -125,6 +131,12 @@
<td class="px-4 py-2 cell-actions"> <td class="px-4 py-2 cell-actions">
<div class="flex gap-2"> <div class="flex gap-2">
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn> <x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
@unless($user->hasVerifiedEmail())
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="reinviaConferma({{ $user->id }})"
wire:loading.attr="disabled" wire:target="reinviaConferma({{ $user->id }})">
Reinvia conferma
</x-btn>
@endunless
{{-- wire:confirm come nel resto dell'app, al posto di un onclick {{-- wire:confirm come nel resto dell'app, al posto di un onclick
che fermava la propagazione a mano. --}} che fermava la propagazione a mano. --}}
<x-btn variante="danger" tono="tenue" dimensione="sm" <x-btn variante="danger" tono="tenue" dimensione="sm"

View File

@@ -61,15 +61,124 @@
zipInCorso: false, zipInCorso: false,
zipPercento: 0, zipPercento: 0,
{{-- La cartella viaggia a lotti: vedi XmlExchange::getFilePerLottoProperty().
Il tetto in byte tiene ogni richiesta lontana dai 100 MB
che Cloudflare accetta per un singolo caricamento. --}}
fileCartella: [],
cartellaInCorso: false,
caricatiCartella: 0,
filePerLotto: {{ $this->filePerLotto }},
byteMassimiLotto: 40 * 1000 * 1000,
log(riga, livello) { log(riga, livello) {
this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'import-pdf' }); this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'import-pdf' });
}, },
/*
* Stessa regola di TerritorioPdfImportDispatcher::daIgnorare():
* Thumbs.db, desktop.ini, .DS_Store e gli altri file di sistema
* o nascosti non partono nemmeno. Il server li scarta comunque,
* ma caricarli sarebbe solo traffico.
*/
daIgnorare(file) {
const segmenti = (file.webkitRelativePath || file.name).split('/').filter((s) => s !== '');
if (segmenti.some((s) => s.startsWith('.') || s.toUpperCase() === '__MACOSX')) {
return true;
}
const nome = segmenti[segmenti.length - 1] || '';
return nome.startsWith('~$') || !nome.toLowerCase().endsWith('.pdf');
},
cartellaScelta(event) { cartellaScelta(event) {
this.fileScelti = event.target.files.length; const tutti = Array.from(event.target.files);
if (this.fileScelti > 0) { this.fileCartella = tutti.filter((file) => !this.daIgnorare(file));
this.log(`Cartella scelta: ${this.fileScelti} file.`); this.fileScelti = this.fileCartella.length;
this.log(`In attesa del caricamento temporaneo di Livewire...`, 'attesa'); this.caricatiCartella = 0;
const ignorati = tutti.length - this.fileCartella.length;
if (tutti.length > 0) {
this.log(`Cartella scelta: ${tutti.length} file, ${this.fileScelti} PDF da importare`
+ (ignorati > 0 ? ` (${ignorati} ignorati: file di sistema, nascosti o non PDF).` : `.`));
}
},
lotti() {
const lotti = [];
let lotto = [];
let byte = 0;
for (const file of this.fileCartella) {
if (lotto.length > 0 && (lotto.length >= this.filePerLotto || byte + file.size > this.byteMassimiLotto)) {
lotti.push(lotto);
lotto = [];
byte = 0;
}
lotto.push(file);
byte += file.size;
}
if (lotto.length > 0) {
lotti.push(lotto);
}
return lotti;
},
/*
* Un lotto alla volta, aggiunto (append) a quelli gia' arrivati.
* La guardia esiste perche' Livewire non ascolta gli errori di
* rete del caricamento: una connessione caduta lascerebbe la
* promessa appesa per sempre, con la pagina muta.
*/
caricaLotto(lotto) {
return new Promise((ok, ko) => {
let ultimoSegno = Date.now();
const guardia = setInterval(() => {
if (Date.now() - ultimoSegno > 120000) {
clearInterval(guardia);
this.$wire.cancelUpload('pdfFolder');
ko(new Error(`nessun avanzamento da due minuti`));
}
}, 5000);
this.$wire.uploadMultiple('pdfFolder', lotto,
() => { clearInterval(guardia); ok(); },
() => { clearInterval(guardia); ko(new Error(`il server ha rifiutato il lotto`)); },
() => { ultimoSegno = Date.now(); },
() => { clearInterval(guardia); ko(new Error(`caricamento annullato`)); },
true);
});
},
async inviaCartella() {
if (this.fileCartella.length === 0 || this.cartellaInCorso) {
return;
}
this.$dispatch('console-apri');
if (!(await this.$wire.cartellaPuoPartire())) {
this.log(`Import non avviato: c'è già un'operazione in corso.`, 'errore');
return;
}
this.cartellaInCorso = true;
this.caricatiCartella = 0;
try {
await this.$wire.svuotaCartella();
const lotti = this.lotti();
this.log(`Caricamento di ${this.fileCartella.length} PDF in ${lotti.length} lotti...`);
for (let i = 0; i < lotti.length; i++) {
await this.caricaLotto(lotti[i]);
this.caricatiCartella += lotti[i].length;
this.log(`Lotto ${i + 1}/${lotti.length} caricato: ${this.caricatiCartella}/${this.fileCartella.length} file.`);
}
this.log(`Cartella caricata. Preparazione import sul server...`, 'ok');
if (await this.$wire.importTerritoryPdfFolder()) {
this.fileCartella = [];
this.fileScelti = 0;
this.$refs.cartella.value = '';
} else {
this.log(`Import non avviato: il motivo è nell'avviso a comparsa. La cartella resta scelta, puoi ripremere.`, 'errore');
}
} catch (errore) {
this.log(`ERRORE durante il caricamento della cartella: ${errore.message}. Nessun import è partito: riprova.`, 'errore');
} finally {
this.cartellaInCorso = false;
} }
}, },
@@ -139,17 +248,15 @@
xhr.send(new FormData(form)); 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> <div>
<h2 id="titolo-pdf" class="text-lg font-semibold text-gray-900">3 &middot; Import PDF territori</h2> <h2 id="titolo-pdf" class="text-lg font-semibold text-gray-900">3 &middot; Import PDF territori</h2>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
Associa i PDF ai territori gi&agrave; presenti e ne genera le miniature. Il nome del file pu&ograve; Associa i PDF ai territori gi&agrave; presenti e ne genera le miniature. Il nome del file deve
variare: basta che contenga il numero di un territorio esistente. contenere il numero del territorio: una lettera subito dopo il numero ne fa parte (&laquo;12 B&raquo;,
&laquo;12B&raquo; e &laquo;12-b&raquo; sono tutti il 12B, mai il 12). I file che non si abbinano con
certezza a un solo territorio vengono saltati e segnalati nell&rsquo;esito.
</p> </p>
</div> </div>
@@ -186,30 +293,32 @@
</form> </form>
{{-- Strada alternativa --}} {{-- Strada alternativa --}}
<form wire:submit.prevent="importTerritoryPdfFolder" class="rounded-lg border border-gray-200 p-4 space-y-3"> {{-- Niente wire:model sull'input: con wire:model Livewire spedisce
tutta la cartella in una sola richiesta, e PHP ne tiene solo i
primi max_file_uploads (20). La spedisce inviaCartella(), a lotti. --}}
<form @submit.prevent="inviaCartella()" class="rounded-lg border border-gray-200 p-4 space-y-3">
<div> <div>
<h3 class="text-sm font-semibold text-gray-900">Da cartella</h3> <h3 class="text-sm font-semibold text-gray-900">Da cartella</h3>
<p class="mt-1 text-xs text-gray-500">Comoda per pochi file. Ogni PDF viaggia come caricamento temporaneo a s&eacute;.</p> <p class="mt-1 text-xs text-gray-500">I PDF partono a gruppi di {{ $this->filePerLotto }}; i file che non sono PDF (Thumbs.db, desktop.ini&hellip;) vengono ignorati.</p>
</div> </div>
<div> <div>
<label for="pdfFolder" class="sr-only">Cartella di PDF</label> <label for="pdfFolder" class="sr-only">Cartella di PDF</label>
<input wire:model="pdfFolder" id="pdfFolder" type="file" multiple webkitdirectory directory <input id="pdfFolder" type="file" multiple webkitdirectory directory
accept=".pdf,application/pdf" x-ref="cartella"
data-file-per-lotto="{{ $this->filePerLotto }}"
x-on:change="cartellaScelta($event)" x-on:change="cartellaScelta($event)"
x-bind:disabled="cartellaInCorso"
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"> 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
@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> </div>
<p class="text-xs text-gray-500" x-show="fileScelti > 0" x-cloak x-text="fileScelti + ' file selezionati'"></p> <p class="text-xs text-gray-500" x-show="fileScelti > 0" x-cloak
x-text="cartellaInCorso ? caricatiCartella + ' / ' + fileScelti + ' PDF caricati' : fileScelti + ' PDF da importare'"></p>
<x-btn variante="secondary" type="submit" <x-btn variante="secondary" type="submit"
x-on:click="$dispatch('console-apri')" x-bind:disabled="fileScelti === 0 || cartellaInCorso">
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 Importa cartella
</x-btn> </x-btn>
<span wire:loading wire:target="importTerritoryPdfFolder" class="text-xs text-indigo-700">Preparazione import...</span> <span wire:loading wire:target="importTerritoryPdfFolder" class="text-xs text-indigo-700">Preparazione import...</span>
@@ -329,7 +438,8 @@
<h3 class="text-sm font-semibold text-gray-900">Importa</h3> <h3 class="text-sm font-semibold text-gray-900">Importa</h3>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
<strong>Sostituisce</strong> zone, tipologie, proclamatori, territori, anni, campagne, <strong>Sostituisce</strong> zone, tipologie, proclamatori, territori, anni, campagne,
assegnazioni e impostazioni. assegnazioni e impostazioni. I PDF e le miniature dei territori sostituiti che il file non
richiama pi&ugrave; vengono eliminati.
</p> </p>
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" class="space-y-3" <form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" class="space-y-3"

Some files were not shown because too many files have changed in this diff Show More