Editor piantine: bozza automatica e pubblicazione con anteprima

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:08:34 +02:00
parent d433c9392d
commit 056d95cfcb
25 changed files with 3084 additions and 980 deletions

3
.gitattributes vendored Normal file
View File

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

View File

@@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
può dare: perché le cose sono come sono, e cosa resta aperto. può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: **28 agosto 2026**. Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
--- ---
@@ -75,6 +75,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 ## Da dove veniamo
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
@@ -212,13 +280,48 @@ non esiste, lo si crea dall'editor col solo numero. La chiave esterna è
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare `nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
`territori` come fa ogni import XML, non porta più via i disegni. `territori` come fa ogni import XML, non porta più via i disegni.
**Due pagine, non una.** `/piantine` tiene area, generazione, collegamenti e **Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni
storico; `/piantine/editor` è l'editor a schermo intero, con un telaio suo piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate /
(`layouts/editor`) senza barra laterale. Tagliare un confine su una mappa larga scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre,
un terzo di schermo vuol dire sbagliarlo, e quel disegno finisce stampato. si pubblica e si elimina. `/piantine/editor` è l'editor a schermo intero, con
un telaio suo (`layouts/editor`) senza barra laterale: tagliare un confine su
una mappa larga un terzo di schermo vuol dire sbagliarlo. Elenco e pannello
galleggiano sopra la mappa; barra strumenti e controlli Leaflet si spostano
nello spazio libero (variabili `--libero-sx`/`--libero-dx`); sotto i 720px il
pannello sale dal basso. Su tablet i vertici sono più grandi (`pointer: coarse`).
La rigenerazione massiva è stata tolta: job, stato in cache e interfaccia. Si **Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo:
rigenera un territorio alla volta dall'elenco. il confine a ogni disegno o spostamento di vertice, nome e collegamento appena
cambiano. È bozza: il PDF del territorio non si tocca. I dati del territorio
(numero, zona, tipologia, confini) sono invece dati del gestionale e si salvano
subito, colonna per colonna (un campo non valido non trascina gli altri). Il
PDF cambia solo con **Pubblica**, uguale in editor e gestione
(`Livewire\Piantine\Pubblicazione`): `PubblicazionePiantina::avvia()` mette in
coda `PreparaAnteprimaPiantina` (il primo rendering può superare i 100 s di
Cloudflare), che genera immagine e PDF sul disco **privato**
(`storage/app/piantine-anteprime/`, stato in cache sotto un token di 40
caratteri, servito da `piantine.anteprima`); la finestra controlla ogni 2 s e
mostra il PDF; solo **Conferma** lo rende ufficiale. Se nel frattempo è cambiato
qualcosa di stampato, la conferma rifiuta.
Gli stati (`TerritorioMappa::stato()`): **Bozza** (mai pubblicata),
**Pubblicata**, **Modifiche da pubblicare**. Il confronto è sull'`impronta()`:
sha256 di confine, territorio, numero, nome della zona e confini — cambia uno
qualunque e la piantina torna da pubblicare anche col disegno identico. Vale
"Pubblicata" solo se anche il PDF in uso è ancora uno composto da una piantina
(`territori-pdf/piantina-{territorio}-…`): un ripristino dallo storico, un
upload a mano o un import la riportano "da pubblicare". "Ripristina il confine
pubblicato" riporta la bozza a `geojson_pubblicato`. La migrazione
`2026_10_09_000010` ha segnato pubblicate le piantine esistenti solo se il PDF
in vigore del territorio veniva da una piantina.
Disegnare crea **sempre** una piantina nuova; per cambiare quella aperta ci
sono "Modifica vertici" e "Ridisegna confine", espliciti. Con la mappa si
parla per eventi: i dati di tutte le piantine li chiede il JS una volta
(`datiMappa()`), poi arriva solo la piantina cambiata (`piantina-aggiornata`).
Gli avvisi passano dall'evento `avviso` (trait `Livewire\Concerns\Avvisa`,
componente `<x-avvisi>`): i flash di sessione nel telaio non si vedono dopo
un'azione Livewire.
**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è **La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
@@ -234,11 +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 marca i territori collegati senza zona: senza, l'intestazione della cartolina
esce vuota e ce se ne accorge in stampa. esce vuota e ce se ne accorge in stampa.
**L'anteprima non tocca niente.** `piantine.anteprima` compone il PDF al volo e
lo serve `inline`: non aggiorna `pdf_path`, non archivia versioni, non lascia
file. «Genera PDF» manda il documento in vigore, e scoprire un confine sbagliato
dopo significa trovarselo già nello storico.
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte **L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`): che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,15 +11,29 @@ use Illuminate\Database\Eloquent\Model;
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il * cartografia, il numero si decide dopo. Finche' resta scollegato vale il
* campo `nome` come etichetta; una volta collegato comanda il numero del * campo `nome` come etichetta; una volta collegato comanda il numero del
* territorio, che e' il riferimento con cui lo chiamano tutti. * territorio, che e' il riferimento con cui lo chiamano tutti.
*
* `geojson` e' la bozza: l'editor la salva da solo a ogni modifica, e non
* tocca il PDF del territorio. Il PDF cambia solo con la pubblicazione, che
* ricorda cosa e' stato pubblicato (`geojson_pubblicato`,
* `impronta_pubblicata`, `pubblicata_at`).
*/ */
class TerritorioMappa extends Model class TerritorioMappa extends Model
{ {
public const STATO_BOZZA = 'bozza';
public const STATO_PUBBLICATA = 'pubblicata';
public const STATO_MODIFICATA = 'modificata';
protected $table = 'territorio_mappe'; protected $table = 'territorio_mappe';
protected $fillable = [ protected $fillable = [
'territorio_id', 'territorio_id',
'nome', 'nome',
'geojson', 'geojson',
'geojson_pubblicato',
'impronta_pubblicata',
'pubblicata_at',
'image_path', 'image_path',
'rendered_at', 'rendered_at',
]; ];
@@ -28,12 +42,19 @@ class TerritorioMappa extends Model
{ {
return [ return [
'rendered_at' => 'datetime', 'rendered_at' => 'datetime',
'pubblicata_at' => 'datetime',
]; ];
} }
/**
* withTrashed: un territorio nel cestino resta collegato. Senza, la
* relazione tornava null, l'editor mostrava la piantina come scollegata e
* il primo salvataggio cancellava il collegamento — che al ripristino del
* territorio non c'era piu'.
*/
public function territorio() public function territorio()
{ {
return $this->belongsTo(Territorio::class, 'territorio_id'); return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed();
} }
/** /**
@@ -42,15 +63,22 @@ class TerritorioMappa extends Model
*/ */
public function geometria(): ?array public function geometria(): ?array
{ {
$decoded = json_decode($this->geojson, true); $decoded = json_decode((string) $this->geojson, true);
return is_array($decoded) ? $decoded : null; return is_array($decoded) ? $decoded : null;
} }
/** Se è collegata a un territorio, il PDF si può generare. */ /** Collegata a un territorio vivo: è la condizione per pubblicare. */
public function collegata(): bool public function collegata(): bool
{ {
return $this->territorio_id !== null && $this->territorio !== null; return $this->territorio_id !== null
&& $this->territorio !== null
&& ! $this->territorio->trashed();
}
public function territorioNelCestino(): bool
{
return $this->territorio !== null && $this->territorio->trashed();
} }
/** /**
@@ -60,7 +88,7 @@ class TerritorioMappa extends Model
*/ */
public function etichetta(): string public function etichetta(): string
{ {
if ($this->collegata()) { if ($this->territorio !== null) {
return 'Terr. ' . $this->territorio->numero; return 'Terr. ' . $this->territorio->numero;
} }
@@ -68,4 +96,87 @@ class TerritorioMappa extends Model
return $nome !== '' ? $nome : 'Piantina #' . $this->id; return $nome !== '' ? $nome : 'Piantina #' . $this->id;
} }
// ─── Bozza e pubblicazione ──────────────────────────────────────────────
/**
* Impronta di tutto cio' che finisce nel PDF: confine, territorio e i
* suoi dati stampati sulla cartolina (numero, zona come «Località»,
* confini). Se cambia uno qualunque di questi, il PDF in vigore non
* corrisponde piu' e la piantina ha modifiche da pubblicare — anche quando
* il disegno e' rimasto lo stesso.
*/
public function impronta(): string
{
$territorio = $this->territorio;
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();
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -212,6 +212,11 @@
</main> </main>
</div> </div>
</div> </div>
{{-- Gli avvisi delle azioni Livewire (trait Avvisa). I flash della
richiesta restano nella fascia in testa al contenuto, qui sopra. --}}
<x-avvisi />
@livewireScripts @livewireScripts
</body> </body>
</html> </html>

View File

@@ -5,8 +5,8 @@
Serve a una cosa sola: la mappa dell'editor delle piantine, che deve Serve a una cosa sola: la mappa dell'editor delle piantine, che deve
prendersi tutta la finestra. I messaggi non spingono il contenuto verso il prendersi tutta la finestra. I messaggi non spingono il contenuto verso il
basso come nelle altre pagine — comparirebbe una fascia che sposta la mappa basso come nelle altre pagine — comparirebbe una fascia che sposta la mappa
mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro, mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro
e si tolgono da soli. (<x-avvisi>, che raccoglie anche quelli lanciati dalle azioni Livewire).
--}} --}}
<html lang="it" class="h-full tm-app"> <html lang="it" class="h-full tm-app">
<head> <head>
@@ -22,33 +22,7 @@
{{ $slot }} {{ $slot }}
</main> </main>
<div class="tm-avvisi" role="status" aria-live="polite"> <x-avvisi :sessione="true" />
@if (session()->has('success'))
<div x-data="{ mostra: true }" x-show="mostra" x-cloak
x-init="setTimeout(() => mostra = false, 4500)"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="tm-avviso tm-avviso-ok">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<span>{{ session('success') }}</span>
<button type="button" @click="mostra = false" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">&times;</button>
</div>
@endif
@if (session()->has('error'))
<div x-data="{ mostra: true }" x-show="mostra" x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="tm-avviso tm-avviso-errore">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>{{ session('error') }}</span>
<button type="button" @click="mostra = false" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">&times;</button>
</div>
@endif
</div>
@livewireScripts @livewireScripts
</body> </body>

View File

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

View File

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

View File

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

View File

@@ -171,7 +171,9 @@ Route::middleware('auth')->group(function () {
// schermo intero perché la mappa vuole tutto lo spazio che c'è. // schermo intero perché la mappa vuole tutto lo spazio che c'è.
Route::get('piantine', PiantineManager::class)->name('piantine.index'); Route::get('piantine', PiantineManager::class)->name('piantine.index');
Route::get('piantine/editor/{mappa?}', PiantineEditor::class)->name('piantine.editor'); 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/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml'); 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'); Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Livewire\Piantine\PiantineEditor; use App\Livewire\Piantine\PiantineEditor;
use App\Livewire\Piantine\Pubblicazione;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\TerritorioMappa; use App\Models\TerritorioMappa;
@@ -17,8 +18,9 @@ use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase; use Tests\TestCase;
/** /**
* L'editor a schermo intero: si disegna prima e si decide dopo a chi * L'editor a schermo intero: il disegno è bozza e si salva da solo, i dati
* appartiene il disegno — territorio compreso, che può nascere qui. * del territorio si salvano subito nel gestionale, il PDF non si tocca finché
* non si pubblica.
*/ */
class PiantineEditorTest extends TestCase 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]]], '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 protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@@ -53,67 +60,242 @@ class PiantineEditorTest extends TestCase
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk(); $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()) Livewire::actingAs($this->admin())
->test(PiantineEditor::class) ->test(PiantineEditor::class)
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) ->dispatch('geometria-modificata', geometry: self::GEOMETRIA, nuova: true)
->set('nome', 'Zona porto — lato nord') ->assertDispatched('piantina-aggiornata')
->call('salva'); ->assertDispatched('editor-indirizzo')
->set('nome', 'Zona porto — lato nord');
$mappa = TerritorioMappa::sole(); $mappa = TerritorioMappa::sole();
$this->assertNull($mappa->territorio_id); $this->assertNull($mappa->territorio_id);
$this->assertSame('Zona porto — lato nord', $mappa->nome); $this->assertSame('Zona porto — lato nord', $mappa->nome);
$this->assertSame(self::GEOMETRIA, $mappa->geometria()); $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]); $zona = Zona::create(['nome' => 'Nord', 'attivo' => true]);
$tipologia = Tipologia::create(['nome' => 'Residenziale', 'attivo' => true]); $tipologia = Tipologia::create(['nome' => 'Residenziale', 'attivo' => true]);
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin()) Livewire::actingAs($this->admin())
->test(PiantineEditor::class) ->test(PiantineEditor::class, ['mappa' => $mappa->id])
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
->set('territorioScelto', 'nuovo') ->set('territorioScelto', 'nuovo')
->set('numero', '42') ->set('numero', '42')
->set('zonaId', (string) $zona->id) ->set('zonaId', (string) $zona->id)
->set('tipologiaId', (string) $tipologia->id) ->set('tipologiaId', (string) $tipologia->id)
->set('confini', "Nord: via Roma\nSud: via Milano") ->set('confini', "Nord: via Roma\nSud: via Milano")
->call('salva') ->call('creaTerritorio')
->assertHasNoErrors(); ->assertHasNoErrors()
->assertSet('territorioScelto', fn ($v) => $v !== 'nuovo' && $v !== '');
$territorio = Territorio::where('numero', '42')->sole(); $territorio = Territorio::where('numero', '42')->sole();
$this->assertTrue((bool) $territorio->attivo); $this->assertTrue((bool) $territorio->attivo);
$this->assertSame($zona->id, $territorio->zona_id); $this->assertSame($zona->id, $territorio->zona_id);
$this->assertSame($tipologia->id, $territorio->tipologia_id); $this->assertSame($tipologia->id, $territorio->tipologia_id);
$this->assertStringContainsString('via Roma', $territorio->confini); $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()) Livewire::actingAs($this->admin())
->test(PiantineEditor::class) ->test(PiantineEditor::class, ['mappa' => $mappa->id])
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) ->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('territorioScelto', (string) $territorio->id)
->set('numero', '12') ->assertHasNoErrors()
->set('confini', 'Nord: via Verdi') ->assertSet('numero', '12')
->call('salva') ->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(); ->assertHasNoErrors();
$this->assertSame('Nord: via Verdi', $territorio->fresh()->confini); $territorio->refresh();
$this->assertSame(1, Territorio::count()); $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 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()) $componente = Livewire::actingAs($this->admin())
->test(PiantineEditor::class) ->test(PiantineEditor::class, ['mappa' => $mappa->id])
->set('territorioScelto', 'nuovo')
->set('nuovaZona', 'Litorale') ->set('nuovaZona', 'Litorale')
->call('creaZona') ->call('creaZona')
->assertHasNoErrors() ->assertHasNoErrors()
@@ -126,6 +308,10 @@ class PiantineEditorTest extends TestCase
$componente->assertSet('zonaId', (string) $zona->id); $componente->assertSet('zonaId', (string) $zona->id);
$componente->assertSet('tipologiaId', (string) $tipologia->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 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()); $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()) Livewire::actingAs($this->admin())
->test(PiantineEditor::class) ->test(PiantineEditor::class, ['mappa' => $scollegata->id])
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) ->call('pubblica')
->set('territorioScelto', 'nuovo') ->assertNotDispatched('pubblica-piantina')
->set('numero', '42') ->assertDispatched('avviso');
->call('salva')
->assertHasErrors('numero');
$this->assertSame(1, Territorio::where('numero', '42')->count()); $territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$this->assertSame(0, TerritorioMappa::count()); $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([ $mappa = TerritorioMappa::create([
'nome' => 'Zona porto', 'territorio_id' => $territorio->id,
'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}', 'geojson' => json_encode(self::ALTRA_GEOMETRIA),
'geojson_pubblicato' => json_encode(self::GEOMETRIA),
'pubblicata_at' => now(),
]); ]);
Livewire::actingAs($this->admin()) Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id]) ->test(PiantineEditor::class, ['mappa' => $mappa->id])
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) ->call('scartaModifiche');
->call('salva');
$this->assertSame(1, TerritorioMappa::count());
$this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria()); $this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria());
} }
public function test_una_geometria_non_valida_viene_ignorata(): void public function test_eliminare_toglie_disegno_e_immagine_ma_non_il_territorio(): 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
{ {
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'x');
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]); $territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
TerritorioMappa::create([ $mappa = TerritorioMappa::create([
'territorio_id' => $territorio->id, 'territorio_id' => $territorio->id,
'geojson' => json_encode(self::GEOMETRIA), 'geojson' => json_encode(self::GEOMETRIA),
'image_path' => 'territori-mappe/piantina-1.jpg',
]); ]);
Livewire::actingAs($this->admin()) Livewire::actingAs($this->admin())
->test(PiantineEditor::class) ->test(PiantineEditor::class, ['mappa' => $mappa->id])
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA) ->call('elimina')
->set('territorioScelto', (string) $territorio->id) ->assertSet('mappaId', null)
->set('numero', $territorio->numero) ->assertDispatched('piantina-rimossa', mappaId: $mappa->id);
->call('salva');
// La seconda piantina non deve essere stata creata. $this->assertSame(0, TerritorioMappa::count());
$this->assertSame(1, 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 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(); app(PermissionRegistrar::class)->forgetCachedPermissions();
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);

View File

@@ -17,7 +17,7 @@ use Tests\TestCase;
/** /**
* La pagina di gestione delle piantine: accesso riservato, area della città, * 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 class PiantineManagerTest extends TestCase
{ {
@@ -77,50 +77,6 @@ XML;
->assertHasErrors('kmlFile'); ->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 * La cascata di prima si portava via il disegno insieme al territorio — e
* l'import XML svuota `territori` in blocco a ogni ripristino. * l'import XML svuota `territori` in blocco a ogni ripristino.
@@ -136,52 +92,128 @@ XML;
$this->assertNull($mappa->fresh()->territorio_id); $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()) Livewire::actingAs($this->admin())
->test(PiantineManager::class) ->test(PiantineManager::class)
->call('generaPdf', $mappa->id); ->assertSee('Terr. 7')
->assertSee('Zona porto')
$this->assertDatabaseCount('territorio_pdf_versioni', 0); ->assertSee('Pubblicata')
->assertSee('Bozza')
->assertSee('Scollegata');
} }
/** public function test_i_filtri_separano_bozze_pubblicate_e_da_pubblicare(): void
* 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
{ {
$territorio = Territorio::create(['numero' => '7', 'attivo' => true, 'confini' => 'Nord: via Roma']); [, $pubblicata] = $this->pubblicata('7');
$mappa = $this->piantina([ [$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, '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); Livewire::actingAs($this->admin())
ob_start(); ->test(PiantineManager::class)
imagepng($immagine); ->call('ripristinaVersione', $versione->id)
Storage::disk('public')->put($mappa->image_path, ob_get_clean()); ->assertDispatched('avviso');
imagedestroy($immagine);
$risposta = $this->actingAs($this->admin())->get(route('piantine.anteprima', $mappa)); $this->assertStringStartsWith('territori-pdf/ripristino-', $territorio->fresh()->pdf_path);
$this->assertSame(TerritorioMappa::STATO_MODIFICATA, $mappa->fresh()->stato());
$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());
} }
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()) $mappa = $this->piantina(['territorio_id' => $territorio->id]);
->get(route('piantine.anteprima', $mappa)) $mappa->load('territorio.zona');
->assertNotFound(); $mappa->update([
'geojson_pubblicato' => $mappa->geojson,
'impronta_pubblicata' => $mappa->impronta(),
'pubblicata_at' => now(),
]);
return [$territorio, $mappa->fresh()];
} }
private function piantina(array $valori = []): TerritorioMappa private function piantina(array $valori = []): TerritorioMappa
@@ -193,7 +225,7 @@ XML;
private function admin(): User 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(); app(PermissionRegistrar::class)->forgetCachedPermissions();
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);

View File

@@ -0,0 +1,286 @@
<?php
namespace Tests\Feature;
use App\Livewire\Piantine\Pubblicazione;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\TerritorioPdfVersione;
use App\Models\User;
use App\Models\Zona;
use App\Services\Piantine\PubblicazionePiantina;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use RuntimeException;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* Dalla bozza al PDF ufficiale: l'anteprima non tocca niente, la conferma
* pubblica esattamente quello che si è visto, o niente.
*/
class PubblicazionePiantinaTest extends TestCase
{
use RefreshDatabase;
private const GEOMETRIA = [
'type' => '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;
}
}