From 056d95cfcbe1c040de2a3800d22c9f2ceb3b9ef8 Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Mon, 12 Oct 2026 12:08:34 +0200 Subject: [PATCH] Editor piantine: bozza automatica e pubblicazione con anteprima MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 + ). - .gitattributes: script shell sempre LF, altrimenti il container locale su Windows non parte. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 + HANDOFF.md | 122 +++- .../Settings/PiantinaAnteprimaController.php | 29 +- app/Jobs/PreparaAnteprimaPiantina.php | 44 ++ app/Livewire/Concerns/Avvisa.php | 25 + app/Livewire/Piantine/PiantineEditor.php | 629 +++++++++++------- app/Livewire/Piantine/PiantineManager.php | 246 +++---- app/Livewire/Piantine/Pubblicazione.php | 124 ++++ app/Models/TerritorioMappa.php | 121 +++- app/Services/Piantine/PiantinaPdfService.php | 44 +- app/Services/Piantine/PiantinaRenderer.php | 79 ++- .../Piantine/PubblicazionePiantina.php | 234 +++++++ ..._000010_bozza_e_pubblicazione_piantine.php | 56 ++ resources/css/app.css | 376 ++++++++++- resources/js/piantine.js | 400 +++++++---- resources/views/components/avvisi.blade.php | 50 ++ .../views/components/layouts/app.blade.php | 5 + .../views/components/layouts/editor.blade.php | 32 +- .../piantine/piantine-editor.blade.php | 376 ++++++----- .../piantine/piantine-manager.blade.php | 223 ++++--- .../livewire/piantine/pubblicazione.blade.php | 54 ++ routes/web.php | 4 +- tests/Feature/PiantineEditorTest.php | 316 +++++++-- tests/Feature/PiantineManagerTest.php | 186 +++--- tests/Feature/PubblicazionePiantinaTest.php | 286 ++++++++ 25 files changed, 3084 insertions(+), 980 deletions(-) create mode 100644 .gitattributes create mode 100644 app/Jobs/PreparaAnteprimaPiantina.php create mode 100644 app/Livewire/Concerns/Avvisa.php create mode 100644 app/Livewire/Piantine/Pubblicazione.php create mode 100644 app/Services/Piantine/PubblicazionePiantina.php create mode 100644 database/migrations/2026_10_09_000010_bozza_e_pubblicazione_piantine.php create mode 100644 resources/views/components/avvisi.blade.php create mode 100644 resources/views/livewire/piantine/pubblicazione.blade.php create mode 100644 tests/Feature/PubblicazionePiantinaTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dde9d72 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/HANDOFF.md b/HANDOFF.md index d84c611..2cc7e77 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto. -Ultimo aggiornamento: **28 agosto 2026**. +Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui). --- @@ -75,6 +75,74 @@ e l'unica che richiede una decisione, non del lavoro. --- +## Lavori in corso — 12 ottobre 2026 (riprendere da qui) + +### Già fatto e committato su `dev` (commit `d433c93`, NON ancora pushato) + +- **Modulo S-13** riscritto su modulo ufficiale (FPDI), regole decise con l'utente: + 20 righe/foglio, tutti i territori attivi, ciclo da 4 con riporto, riporto + iniziale = ultimo rientro prima dell'anno, assegnazioni a cavallo d'anno su + entrambi i fogli (sul vecchio senza rientro), nomi "Cognome I.", scelta per + data. Codice in `app/Services/Registro/ModuloS13.php`. +- **Blocco 1 della revisione** (integrità dati): date export/import che + slittavano di un giorno, import XML che accettava file estranei, caratteri di + controllo, territori senza zona scartati, admin estromesso dall'import, + doppia assegnazione (`Assegnazione::sovrappostaA()` + lock), ultimo admin, + `APP_KEY` sovrascritta dall'entrypoint (`APP_KEY_ROTATE=1`). Vedi la tabella + "Bug corretti". Suite: 328 test verdi. +- Il push su `dev` fa partire il deploy su demo: va fatto quando si decide. + +### Finito, NON ancora committato: riprogettazione dell'Editor Piantine + +Decisioni dell'utente: due pagine separate (gestione / editor); **un solo +pulsante "Pubblica"** (immagine + PDF) con **anteprima obbligatoria** da +confermare, disponibile da editor e gestione; dall'editor **si modificano i +dati del territorio** (salvati subito nel gestionale); uso soprattutto da PC ma +anche **tablet**; **stato di bozza**: in bozza va solo il disegno, salvata +**automaticamente**; stati Bozza / Pubblicata / Modifiche da pubblicare. +Come funziona: sezione [Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi). + +Stato al 12/10/2026: +- Codice completo: modello, servizio di pubblicazione + job, finestra condivisa, + editor e gestione riscritti, JS e CSS. Migrazione + `2026_10_09_000010_bozza_e_pubblicazione_piantine`. +- Test: `PiantineEditorTest` (22), `PiantineManagerTest`, + `PubblicazionePiantinaTest` (nuovo). Suite intera: **360 test verdi**. +- Provato in browser (Playwright + Edge, in locale) su PC 1440, iPad + orizzontale e verticale, iPhone: disegno, modifica vertici (8 maniglie su + poligono), collegamento, anteprima generata dal renderer vero, conferma, + stato "Pubblicata", filtri della gestione. Corretti lì: tendina territori che + segnava tutti "(disattivato)" (colonna `attivo` non caricata), zoom sopra la + ricerca dell'elenco, barra strumenti sopra il pannello su tablet e fuori + schermo su telefono, crediti mappa stampati sul foglio del pannello. +- Aggiunto `.gitattributes` (`*.sh text eol=lf`): con `core.autocrlf=true` + l'entrypoint usciva con CRLF e il container locale non partiva ("no such + file or directory"). Nel repository era già LF: il deploy non era a rischio. +- **Non provato:** modifica vertici al tocco su un tablet vero, MultiPolygon + in modifica vertici (si modifica il primo poligono). +- Visto e **non** corretto (fuori tema): su telefono la barra superiore + dell'app trabocca con nome/email lunghi ("Esci" fuori schermo); + `/favicon.ico` risponde 404. + +### Revisione profonda: blocchi ancora aperti + +- **Blocco 2:** avvisi in tutte le ~10 pagine che oggi li perdono (usare il + trait `Avvisa`); PDF dei territori serviti da nginx su `/storage` senza + autenticazione (spostare su disco privato); ripristino non atomico e blocco + "una sola operazione" ignorato da import XML/PDF; piantine e storico PDF + assenti dal backup; import PDF da cartella (`1-A` sovrascrive `1`, solo 20 + file per `max_file_uploads`, `Thumbs.db` blocca tutto, `12 B` ambiguo). +- **Blocco 3:** `/cambia-password` senza password attuale, sessioni non + invalidate, `trustProxies('*')`, nomi in chiaro nell'activity log, export XML + con hash/token, campagne (ultimo giorno, percentuali), `retry_after` 90 s, + `stop_grace_period` del worker, Redis `allkeys-lru`, cache statica di + `Setting` nel worker, azzeramento che lascia piantine e KML. +- 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 delle date + slittate da fare. + +--- + ## Da dove veniamo Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava @@ -212,13 +280,48 @@ non esiste, lo si crea dall'editor col solo numero. La chiave esterna è `nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare `territori` come fa ogni import XML, non porta più via i disegni. -**Due pagine, non una.** `/piantine` tiene area, generazione, collegamenti e -storico; `/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, e quel disegno finisce stampato. +**Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni +piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate / +scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre, +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 -rigenera un territorio alla volta dall'elenco. +**Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo: +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 ``): 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 è largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi @@ -234,11 +337,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 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 che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`): diff --git a/app/Http/Controllers/Settings/PiantinaAnteprimaController.php b/app/Http/Controllers/Settings/PiantinaAnteprimaController.php index 01c9b35..013effc 100644 --- a/app/Http/Controllers/Settings/PiantinaAnteprimaController.php +++ b/app/Http/Controllers/Settings/PiantinaAnteprimaController.php @@ -3,37 +3,30 @@ namespace App\Http\Controllers\Settings; use App\Http\Controllers\Controller; -use App\Models\TerritorioMappa; -use App\Services\Piantine\PiantinaPdfService; +use App\Services\Piantine\PubblicazionePiantina; 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 - * territorio, non archivia versioni, non lascia file sul disco. Serve a - * guardare come viene prima di decidere — perché "Genera PDF" è un'operazione - * che manda il documento in vigore, e vederlo solo dopo significa scoprire un - * confine sbagliato quando è già nello storico. + * Lo prepara PubblicazionePiantina in una cartella privata: qui lo si serve + * soltanto, senza toccare il PDF in vigore del territorio. Il token è casuale + * e scade con l'anteprima; la rotta resta comunque riservata + * all'amministratore come il resto delle piantine. */ 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($pdf, 200, [ + return response()->file($percorso, [ 'Content-Type' => 'application/pdf', // `inline`: si apre nel visualizzatore del browser invece di // finire fra i download, che di un'anteprima non se ne fa niente. - 'Content-Disposition' => 'inline; filename="anteprima-territorio-' - . $mappa->territorio->numero . '.pdf"', - // Un'anteprima rispecchia lo stato di adesso: se la cache la - // congela, si continua a guardare il confine di prima. + 'Content-Disposition' => 'inline; filename="anteprima-piantina.pdf"', 'Cache-Control' => 'no-store, max-age=0', ]); } diff --git a/app/Jobs/PreparaAnteprimaPiantina.php b/app/Jobs/PreparaAnteprimaPiantina.php new file mode 100644 index 0000000..c64955f --- /dev/null +++ b/app/Jobs/PreparaAnteprimaPiantina.php @@ -0,0 +1,44 @@ +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.' + ); + } +} diff --git a/app/Livewire/Concerns/Avvisa.php b/app/Livewire/Concerns/Avvisa.php new file mode 100644 index 0000000..8bcbfa4 --- /dev/null +++ b/app/Livewire/Concerns/Avvisa.php @@ -0,0 +1,25 @@ +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 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'); + } +} diff --git a/app/Livewire/Piantine/PiantineEditor.php b/app/Livewire/Piantine/PiantineEditor.php index 95a70d0..2453846 100644 --- a/app/Livewire/Piantine/PiantineEditor.php +++ b/app/Livewire/Piantine/PiantineEditor.php @@ -2,45 +2,54 @@ namespace App\Livewire\Piantine; +use App\Livewire\Concerns\Avvisa; use App\Models\Territorio; use App\Models\TerritorioMappa; use App\Models\Tipologia; use App\Models\Zona; -use App\Services\Piantine\PiantinaRenderer; use App\Services\Piantine\PiantineArea; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; +use Livewire\Attributes\Locked; use Livewire\Attributes\On; use Livewire\Component; /** * Editor delle piantine a schermo intero (solo amministratore). * - * Una pagina sola con la mappa grande quanto la finestra e i comandi in una - * barra sottile in alto: tagliare un confine su una cartografia larga un terzo - * di schermo significa sbagliarlo, e questo disegno finisce stampato su un - * modulo che poi gira di mano in mano. + * Tutto quello che si fa qui è bozza e si salva da solo: il confine a ogni + * disegno o spostamento di vertice, il nome e il collegamento appena cambiano. + * La bozza non tocca il PDF del territorio, che cambia solo con "Pubblica" + * (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 - * solo dopo si compila la scheda del territorio — che può anche non esistere - * ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o - * la tipologia mancano si creano sul posto, senza abbandonare il disegno. + * Disegnare crea sempre una piantina nuova: per cambiare il confine di quella + * aperta ci sono "Modifica vertici" e "Ridisegna", espliciti. Prima un + * disegno fatto con una piantina aperta ne sostituiva il confine in silenzio. + * + * 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')] 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 string $nome = ''; - /** Geometria appena disegnata, non ancora salvata. */ - public ?string $geometriaInAttesa = null; + /** Ora dell'ultimo salvataggio automatico, per l'indicazione in testata. */ + public string $salvatoAlle = ''; - public bool $pannelloAperto = false; - - // ─── Scheda del territorio ────────────────────────────────────────────── + // ─── Territorio ───────────────────────────────────────────────────────── /** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */ public string $territorioScelto = ''; @@ -53,7 +62,6 @@ class PiantineEditor extends Component public string $confini = ''; - /** Campi di creazione al volo, aperti solo quando servono. */ public string $nuovaZona = ''; public string $nuovaTipologia = ''; @@ -61,7 +69,7 @@ class PiantineEditor extends Component public function mount(?int $mappa = null): void { 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); if (! $mappa) { + $this->avvisaErrore('Questa piantina non esiste più: forse è stata eliminata da un\'altra scheda.'); + $this->dispatch('piantina-rimossa', mappaId: (int) $mappaId); + return; } - $this->mappaId = $mappa->id; - $this->nome = (string) $mappa->nome; - $this->geometriaInAttesa = null; - $this->pannelloAperto = true; - - $this->caricaScheda($mappa->territorio); - $this->aggiornaMappaJs(inquadra: true); + $this->carica($mappa); + $this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: true); } + /** "Nuova piantina": si deseleziona e la mappa entra in modalità disegno. */ 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([ - 'mappaId', 'nome', 'geometriaInAttesa', + 'mappaId', 'nome', 'salvatoAlle', 'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini', 'nuovaZona', 'nuovaTipologia', ]); - $this->resetValidation(); - $this->pannelloAperto = true; - - $this->aggiornaMappaJs(); } - public function chiudiPannello(): void - { - $this->pannelloAperto = false; - } - - /** Riempie i campi della scheda dal territorio collegato (o li svuota). */ - private function caricaScheda(?Territorio $territorio): void + private function caricaTerritorio(?Territorio $territorio): void { $this->territorioScelto = $territorio ? (string) $territorio->id : ''; $this->numero = (string) ($territorio->numero ?? ''); @@ -113,12 +129,118 @@ class PiantineEditor extends Component $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 { $this->resetValidation(); + $mappa = $this->mappaAperta(); + + if (! $mappa) { + return; + } 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->zonaId = ''; $this->tipologiaId = ''; @@ -127,34 +249,158 @@ class PiantineEditor extends Component 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; } - $this->geometriaInAttesa = json_encode($geometry); - $this->pannelloAperto = true; + $territorio = Territorio::find($valore); + 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; - $this->aggiornaMappaJs(); + $mappa = $this->mappaAperta(); + + 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 ────────────────────────────────── - public function creaZona() + public function creaZona(): void { + $this->nuovaZona = trim($this->nuovaZona); $dati = $this->validate( ['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->nuovaZona = ''; - - session()->flash('success', "Zona «{$zona->nome}» creata e selezionata."); + $this->updatedZonaId(); + $this->avvisa("Zona «{$zona->nome}» creata e selezionata."); } - public function creaTipologia() + public function creaTipologia(): void { + $this->nuovaTipologia = trim($this->nuovaTipologia); $dati = $this->validate( ['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->nuovaTipologia = ''; - - session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata."); + $this->updatedTipologiaId(); + $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; - - 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; + $mappa = $this->mappaAperta(); if (! $mappa) { - session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.'); + return; + } + + if ($motivo = $mappa->motivoNonPubblicabile()) { + $this->avvisaErrore($motivo); return; } - try { - $renderer->render($mappa); - } catch (\Throwable $e) { - session()->flash('error', $e->getMessage()); - - return; - } - - session()->flash('success', "Immagine generata per {$mappa->etichetta()}."); + $this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class); } - 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) { return; } $etichetta = $mappa->etichetta(); + $id = $mappa->id; if ($mappa->image_path) { Storage::disk('public')->delete($mappa->image_path); } $mappa->delete(); - $this->nuova(); - session()->flash('success', "{$etichetta} eliminata."); + $this->svuota(); + $this->dispatch('piantina-rimossa', mappaId: $id); + $this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano."); } // ─── 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 [ '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, ]; } - 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', [ 'mappa' => $mappa, - 'areaCaricata' => $area->area() !== null, - 'datiMappaIniziali' => $this->datiMappa($area), - 'territori' => Territorio::attivi() - ->orderByRaw('LENGTH(numero), numero') - ->get(['id', 'numero']), + 'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null, + 'territori' => $territori, 'zone' => Zona::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', ]); } } diff --git a/app/Livewire/Piantine/PiantineManager.php b/app/Livewire/Piantine/PiantineManager.php index a85f3f2..5829a5d 100644 --- a/app/Livewire/Piantine/PiantineManager.php +++ b/app/Livewire/Piantine/PiantineManager.php @@ -2,38 +2,60 @@ namespace App\Livewire\Piantine; -use App\Models\Territorio; +use App\Livewire\Concerns\Avvisa; use App\Models\TerritorioMappa; use App\Models\TerritorioPdfVersione; use App\Services\Piantine\PiantinaPdfService; -use App\Services\Piantine\PiantinaRenderer; use App\Services\Piantine\PiantineArea; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; +use Livewire\Attributes\On; +use Livewire\Attributes\Url; use Livewire\Component; use Livewire\WithFileUploads; +use Livewire\WithPagination; /** * Pagina di gestione delle piantine (solo amministratore). * - * Qui si tiene l'area della città, si generano immagini e PDF, si collegano - * le piantine ai territori e si ripescano le versioni sostituite. Il disegno - * non sta qui: ha una pagina sua a schermo intero (PiantineEditor), perché su - * una mappa larga un terzo di schermo non si taglia un confine. + * È il quadro d'insieme: a che punto è ogni piantina (bozza, pubblicata, + * con modifiche da pubblicare), l'area della città e lo storico dei PDF + * sostituiti. Il disegno e i dati del territorio si toccano nell'editor; + * da qui si apre, si pubblica (con la stessa finestra di anteprima + * dell'editor) e si elimina. */ class PiantineManager extends Component { + use Avvisa; 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; - /** Piantina di cui è aperto il pannello "collega a un territorio". */ - public ?int $collegamentoAperto = null; - - public ?int $territorioDaCollegare = null; + public function updatedFiltro(): void + { + if (! array_key_exists($this->filtro, self::FILTRI)) { + $this->filtro = 'tutte'; + } + } // ─── Area città (KML) ─────────────────────────────────────────────────── - public function caricaKml(PiantineArea $area) + public function caricaKml(PiantineArea $area): void { $this->validate( ['kmlFile' => 'required|file|max:10240'], @@ -62,111 +84,42 @@ class PiantineManager extends Component $this->kmlFile = null; 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 { - $renderer->render($mappa); - } catch (\Throwable $e) { - session()->flash('error', $e->getMessage()); + if (! $mappa) { + $this->avvisaErrore('La piantina non esiste più.'); return; } - session()->flash('success', "Immagine generata per {$mappa->etichetta()}."); - } - - 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.'); + if ($motivo = $mappa->motivoNonPubblicabile()) { + $this->avvisaErrore("{$mappa->etichetta()}: {$motivo}"); return; } - try { - $pdfService->sostituisci($mappa->territorio, $mappa); - } catch (\Throwable $e) { - session()->flash('error', $e->getMessage()); + $this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class); + } + /** 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; } - 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(); if ($mappa->image_path) { @@ -174,59 +127,106 @@ class PiantineManager extends Component } $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 ─────────────────────────────────────────────────── - 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 ?? '?'; try { $pdfService->ripristina($versione); } catch (\Throwable $e) { - session()->flash('error', $e->getMessage()); + $this->avvisaErrore($e->getMessage()); 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)) { - Storage::disk('public')->delete($versione->pdf_path); + if (! $versione) { + return; } + + Storage::disk('public')->delete($versione->pdf_path); $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 + */ + 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) { + $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', [ 'config' => $area->config(), - 'piantine' => TerritorioMappa::with('territorio') - ->get() - ->sortBy(fn ($m) => [ - $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']), + 'elenco' => $elenco, + 'totale' => $piantine->count(), + 'conteggi' => $conteggi, 'versioni' => TerritorioPdfVersione::with('territorio') ->orderByDesc('sostituito_il') - ->limit(30) - ->get(), + ->orderByDesc('id') + ->paginate(15, pageName: 'versioni'), 'title' => 'Piantine dei territori', ]); } diff --git a/app/Livewire/Piantine/Pubblicazione.php b/app/Livewire/Piantine/Pubblicazione.php new file mode 100644 index 0000000..361f9b7 --- /dev/null +++ b/app/Livewire/Piantine/Pubblicazione.php @@ -0,0 +1,124 @@ +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'); + } +} diff --git a/app/Models/TerritorioMappa.php b/app/Models/TerritorioMappa.php index 76407fb..9892f51 100644 --- a/app/Models/TerritorioMappa.php +++ b/app/Models/TerritorioMappa.php @@ -11,15 +11,29 @@ use Illuminate\Database\Eloquent\Model; * cartografia, il numero si decide dopo. Finche' resta scollegato vale il * campo `nome` come etichetta; una volta collegato comanda il numero del * 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 { + public const STATO_BOZZA = 'bozza'; + + public const STATO_PUBBLICATA = 'pubblicata'; + + public const STATO_MODIFICATA = 'modificata'; + protected $table = 'territorio_mappe'; protected $fillable = [ 'territorio_id', 'nome', 'geojson', + 'geojson_pubblicato', + 'impronta_pubblicata', + 'pubblicata_at', 'image_path', 'rendered_at', ]; @@ -28,12 +42,19 @@ class TerritorioMappa extends Model { return [ '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() { - return $this->belongsTo(Territorio::class, 'territorio_id'); + return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed(); } /** @@ -42,15 +63,22 @@ class TerritorioMappa extends Model */ public function geometria(): ?array { - $decoded = json_decode($this->geojson, true); + $decoded = json_decode((string) $this->geojson, true); 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 { - 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 +88,7 @@ class TerritorioMappa extends Model */ public function etichetta(): string { - if ($this->collegata()) { + if ($this->territorio !== null) { return 'Terr. ' . $this->territorio->numero; } @@ -68,4 +96,87 @@ class TerritorioMappa extends Model 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; + + return hash('sha256', json_encode([ + $this->geometria(), + $this->territorio_id, + $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 è uno composto da una piantina. 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. + */ + public function pdfInUsoDallaPiantina(): bool + { + return $this->territorio_id !== null + && str_starts_with((string) $this->territorio?->pdf_path, 'territori-pdf/piantina-' . $this->territorio_id . '-'); + } + + /** 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(); + } } diff --git a/app/Services/Piantine/PiantinaPdfService.php b/app/Services/Piantine/PiantinaPdfService.php index d8fb9eb..61db6d9 100644 --- a/app/Services/Piantine/PiantinaPdfService.php +++ b/app/Services/Piantine/PiantinaPdfService.php @@ -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)) { - throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.'); + if ($immagine === null) { + 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) { throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.'); } @@ -92,7 +101,7 @@ class PiantinaPdfService ($h - $imgH) / 2, $imgW, $imgH, - $this->tipoImmagine($mappa->image_path) + $this->tipoImmagine($immagine) ); return $pdf->Output('S'); @@ -179,13 +188,26 @@ class PiantinaPdfService */ 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'); - $this->archiviaCorrente($territorio, $origine); - $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([ 'pdf_path' => $nuovoPath, diff --git a/app/Services/Piantine/PiantinaRenderer.php b/app/Services/Piantine/PiantinaRenderer.php index 9d8cc8a..5a04058 100644 --- a/app/Services/Piantine/PiantinaRenderer.php +++ b/app/Services/Piantine/PiantinaRenderer.php @@ -3,6 +3,7 @@ namespace App\Services\Piantine; use App\Models\TerritorioMappa; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use RuntimeException; @@ -14,6 +15,11 @@ use RuntimeException; */ class PiantinaRenderer { + /** + * Rendering e archiviazione come immagine della piantina. + * + * @return string il percorso sul disco pubblico + */ public function render(TerritorioMappa $mappa): string { $geometry = $mappa->geometria(); @@ -22,19 +28,38 @@ class PiantinaRenderer 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'; - $response = Http::timeout((int) config('piantine.renderer_timeout')) - ->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [ - 'geometry' => $geometry, - 'marginRatio' => (float) config('piantine.margin_ratio'), - 'maxSide' => (int) config('piantine.max_side'), - 'aspect' => (float) config('piantine.aspetto'), - 'strokeWidth' => (float) config('piantine.tratto'), - 'strokeOpacity' => (float) config('piantine.tratto_opacita'), - 'format' => $formato, - 'quality' => (int) config('piantine.qualita'), - ]); + try { + $response = Http::timeout((int) config('piantine.renderer_timeout')) + ->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [ + 'geometry' => $geometry, + 'marginRatio' => (float) config('piantine.margin_ratio'), + 'maxSide' => (int) config('piantine.max_side'), + 'aspect' => (float) config('piantine.aspetto'), + 'strokeWidth' => (float) config('piantine.tratto'), + 'strokeOpacity' => (float) config('piantine.tratto_opacita'), + '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()) { $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.'); } - // Il vecchio file puo' avere un'estensione diversa da quella nuova (si - // 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); + return [$immagine, $formato === 'png' ? 'png' : 'jpg']; + } + + /** + * 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); - Storage::disk('public')->put($path, $immagine); + if ($mappa->image_path && $mappa->image_path !== $path) { + $disco->delete($mappa->image_path); + } $mappa->update([ 'image_path' => $path, @@ -65,11 +103,6 @@ class PiantinaRenderer 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. */ private function immagineValida(string $dati, string $formato): bool { diff --git a/app/Services/Piantine/PubblicazionePiantina.php b/app/Services/Piantine/PubblicazionePiantina.php new file mode 100644 index 0000000..f071a32 --- /dev/null +++ b/app/Services/Piantine/PubblicazionePiantina.php @@ -0,0 +1,234 @@ +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'], + '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); + } + } + } +} diff --git a/database/migrations/2026_10_09_000010_bozza_e_pubblicazione_piantine.php b/database/migrations/2026_10_09_000010_bozza_e_pubblicazione_piantine.php new file mode 100644 index 0000000..82ea2d9 --- /dev/null +++ b/database/migrations/2026_10_09_000010_bozza_e_pubblicazione_piantine.php @@ -0,0 +1,56 @@ +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']); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 455c3d7..587c06c 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -557,6 +557,64 @@ html { scroll-behavior: smooth; } background: var(--tm-superficie-alt); } +/* ─── 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 ──────────────────────────────────────────────────── */ .tm-tag { @@ -989,9 +1047,9 @@ html { scroll-behavior: smooth; } .tm-editor-barra { display: flex; align-items: center; - gap: 12px; + gap: 10px; flex: none; - padding: 9px 14px; + padding: 8px 14px; background: var(--tm-superficie); border-bottom: 1px solid var(--tm-bordo); box-shadow: var(--tm-ombra-1); @@ -1026,6 +1084,8 @@ html { scroll-behavior: smooth; } gap: 9px; flex: 1 1 auto; min-width: 0; +} +.tm-editor-titolo h1 { margin: 0; font-size: 15px; font-weight: 600; @@ -1034,6 +1094,11 @@ html { scroll-behavior: smooth; } overflow: hidden; text-overflow: ellipsis; } +.tm-editor-salvato { + font-size: 12px; + color: var(--tm-testo-tenue); + white-space: nowrap; +} .tm-editor-azioni { display: flex; @@ -1042,10 +1107,26 @@ html { scroll-behavior: smooth; } 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; flex: 1 1 auto; 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 { position: absolute; @@ -1056,7 +1137,7 @@ html { scroll-behavior: smooth; } .tm-editor-nota { position: absolute; left: 50%; - top: 14px; + bottom: 18px; transform: translateX(-50%); z-index: 500; max-width: min(92vw, 460px); @@ -1072,16 +1153,132 @@ html { scroll-behavior: smooth; } } .tm-editor-nota a { color: inherit; font-weight: 600; } -/* Il pannello galleggia sopra la mappa invece di restringerla: se togliesse - spazio, aprirlo per cambiare un nome sposterebbe il disegno sotto il - puntatore. */ -.tm-editor-pannello { +/* ── Barra degli strumenti di disegno ── */ +.tm-strumenti { position: absolute; 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; 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; flex-direction: column; border: 1px solid var(--tm-bordo); @@ -1090,7 +1287,6 @@ html { scroll-behavior: smooth; } box-shadow: var(--tm-ombra-3); overflow: hidden; } -.tm-editor-pannello[hidden] { display: none; } .tm-editor-pannello-testata { display: flex; @@ -1098,7 +1294,7 @@ html { scroll-behavior: smooth; } justify-content: space-between; gap: 10px; flex: none; - padding: 13px 16px; + padding: 12px 16px; border-bottom: 1px solid var(--tm-bordo); } .tm-editor-pannello-testata h2 { @@ -1130,7 +1326,7 @@ html { scroll-behavior: smooth; } display: flex; flex-wrap: wrap; align-items: center; - justify-content: space-between; + justify-content: flex-end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--tm-bordo); @@ -1141,14 +1337,164 @@ html { scroll-behavior: smooth; } line-height: 1.55; 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) { .tm-editor-barra { flex-wrap: wrap; } .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 ) ───────────────────── */ .tm-avvisi { position: fixed; diff --git a/resources/js/piantine.js b/resources/js/piantine.js index 63a37c1..18060fc 100644 --- a/resources/js/piantine.js +++ b/resources/js/piantine.js @@ -1,10 +1,22 @@ // Editor delle piantine (pagina /piantine/editor, solo amministratore). // -// Mappa Leaflet su tessere OpenStreetMap: mostra l'area del KML, le piantine -// già salvate e permette di disegnarne di nuove con leaflet-draw. Parla con il -// componente Livewire attraverso eventi: -// JS → Livewire: 'poligono-disegnato', 'piantina-selezionata-mappa' -// Livewire → JS: 'piantine-dati-aggiornati' +// Mappa Leaflet su tessere OpenStreetMap con l'area del KML e le piantine, più +// l'elenco con ricerca a sinistra. Disegno e modifica dei vertici usano i +// gestori di leaflet-draw, comandati da una barra di strumenti nostra: pulsanti +// grandi e con il testo, che si usano anche al tablet. +// +// 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 'leaflet/dist/leaflet.css'; @@ -12,12 +24,13 @@ import 'leaflet-draw'; import 'leaflet-draw/dist/leaflet.draw.css'; 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 -// lavoro ancora aperto, e si vede senza dover aprire il pannello. -const STILE_COLLEGATA = { color: '#8a8578', weight: 2, fillColor: '#8a8578', fillOpacity: 0.1 }; -const STILE_SCOLLEGATA = { color: '#b4762a', 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 STILI = { + pubblicata: { color: '#2f6f4e', weight: 2, fillColor: '#2f6f4e', fillOpacity: 0.08 }, + modificata: { color: '#a06a22', weight: 2, 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, dashArray: null, fillColor: '#605cb2', fillOpacity: 0.18 }; +const STILE_DISEGNO = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 }; function initPiantine() { const contenitore = document.getElementById('piantine-map'); @@ -26,143 +39,294 @@ function initPiantine() { } contenitore.dataset.inizializzato = '1'; - let dati = { area: null, piantine: [], selezionata: null }; - try { - dati = JSON.parse(document.getElementById('piantine-dati').textContent); - } catch { - // si parte comunque con la mappa vuota - } + const radice = contenitore.closest('[wire\\:id]'); + const componente = () => window.Livewire.find(radice.getAttribute('wire:id')); - // 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 = { - start: 'Clicca sulla mappa per iniziare il confine.', - cont: 'Continua a cliccare lungo il confine.', - end: 'Clicca sul primo punto per chiudere il poligono.', + start: 'Tocca o clicca sulla mappa per il primo punto del confine.', + cont: 'Continua lungo il confine.', + 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 }); - L.control.scale({ imperial: false }).addTo(mappa); - L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(mappa); + const gruppo = L.featureGroup().addTo(mappa); + const layers = new Map(); // mappaId → { dati, layer } let layerArea = null; - const layerSalvate = L.featureGroup().addTo(mappa); - let layerInAttesa = null; + let selezionata = null; + let modo = 'vista'; + let gestoreDisegno = null; + let poligonoInModifica = null; - mappa.addControl(new L.Control.Draw({ - draw: { - polygon: { allowIntersection: false, showArea: false, shapeOptions: STILE_IN_ATTESA }, - polyline: false, - rectangle: false, - circle: false, - circlemarker: false, - marker: false, - }, - edit: false, - })); + // ── Disegno delle piantine ────────────────────────────────────────────── - function stileDi(piantina, selezionata) { - if (selezionata) return STILE_SELEZIONATA; - return piantina.collegata ? STILE_COLLEGATA : STILE_SCOLLEGATA; + function stileDi(dati) { + if (dati.mappaId === selezionata) return STILE_SELEZIONATA; + return { dashArray: null, ...(STILI[dati.stato] ?? STILI.bozza) }; } - function ridisegna(nuoviDati, adattaVista) { - dati = nuoviDati; + function disegna(dati) { + rimuovi(dati.mappaId, false); + if (!dati.geometry) return; - if (layerArea) { - mappa.removeLayer(layerArea); - layerArea = null; + let layer; + try { + 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) { layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa); } + selezionata = dati.selezionata ?? null; + dati.piantine.forEach(disegna); + disegnaElenco(); + aggiornaBarra(); - layerSalvate.clearLayers(); - let layerSelezionata = null; - - for (const piantina of dati.piantine) { - const selezionata = dati.selezionata === piantina.mappaId; - const layer = L.geoJSON( - { 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)); + if (selezionata !== null && layers.has(selezionata)) { + inquadra(selezionata); + } else if (layerArea) { + mappa.fitBounds(layerArea.getBounds().pad(0.08)); + } else if (layers.size) { + mappa.fitBounds(gruppo.getBounds().pad(0.08)); } else { 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) => { - if (layerInAttesa) { - mappa.removeLayer(layerInAttesa); - } - layerInAttesa = evento.layer; - 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(); + const avvia = () => { registra(); carica(); }; + if (window.Livewire?.find && radice && window.Livewire.find(radice.getAttribute('wire:id'))) { + avvia(); + } else { + document.addEventListener('livewire:initialized', avvia, { once: true }); } - function registraEventi() { - if (registrato) { - return; - } - 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); + // Pannelli che si aprono e chiudono cambiano le misure del contenitore: + // senza, Leaflet resta convinto di quelle di partenza e le tessere si + // disallineano dal puntatore. + new ResizeObserver(() => mappa.invalidateSize()).observe(contenitore); } if (document.readyState === 'loading') { diff --git a/resources/views/components/avvisi.blade.php b/resources/views/components/avvisi.blade.php new file mode 100644 index 0000000..a3e7075 --- /dev/null +++ b/resources/views/components/avvisi.blade.php @@ -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 + +
+ +
diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 802a6e9..003d001 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -212,6 +212,11 @@ + + {{-- Gli avvisi delle azioni Livewire (trait Avvisa). I flash della + richiesta restano nella fascia in testa al contenuto, qui sopra. --}} + + @livewireScripts diff --git a/resources/views/components/layouts/editor.blade.php b/resources/views/components/layouts/editor.blade.php index c01b20d..ba7c9f9 100644 --- a/resources/views/components/layouts/editor.blade.php +++ b/resources/views/components/layouts/editor.blade.php @@ -5,8 +5,8 @@ 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 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, - e si tolgono da soli. + mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro + (, che raccoglie anche quelli lanciati dalle azioni Livewire). --}} @@ -22,33 +22,7 @@ {{ $slot }} -
- @if (session()->has('success')) -
- - {{ session('success') }} - -
- @endif - @if (session()->has('error')) -
- - {{ session('error') }} - -
- @endif -
+ @livewireScripts diff --git a/resources/views/livewire/piantine/piantine-editor.blade.php b/resources/views/livewire/piantine/piantine-editor.blade.php index 4d37d06..7fa7704 100644 --- a/resources/views/livewire/piantine/piantine-editor.blade.php +++ b/resources/views/livewire/piantine/piantine-editor.blade.php @@ -1,7 +1,27 @@ -
+@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. +--}} +
@vite('resources/js/piantine.js') - {{-- ── Barra strumenti ───────────────────────────────────────────────── --}} + {{-- ── Testata ───────────────────────────────────────────────────────── --}}
@@ -10,188 +30,228 @@ -

- {{ $mappa?->etichetta() ?? 'Nuova piantina' }} - @if($geometriaInAttesa) - disegno non salvato - @elseif($mappa && ! $mappa->collegata()) - scollegata + + + + +
+

{{ $mappa?->etichetta() ?? 'Editor delle piantine' }}

+ @if($mappa) + {{ $mappa->statoEtichetta() }} + + Salvataggio… + + @if($salvatoAlle) Salvato alle {{ $salvatoAlle }} @endif + + @endif -

+
- Nuova - - - - - Scheda + Nuova piantina @if($mappa) - - Genera immagine - Rendering… + + Scheda - @if($mappa->collegata() && $mappa->image_path) - - - Anteprima PDF - - @endif + + + Pubblica + @endif - - - - Salva -
- {{-- ── Mappa ─────────────────────────────────────────────────────────── --}} -
-
+
+ {{-- ── Elenco ────────────────────────────────────────────────────── --}} + - @unless($areaCaricata) -

- Nessuna area città caricata: la mappa parte senza perimetro di riferimento. - Il KML si carica dalla pagina di gestione. -

- @endunless + {{-- ── Mappa ─────────────────────────────────────────────────────── --}} +
+
+ - {{-- Pannello: scorre sopra la mappa, non la restringe. --}} -
+ + {{-- ── Pannello della piantina ───────────────────────────────────── --}} +
- +
diff --git a/resources/views/livewire/piantine/piantine-manager.blade.php b/resources/views/livewire/piantine/piantine-manager.blade.php index 8e0f33d..3a3565d 100644 --- a/resources/views/livewire/piantine/piantine-manager.blade.php +++ b/resources/views/livewire/piantine/piantine-manager.blade.php @@ -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 +

Piantine dei territori

- Il disegno dei confini si fa nell'editor a schermo intero. Qui si tiene l'area della - città, si generano immagini e PDF sul modulo S-12, e si ripescano le versioni sostituite. + A che punto è ogni piantina. Il disegno e i dati del territorio si modificano nell'editor, + dove le modifiche si salvano da sole come bozza; il PDF del territorio cambia solo quando + si pubblica, dopo averne visto l'anteprima.

@@ -13,8 +24,101 @@
+ {{-- ── Piantine ──────────────────────────────────────────────────────── --}} +
+
+

Piantine {{ $totale }}

+
+ + @if($totale === 0) +
+ +

Nessuna piantina disegnata.

+ Disegna la prima +
+ @else +
+ +
+ @foreach(\App\Livewire\Piantine\PiantineManager::FILTRI as $chiave => $etichetta) + + @endforeach +
+
+ + @if($elenco->isEmpty()) +

Nessuna piantina corrisponde alla ricerca.

+ @else +
    + @foreach($elenco as $piantina) + @php + $stato = $piantina->stato(); + $motivo = $piantina->motivoNonPubblicabile(); + @endphp +
  • +
    + + {{ $piantina->etichetta() }} + + {{ $piantina->statoEtichetta() }} + @if($piantina->territorioNelCestino()) + Territorio nel cestino + @elseif(! $piantina->collegata()) + Scollegata + @elseif(! $piantina->territorio->zona_id) + Zona mancante + @endif + + @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' }} + +
    + +
    + + Apri + + + @if($stato !== TerritorioMappa::STATO_PUBBLICATA) + + Pubblica + + @endif + + @if($piantina->collegata() && $piantina->territorio->pdf_path) + PDF in uso + @endif + + + Elimina + +
    + + @if($motivo && $stato !== TerritorioMappa::STATO_PUBBLICATA) +

    {{ $motivo }}

    + @endif +
  • + @endforeach +
+ @endif + @endif +
+ {{-- ── Area della città ──────────────────────────────────────────────── --}} -
+
@@ -59,110 +162,14 @@ - {{-- ── Piantine ──────────────────────────────────────────────────────── --}} -
-
-

Piantine {{ $piantine->count() }}

-

- Una piantina può vivere scollegata: per il PDF sul modulo S-12 serve invece un territorio. -

-
- - @if($piantine->isEmpty()) -
- -

Nessuna piantina disegnata.

- Disegna la prima -
- @else -
    - @foreach($piantine as $piantina) -
  • -
    - - {{ $piantina->etichetta() }} - - @unless($piantina->collegata()) - scollegata - @endunless - @if($piantina->collegata() && ! $piantina->territorio->zona_id) - Zona mancante - @endif - - {{ $piantina->image_path - ? 'immagine del ' . $piantina->rendered_at?->format('d/m/Y H:i') - : 'immagine da generare' }} - -
    - -
    - - Genera immagine - Rendering… - - - @if($piantina->image_path) - Immagine - @endif - - @if($piantina->collegata()) - @if($piantina->image_path) - Anteprima PDF - - Genera PDF - Composizione… - - @endif - - Scollega - - @else - - Collega a un territorio - - @endif - - - Elimina - -
    - - @if($collegamentoAperto === $piantina->id) -
    -
    - - Collega - Annulla -
    -

    - Il territorio da creare non c'è? Si crea dall'editor, senza perdere il disegno. -

    -
    - @endif -
  • - @endforeach -
- @endif -
- {{-- ── Storico versioni PDF ──────────────────────────────────────────── --}}
-

Storico versioni PDF

-

I PDF sostituiti da una piantina. Il ripristino è sempre reversibile.

+

Storico versioni PDF {{ $versioni->total() }}

+

+ I PDF dei territori sostituiti da una pubblicazione o da un ripristino. Ripristinare una versione + rimette in uso quel PDF: la piantina del territorio torna "da pubblicare". +

@if($versioni->isEmpty()) @@ -205,6 +212,10 @@
+ +
{{ $versioni->links() }}
@endif + + diff --git a/resources/views/livewire/piantine/pubblicazione.blade.php b/resources/views/livewire/piantine/pubblicazione.blade.php new file mode 100644 index 0000000..754cde2 --- /dev/null +++ b/resources/views/livewire/piantine/pubblicazione.blade.php @@ -0,0 +1,54 @@ +
+ @if($mappaId) +
+ +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 2884836..9fbc8a5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -171,7 +171,9 @@ Route::middleware('auth')->group(function () { // schermo intero perché la mappa vuole tutto lo spazio che c'è. Route::get('piantine', PiantineManager::class)->name('piantine.index'); Route::get('piantine/editor/{mappa?}', PiantineEditor::class)->name('piantine.editor'); - Route::get('piantine/{mappa}/anteprima', PiantinaAnteprimaController::class)->name('piantine.anteprima'); + Route::get('piantine/anteprima/{token}', PiantinaAnteprimaController::class) + ->where('token', '[A-Za-z0-9]{40}') + ->name('piantine.anteprima'); Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql'); Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml'); Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip'); diff --git a/tests/Feature/PiantineEditorTest.php b/tests/Feature/PiantineEditorTest.php index ef4d928..d58b16d 100644 --- a/tests/Feature/PiantineEditorTest.php +++ b/tests/Feature/PiantineEditorTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Livewire\Piantine\PiantineEditor; +use App\Livewire\Piantine\Pubblicazione; use App\Models\Setting; use App\Models\Territorio; use App\Models\TerritorioMappa; @@ -17,8 +18,9 @@ use Spatie\Permission\PermissionRegistrar; use Tests\TestCase; /** - * L'editor a schermo intero: si disegna prima e si decide dopo a chi - * appartiene il disegno — territorio compreso, che può nascere qui. + * L'editor a schermo intero: il disegno è bozza e si salva da solo, i dati + * del territorio si salvano subito nel gestionale, il PDF non si tocca finché + * non si pubblica. */ class PiantineEditorTest extends TestCase { @@ -29,6 +31,11 @@ class PiantineEditorTest extends TestCase 'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]], ]; + private const ALTRA_GEOMETRIA = [ + 'type' => 'Polygon', + 'coordinates' => [[[13.40, 38.11], [13.42, 38.11], [13.42, 38.13], [13.40, 38.11]]], + ]; + protected function setUp(): void { parent::setUp(); @@ -53,67 +60,242 @@ class PiantineEditorTest extends TestCase $this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk(); } - public function test_una_piantina_si_salva_senza_nessun_territorio(): void + public function test_la_pagina_si_apre_su_una_piantina_esistente(): void + { + $mappa = TerritorioMappa::create(['nome' => 'Zona porto', 'geojson' => json_encode(self::GEOMETRIA)]); + + $this->actingAs($this->admin()) + ->get(route('piantine.editor', $mappa->id)) + ->assertOk() + ->assertSee('Zona porto'); + } + + public function test_un_disegno_crea_subito_una_bozza_senza_territorio(): void { Livewire::actingAs($this->admin()) ->test(PiantineEditor::class) - ->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) - ->set('nome', 'Zona porto — lato nord') - ->call('salva'); + ->dispatch('geometria-modificata', geometry: self::GEOMETRIA, nuova: true) + ->assertDispatched('piantina-aggiornata') + ->assertDispatched('editor-indirizzo') + ->set('nome', 'Zona porto — lato nord'); $mappa = TerritorioMappa::sole(); $this->assertNull($mappa->territorio_id); $this->assertSame('Zona porto — lato nord', $mappa->nome); $this->assertSame(self::GEOMETRIA, $mappa->geometria()); - $this->assertSame('Zona porto — lato nord', $mappa->etichetta()); + $this->assertSame(TerritorioMappa::STATO_BOZZA, $mappa->stato()); } - public function test_il_territorio_si_crea_dalla_scheda_con_tutte_le_proprieta(): void + public function test_spostare_i_vertici_aggiorna_la_piantina_aperta(): void + { + $mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->dispatch('geometria-modificata', geometry: self::ALTRA_GEOMETRIA, nuova: false); + + $this->assertSame(1, TerritorioMappa::count()); + $this->assertSame(self::ALTRA_GEOMETRIA, $mappa->fresh()->geometria()); + } + + public function test_un_disegno_nuovo_con_una_piantina_aperta_non_ne_sostituisce_il_confine(): void + { + $mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->dispatch('geometria-modificata', geometry: self::ALTRA_GEOMETRIA, nuova: true); + + $this->assertSame(2, TerritorioMappa::count()); + $this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria()); + } + + public function test_una_geometria_non_valida_viene_respinta(): void + { + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class) + ->dispatch('geometria-modificata', geometry: ['type' => 'Point', 'coordinates' => [1, 2]], nuova: true) + ->dispatch('geometria-modificata', geometry: [ + 'type' => 'Polygon', + 'coordinates' => [[[0, 0], [1, 0], [0, 0]]], + ], nuova: true) + ->dispatch('geometria-modificata', geometry: [ + 'type' => 'Polygon', + 'coordinates' => [[[0, 0], [500, 0], [1, 1], [0, 0]]], + ], nuova: true) + ->assertDispatched('avviso'); + + $this->assertSame(0, TerritorioMappa::count()); + } + + public function test_il_territorio_si_crea_dall_editor_con_tutte_le_proprieta(): void { $zona = Zona::create(['nome' => 'Nord', 'attivo' => true]); $tipologia = Tipologia::create(['nome' => 'Residenziale', 'attivo' => true]); + $mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); Livewire::actingAs($this->admin()) - ->test(PiantineEditor::class) - ->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) ->set('territorioScelto', 'nuovo') ->set('numero', '42') ->set('zonaId', (string) $zona->id) ->set('tipologiaId', (string) $tipologia->id) ->set('confini', "Nord: via Roma\nSud: via Milano") - ->call('salva') - ->assertHasNoErrors(); + ->call('creaTerritorio') + ->assertHasNoErrors() + ->assertSet('territorioScelto', fn ($v) => $v !== 'nuovo' && $v !== ''); $territorio = Territorio::where('numero', '42')->sole(); $this->assertTrue((bool) $territorio->attivo); $this->assertSame($zona->id, $territorio->zona_id); $this->assertSame($tipologia->id, $territorio->tipologia_id); $this->assertStringContainsString('via Roma', $territorio->confini); - $this->assertSame($territorio->id, TerritorioMappa::sole()->territorio_id); + $this->assertSame($territorio->id, $mappa->fresh()->territorio_id); } - public function test_la_scheda_aggiorna_un_territorio_gia_esistente(): void + public function test_in_creazione_i_campi_non_salvano_niente_prima_del_pulsante(): void { - $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + $mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); Livewire::actingAs($this->admin()) - ->test(PiantineEditor::class) - ->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->set('territorioScelto', 'nuovo') + ->set('numero', '4'); + + $this->assertSame(0, Territorio::count()); + $this->assertNull($mappa->fresh()->territorio_id); + } + + public function test_un_numero_gia_in_uso_non_crea_un_secondo_territorio(): void + { + Territorio::create(['numero' => '42', 'attivo' => true]); + $mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->set('territorioScelto', 'nuovo') + ->set('numero', '42') + ->call('creaTerritorio') + ->assertHasErrors('numero'); + + $this->assertSame(1, Territorio::where('numero', '42')->count()); + $this->assertNull($mappa->fresh()->territorio_id); + } + + public function test_scegliere_un_territorio_collega_subito_la_piantina(): void + { + $territorio = Territorio::create(['numero' => '12', 'attivo' => true, 'confini' => 'Nord: via Verdi']); + $mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) ->set('territorioScelto', (string) $territorio->id) - ->set('numero', '12') - ->set('confini', 'Nord: via Verdi') - ->call('salva') + ->assertHasNoErrors() + ->assertSet('numero', '12') + ->assertSet('confini', 'Nord: via Verdi'); + + $this->assertSame($territorio->id, $mappa->fresh()->territorio_id); + } + + /** La tendina leggeva `attivo` senza averlo caricato: tutti "disattivati". */ + public function test_la_tendina_segna_disattivato_solo_chi_lo_e(): void + { + Territorio::create(['numero' => '12', 'attivo' => true]); + $mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->assertSee('Territorio 12') + ->assertDontSee('(disattivato)'); + } + + public function test_i_dati_del_territorio_si_salvano_subito_campo_per_campo(): void + { + $zona = Zona::create(['nome' => 'Sud', 'attivo' => true]); + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + $mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->set('confini', 'Est: via Garibaldi') + ->set('zonaId', (string) $zona->id) ->assertHasNoErrors(); - $this->assertSame('Nord: via Verdi', $territorio->fresh()->confini); - $this->assertSame(1, Territorio::count()); + $territorio->refresh(); + $this->assertSame('Est: via Garibaldi', $territorio->confini); + $this->assertSame($zona->id, $territorio->zona_id); + } + + public function test_un_campo_non_valido_non_trascina_gli_altri(): void + { + Territorio::create(['numero' => '7', 'attivo' => true]); + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + $mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->set('numero', '7') + ->assertHasErrors('numero') + ->set('confini', 'Ovest: via Dante'); + + $territorio->refresh(); + $this->assertSame('12', $territorio->numero); + $this->assertSame('Ovest: via Dante', $territorio->confini); + } + + public function test_non_si_ruba_il_territorio_a_un_altra_piantina(): void + { + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]); + $seconda = TerritorioMappa::create(['geojson' => json_encode(self::ALTRA_GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $seconda->id]) + ->set('territorioScelto', (string) $territorio->id) + ->assertHasErrors('territorioScelto') + ->assertSet('territorioScelto', ''); + + $this->assertNull($seconda->fresh()->territorio_id); + } + + public function test_scollegare_lascia_un_etichetta_riconoscibile(): void + { + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + $mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->set('territorioScelto', ''); + + $mappa->refresh(); + $this->assertNull($mappa->territorio_id); + $this->assertSame('Ex territorio 12', $mappa->nome); + $this->assertNotNull(Territorio::find($territorio->id)); + } + + public function test_un_territorio_nel_cestino_resta_collegato(): void + { + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + $mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]); + $territorio->delete(); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->assertSet('territorioScelto', (string) $territorio->id) + ->set('nome', 'Prova'); + + $mappa->refresh(); + $this->assertSame($territorio->id, $mappa->territorio_id); + $this->assertNotNull($mappa->motivoNonPubblicabile()); } public function test_zona_e_tipologia_si_creano_dall_editor_e_restano_selezionate(): void { + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + $mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]); + $componente = Livewire::actingAs($this->admin()) - ->test(PiantineEditor::class) - ->set('territorioScelto', 'nuovo') + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) ->set('nuovaZona', 'Litorale') ->call('creaZona') ->assertHasNoErrors() @@ -126,6 +308,10 @@ class PiantineEditorTest extends TestCase $componente->assertSet('zonaId', (string) $zona->id); $componente->assertSet('tipologiaId', (string) $tipologia->id); + + $territorio->refresh(); + $this->assertSame($zona->id, $territorio->zona_id); + $this->assertSame($tipologia->id, $territorio->tipologia_id); } public function test_una_zona_gia_esistente_non_viene_duplicata(): void @@ -141,70 +327,80 @@ class PiantineEditorTest extends TestCase $this->assertSame(1, Zona::where('nome', 'Nord')->count()); } - public function test_un_numero_gia_in_uso_non_crea_un_secondo_territorio(): void + public function test_pubblica_apre_la_finestra_solo_se_si_puo(): void { - Territorio::create(['numero' => '42', 'attivo' => true]); + $scollegata = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); Livewire::actingAs($this->admin()) - ->test(PiantineEditor::class) - ->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) - ->set('territorioScelto', 'nuovo') - ->set('numero', '42') - ->call('salva') - ->assertHasErrors('numero'); + ->test(PiantineEditor::class, ['mappa' => $scollegata->id]) + ->call('pubblica') + ->assertNotDispatched('pubblica-piantina') + ->assertDispatched('avviso'); - $this->assertSame(1, Territorio::where('numero', '42')->count()); - $this->assertSame(0, TerritorioMappa::count()); + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + $collegata = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::ALTRA_GEOMETRIA)]); + + Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class, ['mappa' => $collegata->id]) + ->call('pubblica') + ->assertDispatchedTo(Pubblicazione::class, 'pubblica-piantina', mappaId: $collegata->id); } - public function test_ridisegnare_sostituisce_il_poligono_senza_duplicare_la_piantina(): void + public function test_scartare_le_modifiche_riporta_il_confine_pubblicato(): void { + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); $mappa = TerritorioMappa::create([ - 'nome' => 'Zona porto', - 'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}', + 'territorio_id' => $territorio->id, + 'geojson' => json_encode(self::ALTRA_GEOMETRIA), + 'geojson_pubblicato' => json_encode(self::GEOMETRIA), + 'pubblicata_at' => now(), ]); Livewire::actingAs($this->admin()) ->test(PiantineEditor::class, ['mappa' => $mappa->id]) - ->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) - ->call('salva'); + ->call('scartaModifiche'); - $this->assertSame(1, TerritorioMappa::count()); $this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria()); } - public function test_una_geometria_non_valida_viene_ignorata(): void - { - Livewire::actingAs($this->admin()) - ->test(PiantineEditor::class) - ->dispatch('poligono-disegnato', geometry: ['type' => 'Point', 'coordinates' => [1, 2]]) - ->call('salva'); - - $this->assertSame(0, TerritorioMappa::count()); - } - - public function test_non_si_ruba_il_territorio_a_un_altra_piantina(): void + public function test_eliminare_toglie_disegno_e_immagine_ma_non_il_territorio(): void { + Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'x'); $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); - TerritorioMappa::create([ + $mappa = TerritorioMappa::create([ 'territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA), + 'image_path' => 'territori-mappe/piantina-1.jpg', ]); Livewire::actingAs($this->admin()) - ->test(PiantineEditor::class) - ->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) - ->set('territorioScelto', (string) $territorio->id) - ->set('numero', $territorio->numero) - ->call('salva'); + ->test(PiantineEditor::class, ['mappa' => $mappa->id]) + ->call('elimina') + ->assertSet('mappaId', null) + ->assertDispatched('piantina-rimossa', mappaId: $mappa->id); - // La seconda piantina non deve essere stata creata. - $this->assertSame(1, TerritorioMappa::count()); + $this->assertSame(0, TerritorioMappa::count()); + $this->assertNotNull(Territorio::find($territorio->id)); + Storage::disk('public')->assertMissing('territori-mappe/piantina-1.jpg'); + } + + public function test_dati_mappa_esclude_le_geometrie_illeggibili(): void + { + TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); + TerritorioMappa::create(['geojson' => 'non è json']); + + $dati = Livewire::actingAs($this->admin()) + ->test(PiantineEditor::class) + ->instance() + ->datiMappa(app(\App\Services\Piantine\PiantineArea::class)); + + $this->assertCount(1, $dati['piantine']); + $this->assertSame(TerritorioMappa::STATO_BOZZA, $dati['piantine'][0]['stato']); } private function admin(): User { - Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web'])); + Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']); app(PermissionRegistrar::class)->forgetCachedPermissions(); Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); diff --git a/tests/Feature/PiantineManagerTest.php b/tests/Feature/PiantineManagerTest.php index d86ed3e..67abf42 100644 --- a/tests/Feature/PiantineManagerTest.php +++ b/tests/Feature/PiantineManagerTest.php @@ -17,7 +17,7 @@ use Tests\TestCase; /** * La pagina di gestione delle piantine: accesso riservato, area della città, - * e il legame — che si può fare e disfare — fra una piantina e un territorio. + * stato di ogni piantina con ricerca e filtri, pubblicazione e storico. */ class PiantineManagerTest extends TestCase { @@ -77,50 +77,6 @@ XML; ->assertHasErrors('kmlFile'); } - public function test_una_piantina_si_collega_a_un_territorio(): void - { - $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); - $mappa = $this->piantina(['nome' => 'Zona porto']); - - Livewire::actingAs($this->admin()) - ->test(PiantineManager::class) - ->call('apriCollegamento', $mappa->id) - ->set('territorioDaCollegare', $territorio->id) - ->call('collega', $mappa->id); - - $this->assertSame($territorio->id, $mappa->fresh()->territorio_id); - } - - public function test_un_territorio_che_ha_gia_una_piantina_non_ne_accetta_una_seconda(): void - { - $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); - $this->piantina(['territorio_id' => $territorio->id]); - $seconda = $this->piantina(['nome' => 'Doppione']); - - Livewire::actingAs($this->admin()) - ->test(PiantineManager::class) - ->call('apriCollegamento', $seconda->id) - ->set('territorioDaCollegare', $territorio->id) - ->call('collega', $seconda->id); - - $this->assertNull($seconda->fresh()->territorio_id); - } - - public function test_scollegare_conserva_la_piantina_e_le_da_un_nome(): void - { - $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); - $mappa = $this->piantina(['territorio_id' => $territorio->id]); - - Livewire::actingAs($this->admin()) - ->test(PiantineManager::class) - ->call('scollega', $mappa->id); - - $mappa->refresh(); - $this->assertNull($mappa->territorio_id); - $this->assertSame('Ex territorio 12', $mappa->nome); - $this->assertDatabaseCount('territorio_mappe', 1); - } - /** * La cascata di prima si portava via il disegno insieme al territorio — e * l'import XML svuota `territori` in blocco a ogni ripristino. @@ -136,52 +92,128 @@ XML; $this->assertNull($mappa->fresh()->territorio_id); } - public function test_senza_territorio_il_pdf_non_si_genera(): void + public function test_l_elenco_mostra_lo_stato_di_ogni_piantina(): void { - $mappa = $this->piantina(['nome' => 'Zona porto']); + [$territorio, $pubblicata] = $this->pubblicata('7'); + $this->piantina(['nome' => 'Zona porto']); Livewire::actingAs($this->admin()) ->test(PiantineManager::class) - ->call('generaPdf', $mappa->id); - - $this->assertDatabaseCount('territorio_pdf_versioni', 0); + ->assertSee('Terr. 7') + ->assertSee('Zona porto') + ->assertSee('Pubblicata') + ->assertSee('Bozza') + ->assertSee('Scollegata'); } - /** - * L'anteprima serve a guardare la cartolina PRIMA di mandarla in vigore: - * se toccasse pdf_path o lo storico, non sarebbe un'anteprima. - */ - public function test_l_anteprima_mostra_il_pdf_senza_sostituire_niente(): void + public function test_i_filtri_separano_bozze_pubblicate_e_da_pubblicare(): void { - $territorio = Territorio::create(['numero' => '7', 'attivo' => true, 'confini' => 'Nord: via Roma']); - $mappa = $this->piantina([ + [, $pubblicata] = $this->pubblicata('7'); + [$t8, $modificata] = $this->pubblicata('8'); + $t8->update(['confini' => 'cambiati dopo la pubblicazione']); + $this->piantina(['nome' => 'Zona porto']); + + $componente = Livewire::actingAs($this->admin())->test(PiantineManager::class); + + $componente->set('filtro', 'pubblicate') + ->assertViewHas('elenco', fn ($e) => $e->pluck('id')->all() === [$pubblicata->id]); + + $componente->set('filtro', 'da-pubblicare') + ->assertViewHas('elenco', fn ($e) => $e->pluck('id')->all() === [$modificata->id]); + + $componente->set('filtro', 'scollegate') + ->assertViewHas('elenco', fn ($e) => $e->count() === 1 && $e->first()->nome === 'Zona porto'); + + $componente->set('filtro', 'inventato') + ->assertSet('filtro', 'tutte') + ->assertViewHas('elenco', fn ($e) => $e->count() === 3); + } + + public function test_la_ricerca_trova_numero_nome_e_zona(): void + { + $zona = \App\Models\Zona::create(['nome' => 'Litorale', 'attivo' => true]); + $territorio = Territorio::create(['numero' => '12', 'attivo' => true, 'zona_id' => $zona->id]); + $this->piantina(['territorio_id' => $territorio->id]); + $this->piantina(['nome' => 'Zona porto']); + + Livewire::actingAs($this->admin()) + ->test(PiantineManager::class) + ->set('cerca', 'litor') + ->assertViewHas('elenco', fn ($e) => $e->count() === 1 && $e->first()->territorio_id === $territorio->id) + ->set('cerca', 'PORTO') + ->assertViewHas('elenco', fn ($e) => $e->count() === 1 && $e->first()->nome === 'Zona porto'); + } + + public function test_pubblica_apre_la_finestra_condivisa_solo_se_si_puo(): void + { + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + $collegata = $this->piantina(['territorio_id' => $territorio->id]); + $scollegata = $this->piantina(['nome' => 'Zona porto']); + + Livewire::actingAs($this->admin()) + ->test(PiantineManager::class) + ->call('pubblica', $collegata->id) + ->assertDispatchedTo(\App\Livewire\Piantine\Pubblicazione::class, 'pubblica-piantina', mappaId: $collegata->id); + + Livewire::actingAs($this->admin()) + ->test(PiantineManager::class) + ->call('pubblica', $scollegata->id) + ->assertNotDispatched('pubblica-piantina') + ->assertDispatched('avviso'); + } + + public function test_eliminare_toglie_la_piantina_ma_non_il_territorio(): void + { + $territorio = Territorio::create(['numero' => '12', 'attivo' => true]); + $mappa = $this->piantina(['territorio_id' => $territorio->id]); + + Livewire::actingAs($this->admin()) + ->test(PiantineManager::class) + ->call('elimina', $mappa->id) + ->assertDispatched('avviso'); + + $this->assertDatabaseCount('territorio_mappe', 0); + $this->assertNotNull($territorio->fresh()); + } + + public function test_ripristinare_una_versione_riporta_la_piantina_da_pubblicare(): void + { + [$territorio, $mappa] = $this->pubblicata('7'); + + Storage::disk('public')->put('territori-pdf-versioni/vecchia.pdf', '%PDF-vecchia'); + $versione = \App\Models\TerritorioPdfVersione::create([ 'territorio_id' => $territorio->id, - 'image_path' => 'territori-mappe/piantina-prova.png', + 'pdf_path' => 'territori-pdf-versioni/vecchia.pdf', + 'origine' => 'piantine', + 'sostituito_il' => now()->subDay(), ]); - $immagine = imagecreatetruecolor(80, 50); - ob_start(); - imagepng($immagine); - Storage::disk('public')->put($mappa->image_path, ob_get_clean()); - imagedestroy($immagine); + Livewire::actingAs($this->admin()) + ->test(PiantineManager::class) + ->call('ripristinaVersione', $versione->id) + ->assertDispatched('avviso'); - $risposta = $this->actingAs($this->admin())->get(route('piantine.anteprima', $mappa)); - - $risposta->assertOk(); - $risposta->assertHeader('Content-Type', 'application/pdf'); - $this->assertStringStartsWith('%PDF', $risposta->getContent()); - - $this->assertNull($territorio->fresh()->pdf_path); - $this->assertSame(0, \App\Models\TerritorioPdfVersione::count()); + $this->assertStringStartsWith('territori-pdf/ripristino-', $territorio->fresh()->pdf_path); + $this->assertSame(TerritorioMappa::STATO_MODIFICATA, $mappa->fresh()->stato()); } - public function test_l_anteprima_di_una_piantina_scollegata_non_esiste(): void + /** Una piantina collegata e segnata come pubblicata, senza passare dal renderer. */ + private function pubblicata(string $numero): array { - $mappa = $this->piantina(['nome' => 'Scollegata']); + $territorio = Territorio::create(['numero' => $numero, 'attivo' => true]); + $pdf = 'territori-pdf/piantina-' . $territorio->id . '-20261001-120000.pdf'; + Storage::disk('public')->put($pdf, '%PDF-piantina'); + $territorio->update(['pdf_path' => $pdf]); - $this->actingAs($this->admin()) - ->get(route('piantine.anteprima', $mappa)) - ->assertNotFound(); + $mappa = $this->piantina(['territorio_id' => $territorio->id]); + $mappa->load('territorio.zona'); + $mappa->update([ + 'geojson_pubblicato' => $mappa->geojson, + 'impronta_pubblicata' => $mappa->impronta(), + 'pubblicata_at' => now(), + ]); + + return [$territorio, $mappa->fresh()]; } private function piantina(array $valori = []): TerritorioMappa @@ -193,7 +225,7 @@ XML; private function admin(): User { - Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web'])); + Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']); app(PermissionRegistrar::class)->forgetCachedPermissions(); Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); diff --git a/tests/Feature/PubblicazionePiantinaTest.php b/tests/Feature/PubblicazionePiantinaTest.php new file mode 100644 index 0000000..f22775b --- /dev/null +++ b/tests/Feature/PubblicazionePiantinaTest.php @@ -0,0 +1,286 @@ + 'Polygon', + 'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]], + ]; + + protected function setUp(): void + { + parent::setUp(); + $this->withoutVite(); + Storage::fake('public'); + Storage::fake('local'); + config(['piantine.formato' => 'jpeg']); + + // Un JPEG vero: FPDF lo legge davvero quando compone il PDF. + $immagine = imagecreatetruecolor(80, 50); + imagefilledrectangle($immagine, 0, 0, 79, 49, imagecolorallocate($immagine, 230, 230, 230)); + ob_start(); + imagejpeg($immagine); + $jpeg = ob_get_clean(); + imagedestroy($immagine); + + $this->jpeg = $jpeg; + } + + private string $jpeg; + + private function rendererRisponde(): void + { + Http::fake(['*/render' => Http::response($this->jpeg)]); + } + + public function test_l_anteprima_si_prepara_senza_toccare_territorio_ne_piantina(): void + { + [$territorio, $mappa] = $this->piantinaCollegata(); + $servizio = app(PubblicazionePiantina::class); + + // In test la coda è sincrona: il job ha già lavorato al ritorno. + $token = $servizio->avvia($mappa); + + $this->assertSame(PubblicazionePiantina::PRONTA, $servizio->stato($token)['stato']); + $this->assertStringStartsWith('%PDF', file_get_contents($servizio->pdfAnteprima($token))); + + $this->assertNull($territorio->fresh()->pdf_path); + $this->assertNull($mappa->fresh()->pubblicata_at); + $this->assertNull($mappa->fresh()->image_path); + $this->assertSame(TerritorioMappa::STATO_BOZZA, $mappa->fresh()->stato()); + } + + public function test_il_pdf_dell_anteprima_si_vede_solo_con_il_token_giusto(): void + { + [, $mappa] = $this->piantinaCollegata(); + $token = app(PubblicazionePiantina::class)->avvia($mappa); + + $this->actingAs($this->admin()) + ->get(route('piantine.anteprima', $token)) + ->assertOk() + ->assertHeader('Content-Type', 'application/pdf'); + + $this->actingAs($this->admin()) + ->get(route('piantine.anteprima', str_repeat('a', 40))) + ->assertNotFound(); + } + + public function test_la_conferma_sostituisce_il_pdf_e_archivia_il_precedente(): void + { + [$territorio, $mappa] = $this->piantinaCollegata(); + Storage::disk('public')->put('territori-pdf/vecchio.pdf', '%PDF-vecchio'); + $territorio->update(['pdf_path' => 'territori-pdf/vecchio.pdf']); + + $servizio = app(PubblicazionePiantina::class); + $token = $servizio->avvia($mappa); + + $this->actingAs($this->admin()); + $servizio->conferma($token); + + $territorio->refresh(); + $mappa->refresh(); + + $this->assertStringStartsWith('territori-pdf/piantina-' . $territorio->id . '-', $territorio->pdf_path); + Storage::disk('public')->assertExists($territorio->pdf_path); + $this->assertSame(1, TerritorioPdfVersione::where('territorio_id', $territorio->id)->count()); + + $this->assertNotNull($mappa->image_path); + Storage::disk('public')->assertExists($mappa->image_path); + $this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->stato()); + $this->assertSame($mappa->geojson, $mappa->geojson_pubblicato); + + // L'anteprima confermata non resta in giro. + $this->assertNull($servizio->stato($token)); + $this->assertSame([], Storage::disk('local')->files('piantine-anteprime')); + } + + public function test_se_la_piantina_cambia_dopo_l_anteprima_la_conferma_viene_rifiutata(): void + { + [$territorio, $mappa] = $this->piantinaCollegata(); + $servizio = app(PubblicazionePiantina::class); + $token = $servizio->avvia($mappa); + + // I confini sono stampati sulla cartolina: cambiarli rende vecchia l'anteprima. + $territorio->update(['confini' => 'Nord: via Nuova']); + + try { + $servizio->conferma($token); + $this->fail('La conferma doveva essere rifiutata.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('cambiat', $e->getMessage()); + } + + $this->assertNull($territorio->fresh()->pdf_path); + $this->assertNull($mappa->fresh()->pubblicata_at); + } + + public function test_una_piantina_scollegata_non_si_pubblica(): void + { + $mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); + + $this->expectException(RuntimeException::class); + app(PubblicazionePiantina::class)->avvia($mappa); + } + + public function test_un_renderer_che_non_risponde_lascia_un_errore_leggibile(): void + { + Http::fake(['*/render' => Http::response(['error' => 'area troppo estesa'], 413)]); + [, $mappa] = $this->piantinaCollegata(renderer: false); + + $servizio = app(PubblicazionePiantina::class); + $token = $servizio->avvia($mappa); + + $stato = $servizio->stato($token); + $this->assertSame(PubblicazionePiantina::ERRORE, $stato['stato']); + $this->assertStringContainsString('area troppo estesa', $stato['messaggio']); + $this->assertNull($servizio->pdfAnteprima($token)); + } + + public function test_annullare_butta_l_anteprima(): void + { + [, $mappa] = $this->piantinaCollegata(); + $servizio = app(PubblicazionePiantina::class); + $token = $servizio->avvia($mappa); + + $servizio->annulla($token); + + $this->assertNull($servizio->stato($token)); + $this->assertSame([], Storage::disk('local')->files('piantine-anteprime')); + } + + public function test_la_finestra_porta_dalla_richiesta_alla_conferma(): void + { + [$territorio, $mappa] = $this->piantinaCollegata(); + + Livewire::actingAs($this->admin()) + ->test(Pubblicazione::class) + ->dispatch('pubblica-piantina', mappaId: $mappa->id) + ->assertSet('fase', PubblicazionePiantina::PRONTA) + ->assertSee('Anteprima del PDF da pubblicare', false) + ->call('conferma') + ->assertSet('mappaId', null) + ->assertDispatched('piantina-pubblicata', mappaId: $mappa->id); + + $this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->fresh()->stato()); + $this->assertNotNull($territorio->fresh()->pdf_path); + } + + public function test_chiudere_la_finestra_non_pubblica_niente(): void + { + [$territorio, $mappa] = $this->piantinaCollegata(); + + Livewire::actingAs($this->admin()) + ->test(Pubblicazione::class) + ->dispatch('pubblica-piantina', mappaId: $mappa->id) + ->call('chiudi') + ->assertSet('mappaId', null); + + $this->assertNull($territorio->fresh()->pdf_path); + $this->assertSame([], Storage::disk('local')->files('piantine-anteprime')); + } + + // ─── Stati del modello ────────────────────────────────────────────────── + + public function test_dopo_la_pubblicazione_ogni_dato_stampato_riporta_la_piantina_da_pubblicare(): void + { + [$territorio, $mappa] = $this->piantinaCollegata(); + $servizio = app(PubblicazionePiantina::class); + $this->actingAs($this->admin()); + $servizio->conferma($servizio->avvia($mappa)); + + $this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->fresh()->stato()); + + $territorio->update(['zona_id' => Zona::create(['nome' => 'Altra', 'attivo' => true])->id]); + $this->assertSame(TerritorioMappa::STATO_MODIFICATA, $mappa->fresh()->stato()); + } + + public function test_un_pdf_sostituito_per_altre_vie_riporta_la_piantina_da_pubblicare(): void + { + [$territorio, $mappa] = $this->piantinaCollegata(); + $servizio = app(PubblicazionePiantina::class); + $this->actingAs($this->admin()); + $servizio->conferma($servizio->avvia($mappa)); + + $territorio->update(['pdf_path' => 'territori-pdf/caricato-a-mano.pdf']); + + $this->assertSame(TerritorioMappa::STATO_MODIFICATA, $mappa->fresh()->stato()); + } + + public function test_motivo_non_pubblicabile_dice_cosa_manca(): void + { + $senzaDisegno = TerritorioMappa::create(['geojson' => 'rotto']); + $this->assertStringContainsString('Disegna', $senzaDisegno->motivoNonPubblicabile()); + + $scollegata = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]); + $this->assertStringContainsString('Collega', $scollegata->motivoNonPubblicabile()); + + [$territorio, $mappa] = $this->piantinaCollegata(); + $this->assertNull($mappa->motivoNonPubblicabile()); + + $territorio->delete(); + $this->assertStringContainsString('cestino', $mappa->fresh()->motivoNonPubblicabile()); + $this->assertSame('Terr. 7', $mappa->fresh()->etichetta()); + } + + private function piantinaCollegata(bool $renderer = true): array + { + if ($renderer) { + $this->rendererRisponde(); + } + + $zona = Zona::firstOrCreate(['nome' => 'Centro'], ['attivo' => true]); + $territorio = Territorio::create([ + 'numero' => '7', + 'attivo' => true, + 'zona_id' => $zona->id, + 'confini' => 'Nord: via Roma', + ]); + $mappa = TerritorioMappa::create([ + 'territorio_id' => $territorio->id, + 'geojson' => json_encode(self::GEOMETRIA), + ]); + + return [$territorio, $mappa]; + } + + private function admin(): User + { + Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); + + $utente = User::firstOrCreate( + ['email' => 'admin@example.test'], + ['name' => 'Amministratore', 'password' => 'password-di-prova'], + ); + $utente->assignRole('amministratore'); + + return $utente; + } +}