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
può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: **28 agosto 2026**.
Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
---
@@ -75,6 +75,74 @@ e l'unica che richiede una decisione, non del lavoro.
---
## Lavori in corso — 12 ottobre 2026 (riprendere da qui)
### Già fatto e committato su `dev` (commit `d433c93`, NON ancora pushato)
- **Modulo S-13** riscritto su modulo ufficiale (FPDI), regole decise con l'utente:
20 righe/foglio, tutti i territori attivi, ciclo da 4 con riporto, riporto
iniziale = ultimo rientro prima dell'anno, assegnazioni a cavallo d'anno su
entrambi i fogli (sul vecchio senza rientro), nomi "Cognome I.", scelta per
data. Codice in `app/Services/Registro/ModuloS13.php`.
- **Blocco 1 della revisione** (integrità dati): date export/import che
slittavano di un giorno, import XML che accettava file estranei, caratteri di
controllo, territori senza zona scartati, admin estromesso dall'import,
doppia assegnazione (`Assegnazione::sovrappostaA()` + lock), ultimo admin,
`APP_KEY` sovrascritta dall'entrypoint (`APP_KEY_ROTATE=1`). Vedi la tabella
"Bug corretti". Suite: 328 test verdi.
- Il push su `dev` fa partire il deploy su demo: va fatto quando si decide.
### Finito, NON ancora committato: riprogettazione dell'Editor Piantine
Decisioni dell'utente: due pagine separate (gestione / editor); **un solo
pulsante "Pubblica"** (immagine + PDF) con **anteprima obbligatoria** da
confermare, disponibile da editor e gestione; dall'editor **si modificano i
dati del territorio** (salvati subito nel gestionale); uso soprattutto da PC ma
anche **tablet**; **stato di bozza**: in bozza va solo il disegno, salvata
**automaticamente**; stati Bozza / Pubblicata / Modifiche da pubblicare.
Come funziona: sezione [Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
Stato al 12/10/2026:
- Codice completo: modello, servizio di pubblicazione + job, finestra condivisa,
editor e gestione riscritti, JS e CSS. Migrazione
`2026_10_09_000010_bozza_e_pubblicazione_piantine`.
- Test: `PiantineEditorTest` (22), `PiantineManagerTest`,
`PubblicazionePiantinaTest` (nuovo). Suite intera: **360 test verdi**.
- Provato in browser (Playwright + Edge, in locale) su PC 1440, iPad
orizzontale e verticale, iPhone: disegno, modifica vertici (8 maniglie su
poligono), collegamento, anteprima generata dal renderer vero, conferma,
stato "Pubblicata", filtri della gestione. Corretti lì: tendina territori che
segnava tutti "(disattivato)" (colonna `attivo` non caricata), zoom sopra la
ricerca dell'elenco, barra strumenti sopra il pannello su tablet e fuori
schermo su telefono, crediti mappa stampati sul foglio del pannello.
- Aggiunto `.gitattributes` (`*.sh text eol=lf`): con `core.autocrlf=true`
l'entrypoint usciva con CRLF e il container locale non partiva ("no such
file or directory"). Nel repository era già LF: il deploy non era a rischio.
- **Non provato:** modifica vertici al tocco su un tablet vero, MultiPolygon
in modifica vertici (si modifica il primo poligono).
- Visto e **non** corretto (fuori tema): su telefono la barra superiore
dell'app trabocca con nome/email lunghi ("Esci" fuori schermo);
`/favicon.ico` risponde 404.
### Revisione profonda: blocchi ancora aperti
- **Blocco 2:** avvisi in tutte le ~10 pagine che oggi li perdono (usare il
trait `Avvisa`); PDF dei territori serviti da nginx su `/storage` senza
autenticazione (spostare su disco privato); ripristino non atomico e blocco
"una sola operazione" ignorato da import XML/PDF; piantine e storico PDF
assenti dal backup; import PDF da cartella (`1-A` sovrascrive `1`, solo 20
file per `max_file_uploads`, `Thumbs.db` blocca tutto, `12 B` ambiguo).
- **Blocco 3:** `/cambia-password` senza password attuale, sessioni non
invalidate, `trustProxies('*')`, nomi in chiaro nell'activity log, export XML
con hash/token, campagne (ultimo giorno, percentuali), `retry_after` 90 s,
`stop_grace_period` del worker, Redis `allkeys-lru`, cache statica di
`Setting` nel worker, azzeramento che lascia piantine e KML.
- Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC
di sviluppo, più la demo da `dev`). Nessuna correzione una tantum delle date
slittate da fare.
---
## Da dove veniamo
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
@@ -212,13 +280,48 @@ non esiste, lo si crea dall'editor col solo numero. La chiave esterna è
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
`territori` come fa ogni import XML, non porta più via i disegni.
**Due pagine, non una.** `/piantine` tiene area, generazione, collegamenti e
storico; `/piantine/editor` è l'editor a schermo intero, con un telaio suo
(`layouts/editor`) senza barra laterale. Tagliare un confine su una mappa larga
un terzo di schermo vuol dire sbagliarlo, e quel disegno finisce stampato.
**Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni
piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate /
scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre,
si pubblica e si elimina. `/piantine/editor` è l'editor a schermo intero, con
un telaio suo (`layouts/editor`) senza barra laterale: tagliare un confine su
una mappa larga un terzo di schermo vuol dire sbagliarlo. Elenco e pannello
galleggiano sopra la mappa; barra strumenti e controlli Leaflet si spostano
nello spazio libero (variabili `--libero-sx`/`--libero-dx`); sotto i 720px il
pannello sale dal basso. Su tablet i vertici sono più grandi (`pointer: coarse`).
La rigenerazione massiva è stata tolta: job, stato in cache e interfaccia. Si
rigenera un territorio alla volta dall'elenco.
**Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo:
il confine a ogni disegno o spostamento di vertice, nome e collegamento appena
cambiano. È bozza: il PDF del territorio non si tocca. I dati del territorio
(numero, zona, tipologia, confini) sono invece dati del gestionale e si salvano
subito, colonna per colonna (un campo non valido non trascina gli altri). Il
PDF cambia solo con **Pubblica**, uguale in editor e gestione
(`Livewire\Piantine\Pubblicazione`): `PubblicazionePiantina::avvia()` mette in
coda `PreparaAnteprimaPiantina` (il primo rendering può superare i 100 s di
Cloudflare), che genera immagine e PDF sul disco **privato**
(`storage/app/piantine-anteprime/`, stato in cache sotto un token di 40
caratteri, servito da `piantine.anteprima`); la finestra controlla ogni 2 s e
mostra il PDF; solo **Conferma** lo rende ufficiale. Se nel frattempo è cambiato
qualcosa di stampato, la conferma rifiuta.
Gli stati (`TerritorioMappa::stato()`): **Bozza** (mai pubblicata),
**Pubblicata**, **Modifiche da pubblicare**. Il confronto è sull'`impronta()`:
sha256 di confine, territorio, numero, nome della zona e confini — cambia uno
qualunque e la piantina torna da pubblicare anche col disegno identico. Vale
"Pubblicata" solo se anche il PDF in uso è ancora uno composto da una piantina
(`territori-pdf/piantina-{territorio}-…`): un ripristino dallo storico, un
upload a mano o un import la riportano "da pubblicare". "Ripristina il confine
pubblicato" riporta la bozza a `geojson_pubblicato`. La migrazione
`2026_10_09_000010` ha segnato pubblicate le piantine esistenti solo se il PDF
in vigore del territorio veniva da una piantina.
Disegnare crea **sempre** una piantina nuova; per cambiare quella aperta ci
sono "Modifica vertici" e "Ridisegna confine", espliciti. Con la mappa si
parla per eventi: i dati di tutte le piantine li chiede il JS una volta
(`datiMappa()`), poi arriva solo la piantina cambiata (`piantina-aggiornata`).
Gli avvisi passano dall'evento `avviso` (trait `Livewire\Concerns\Avvisa`,
componente `<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 è
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
@@ -234,11 +337,6 @@ nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco
marca i territori collegati senza zona: senza, l'intestazione della cartolina
esce vuota e ce se ne accorge in stampa.
**L'anteprima non tocca niente.** `piantine.anteprima` compone il PDF al volo e
lo serve `inline`: non aggiorna `pdf_path`, non archivia versioni, non lascia
file. «Genera PDF» manda il documento in vigore, e scoprire un confine sbagliato
dopo significa trovarselo già nello storico.
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):

View File

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

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

View File

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

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

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

View File

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

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

View File

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

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>
</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
</body>
</html>

View File

@@ -5,8 +5,8 @@
Serve a una cosa sola: la mappa dell'editor delle piantine, che deve
prendersi tutta la finestra. I messaggi non spingono il contenuto verso il
basso come nelle altre pagine — comparirebbe una fascia che sposta la mappa
mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro,
e si tolgono da soli.
mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro
(<x-avvisi>, che raccoglie anche quelli lanciati dalle azioni Livewire).
--}}
<html lang="it" class="h-full tm-app">
<head>
@@ -22,33 +22,7 @@
{{ $slot }}
</main>
<div class="tm-avvisi" role="status" aria-live="polite">
@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>
<x-avvisi :sessione="true" />
@livewireScripts
</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')
{{-- ── Barra strumenti ───────────────────────────────────────────────── --}}
{{-- ── Testata ───────────────────────────────────────────────────────── --}}
<header class="tm-editor-barra">
<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>
@@ -10,53 +30,59 @@
<span class="tm-editor-sep" aria-hidden="true"></span>
<h1 class="tm-editor-titolo">
{{ $mappa?->etichetta() ?? 'Nuova piantina' }}
@if($geometriaInAttesa)
<span class="tm-tag tm-tag-attesa">disegno non salvato</span>
@elseif($mappa && ! $mappa->collegata())
<span class="tm-tag">scollegata</span>
<x-btn variante="ghost" dimensione="sm" icona @click="elenco = ! elenco" ::aria-pressed="elenco"
aria-label="Mostra o nascondi l'elenco delle piantine" title="Elenco delle piantine">
<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>
</x-btn>
<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
</h1>
</div>
<div class="tm-editor-azioni">
<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>
Nuova
</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
<span>Nuova piantina</span>
</x-btn>
@if($mappa)
<x-btn variante="secondary" dimensione="sm"
wire:click="generaImmagine" wire:loading.attr="disabled" wire:target="generaImmagine">
<span wire:loading.remove wire:target="generaImmagine">Genera immagine</span>
<span wire:loading wire:target="generaImmagine">Rendering…</span>
<x-btn variante="ghost" dimensione="sm" class="tm-solo-stretto" @click="pannello = ! pannello">
Scheda
</x-btn>
@if($mappa->collegata() && $mappa->image_path)
<x-btn variante="secondary" dimensione="sm"
:href="route('piantine.anteprima', $mappa)" target="_blank" rel="noopener">
<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>
Anteprima PDF
<x-btn variante="primary" dimensione="sm" wire:click="pubblica"
:disabled="$motivo !== null || $stato === TerritorioMappa::STATO_PUBBLICATA"
: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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
<span>Pubblica</span>
</x-btn>
@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>
</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 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)
<p class="tm-editor-nota">
@@ -64,103 +90,130 @@
Il KML si carica dalla <a href="{{ route('piantine.index') }}">pagina di gestione</a>.
</p>
@endunless
</div>
{{-- Pannello: scorre sopra la mappa, non la restringe. --}}
<aside class="tm-editor-pannello" @if(! $pannelloAperto) hidden @endif
aria-label="Scheda della piantina">
{{-- ── Pannello della piantina ───────────────────────────────────── --}}
<aside class="tm-editor-pannello" x-show="pannello" x-cloak aria-label="La piantina aperta">
<div class="tm-editor-pannello-testata">
<h2>Scheda</h2>
<button type="button" wire:click="chiudiPannello" class="tm-editor-chiudi" aria-label="Chiudi il pannello">&times;</button>
<h2>{{ $mappa ? 'La piantina' : 'Come si comincia' }}</h2>
<button type="button" @click="pannello = false" class="tm-editor-chiudi" aria-label="Chiudi il pannello">&times;</button>
</div>
<div class="tm-editor-pannello-corpo">
@if($geometriaInAttesa)
<div class="tm-nota tm-nota-attesa">
<p>C'è un poligono disegnato non ancora salvato.</p>
<x-btn variante="ghost" dimensione="sm" wire:click="scartaDisegno">Scarta il disegno</x-btn>
</div>
@elseif(! $mappa)
@if(! $mappa)
<ol class="tm-passi">
<li>Apri una piantina dall'elenco o cliccandola sulla mappa,</li>
<li>oppure premi <strong>Nuova piantina</strong> e traccia il confine punto per punto.</li>
<li>Collega la piantina a un territorio e controlla i dati.</li>
<li>Premi <strong>Pubblica</strong>: vedi il PDF e lo confermi.</li>
</ol>
<p class="tm-editor-aiuto">
Usa lo strumento poligono in alto a sinistra sulla mappa per tracciare il confine.
La scheda del territorio si compila qui sotto, anche dopo.
Tutto quello che fai qui si salva da solo come bozza. Il PDF del territorio cambia
solo quando pubblichi.
</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
{{-- ── La piantina ───────────────────────────────────────── --}}
<fieldset class="tm-gruppo">
<legend>La piantina</legend>
@if($motivo)
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">Per pubblicare: {{ lcfirst($motivo) }}</p>
@endif
<div class="tm-campo">
<label for="nome">Nome interno</label>
<input type="text" id="nome" wire:model="nome" autocomplete="off"
placeholder="Es. Zona porto — lato nord">
<p class="tm-campo-aiuto">
Serve solo a ritrovarla in elenco finché non è collegata a un territorio.
Non viene stampato.
</p>
<div class="tm-azioni-riga">
@if($mappa->collegata() && $mappa->territorio->pdf_path)
<a href="{{ asset('storage/' . $mappa->territorio->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
@endif
@if($mappa->bozzaScartabile())
<x-btn variante="ghost" dimensione="sm" wire:click="scartaModifiche"
wire:confirm="Riportare il confine a quello pubblicato? Il disegno attuale va perso.">
Ripristina il confine pubblicato
</x-btn>
@endif
</div>
</fieldset>
</section>
{{-- ── Il territorio ─────────────────────────────────────── --}}
{{-- ── Territorio ────────────────────────────────────── --}}
<fieldset class="tm-gruppo">
<legend>Il territorio</legend>
<legend>Territorio</legend>
<div class="tm-campo">
<label for="territorioScelto">Collegamento</label>
<label for="territorioScelto">Collegata a</label>
<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>
@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
</select>
@if($territorioScelto === '')
<p class="tm-campo-aiuto">
Senza territorio la piantina si disegna e si renderizza lo stesso; per il PDF
sul modulo S-12 serve il collegamento.
@error('territorioScelto') <p class="tm-campo-errore">{{ $message }}</p> @enderror
@if($mappa->territorioNelCestino())
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">
Il territorio è nel cestino: il collegamento resta, ma per pubblicare va ripristinato.
</p>
@endif
</div>
@if($territorioScelto !== '')
@if($territorioScelto === 'nuovo')
<p class="tm-campo-aiuto">Compila i dati e premi <strong>Crea territorio</strong>.</p>
@else
<p class="tm-campo-aiuto">
Questi sono i dati del territorio in tutto il gestionale: le modifiche valgono
subito. Nel PDF entrano alla prossima pubblicazione.
</p>
@endif
@php($modello = $territorioScelto === 'nuovo' ? 'wire:model' : 'wire:model.blur')
<div class="tm-campo">
<label for="numero">Numero</label>
<input type="text" id="numero" wire:model="numero" autocomplete="off"
maxlength="20" placeholder="Es. 42">
<input type="text" id="numero" {{ $modello }}="numero" autocomplete="off" maxlength="20" placeholder="Es. 42">
@error('numero') <p class="tm-campo-errore">{{ $message }}</p> @enderror
</div>
<div class="tm-campo">
<label for="zonaId">Zona <span class="tm-campo-nota">stampata come «Località» sulla cartolina</span></label>
<select id="zonaId" wire:model="zonaId">
<div class="tm-campo" x-data="{ crea: false }">
<label for="zonaId">Zona <span class="tm-campo-nota">stampata come «Località»</span></label>
<select id="zonaId" {{ $territorioScelto === 'nuovo' ? 'wire:model' : 'wire:model.live' }}="zonaId">
<option value="">— nessuna —</option>
@foreach($zone as $zona)
<option value="{{ $zona->id }}">{{ $zona->nome }}</option>
@endforeach
</select>
<div class="tm-campo-riga tm-campo-crea">
<input type="text" wire:model="nuovaZona" autocomplete="off"
placeholder="Zona che non c'è ancora"
aria-label="Nome della nuova zona"
wire:keydown.enter.prevent="creaZona">
@error('zonaId') <p class="tm-campo-errore">{{ $message }}</p> @enderror
<button type="button" class="tm-collegamento tm-collegamento-piccolo" @click="crea = ! crea" x-show="! crea">+ Zona che non c'è ancora</button>
<div class="tm-campo-riga tm-campo-crea" x-show="crea" x-cloak>
<input type="text" wire:model="nuovaZona" autocomplete="off" placeholder="Nome della nuova zona"
aria-label="Nome della nuova zona" wire:keydown.enter.prevent="creaZona">
<x-btn variante="secondary" dimensione="sm" wire:click="creaZona">Crea</x-btn>
</div>
@error('nuovaZona') <p class="tm-campo-errore">{{ $message }}</p> @enderror
</div>
<div class="tm-campo">
<div class="tm-campo" x-data="{ crea: false }">
<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>
@foreach($tipologie as $tipologia)
<option value="{{ $tipologia->id }}">{{ $tipologia->nome }}</option>
@endforeach
</select>
<div class="tm-campo-riga tm-campo-crea">
<input type="text" wire:model="nuovaTipologia" autocomplete="off"
placeholder="Tipologia che non c'è ancora"
aria-label="Nome della nuova tipologia"
wire:keydown.enter.prevent="creaTipologia">
@error('tipologiaId') <p class="tm-campo-errore">{{ $message }}</p> @enderror
<button type="button" class="tm-collegamento tm-collegamento-piccolo" @click="crea = ! crea" x-show="! crea">+ Tipologia che non c'è ancora</button>
<div class="tm-campo-riga tm-campo-crea" x-show="crea" x-cloak>
<input type="text" wire:model="nuovaTipologia" autocomplete="off" placeholder="Nome della nuova tipologia"
aria-label="Nome della nuova tipologia" wire:keydown.enter.prevent="creaTipologia">
<x-btn variante="secondary" dimensione="sm" wire:click="creaTipologia">Crea</x-btn>
</div>
@error('nuovaTipologia') <p class="tm-campo-errore">{{ $message }}</p> @enderror
@@ -168,30 +221,37 @@
<div class="tm-campo">
<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>
@error('confini') <p class="tm-campo-errore">{{ $message }}</p> @enderror
<p class="tm-campo-aiuto">
Vengono stampati sulla prima pagina della cartolina, nello spazio centrale
del modulo. Il corpo del testo si adatta da solo alla lunghezza.
</p>
<p class="tm-campo-aiuto">Stampati sulla prima pagina del PDF; il testo si adatta da solo allo spazio.</p>
</div>
@if($territorioScelto === 'nuovo')
<x-btn variante="primary" dimensione="sm" wire:click="creaTerritorio"
wire:loading.attr="disabled" wire:target="creaTerritorio">
Crea territorio
</x-btn>
@endif
@endif
</fieldset>
@if($mappa)
<div class="tm-editor-pannello-piede">
@if($mappa->image_path)
<a href="{{ asset('storage/' . $mappa->image_path) }}" target="_blank" rel="noopener"
class="tm-collegamento">
Immagine del {{ $mappa->rendered_at?->format('d/m/Y H:i') }}
</a>
@else
<span class="tm-editor-aiuto">Immagine non ancora generata.</span>
{{-- ── Nome interno, solo finché è scollegata ───────── --}}
@if(! $mappa->territorio)
<fieldset class="tm-gruppo">
<legend>Nome interno</legend>
<div class="tm-campo">
<label for="nome" class="tm-solo-lettori">Nome interno</label>
<input type="text" id="nome" wire:model.blur="nome" autocomplete="off" maxlength="120"
placeholder="Es. Zona porto — lato nord">
<p class="tm-campo-aiuto">Serve a ritrovarla in elenco finché non ha un territorio. Non viene stampato.</p>
</div>
</fieldset>
@endif
<div class="tm-editor-pannello-piede">
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="elimina"
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
</x-btn>
</div>
@@ -200,5 +260,5 @@
</aside>
</div>
<script type="application/json" id="piantine-dati">@json($datiMappaIniziali)</script>
<livewire:piantine.pubblicazione />
</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>
<header class="tm-pagina-testata">
<div>
<h1 class="tm-pagina-titolo">Piantine dei territori</h1>
<p class="tm-pagina-nota">
Il disegno dei confini si fa nell'editor a schermo intero. Qui si tiene l'area della
città, si generano immagini e PDF sul modulo S-12, e si ripescano le versioni sostituite.
A che punto è ogni piantina. Il disegno e i dati del territorio si modificano nell'editor,
dove le modifiche si salvano da sole come bozza; il PDF del territorio cambia solo quando
si pubblica, dopo averne visto l'anteprima.
</p>
</div>
<x-btn variante="primary" :href="route('piantine.editor')">
@@ -13,8 +24,101 @@
</x-btn>
</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à ──────────────────────────────────────────────── --}}
<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">
<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>
@@ -29,8 +133,7 @@
@endif
</h2>
<p class="tm-card-nota">
Il perimetro di riferimento su cui si disegnano le piantine: è il primo passo,
senza l'editor parte su una mappa senza contorni.
Il perimetro di riferimento che l'editor mostra sotto le piantine.
</p>
</div>
</div>
@@ -59,110 +162,14 @@
</form>
</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 ──────────────────────────────────────────── --}}
<section class="tm-card">
<div class="tm-card-testata">
<h2 class="tm-card-titolo">Storico versioni PDF</h2>
<p class="tm-card-nota">I PDF sostituiti da una piantina. Il ripristino è sempre reversibile.</p>
<h2 class="tm-card-titolo">Storico versioni PDF <span class="tm-conteggio">{{ $versioni->total() }}</span></h2>
<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>
@if($versioni->isEmpty())
@@ -205,6 +212,10 @@
</tbody>
</table>
</div>
<div class="mt-4">{{ $versioni->links() }}</div>
@endif
</section>
<livewire:piantine.pubblicazione />
</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'è.
Route::get('piantine', PiantineManager::class)->name('piantine.index');
Route::get('piantine/editor/{mappa?}', PiantineEditor::class)->name('piantine.editor');
Route::get('piantine/{mappa}/anteprima', PiantinaAnteprimaController::class)->name('piantine.anteprima');
Route::get('piantine/anteprima/{token}', PiantinaAnteprimaController::class)
->where('token', '[A-Za-z0-9]{40}')
->name('piantine.anteprima');
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Livewire\Piantine\PiantineEditor;
use App\Livewire\Piantine\Pubblicazione;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
@@ -17,8 +18,9 @@ use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
/**
* L'editor a schermo intero: si disegna prima e si decide dopo a chi
* appartiene il disegno — territorio compreso, che può nascere qui.
* L'editor a schermo intero: il disegno è bozza e si salva da solo, i dati
* del territorio si salvano subito nel gestionale, il PDF non si tocca finché
* non si pubblica.
*/
class PiantineEditorTest extends TestCase
{
@@ -29,6 +31,11 @@ class PiantineEditorTest extends TestCase
'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
];
private const ALTRA_GEOMETRIA = [
'type' => 'Polygon',
'coordinates' => [[[13.40, 38.11], [13.42, 38.11], [13.42, 38.13], [13.40, 38.11]]],
];
protected function setUp(): void
{
parent::setUp();
@@ -53,67 +60,242 @@ class PiantineEditorTest extends TestCase
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
}
public function test_una_piantina_si_salva_senza_nessun_territorio(): void
public function test_la_pagina_si_apre_su_una_piantina_esistente(): void
{
$mappa = TerritorioMappa::create(['nome' => 'Zona porto', 'geojson' => json_encode(self::GEOMETRIA)]);
$this->actingAs($this->admin())
->get(route('piantine.editor', $mappa->id))
->assertOk()
->assertSee('Zona porto');
}
public function test_un_disegno_crea_subito_una_bozza_senza_territorio(): void
{
Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
->set('nome', 'Zona porto — lato nord')
->call('salva');
->dispatch('geometria-modificata', geometry: self::GEOMETRIA, nuova: true)
->assertDispatched('piantina-aggiornata')
->assertDispatched('editor-indirizzo')
->set('nome', 'Zona porto — lato nord');
$mappa = TerritorioMappa::sole();
$this->assertNull($mappa->territorio_id);
$this->assertSame('Zona porto — lato nord', $mappa->nome);
$this->assertSame(self::GEOMETRIA, $mappa->geometria());
$this->assertSame('Zona porto — lato nord', $mappa->etichetta());
$this->assertSame(TerritorioMappa::STATO_BOZZA, $mappa->stato());
}
public function test_il_territorio_si_crea_dalla_scheda_con_tutte_le_proprieta(): void
public function test_spostare_i_vertici_aggiorna_la_piantina_aperta(): void
{
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->dispatch('geometria-modificata', geometry: self::ALTRA_GEOMETRIA, nuova: false);
$this->assertSame(1, TerritorioMappa::count());
$this->assertSame(self::ALTRA_GEOMETRIA, $mappa->fresh()->geometria());
}
public function test_un_disegno_nuovo_con_una_piantina_aperta_non_ne_sostituisce_il_confine(): void
{
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->dispatch('geometria-modificata', geometry: self::ALTRA_GEOMETRIA, nuova: true);
$this->assertSame(2, TerritorioMappa::count());
$this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria());
}
public function test_una_geometria_non_valida_viene_respinta(): void
{
Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->dispatch('geometria-modificata', geometry: ['type' => 'Point', 'coordinates' => [1, 2]], nuova: true)
->dispatch('geometria-modificata', geometry: [
'type' => 'Polygon',
'coordinates' => [[[0, 0], [1, 0], [0, 0]]],
], nuova: true)
->dispatch('geometria-modificata', geometry: [
'type' => 'Polygon',
'coordinates' => [[[0, 0], [500, 0], [1, 1], [0, 0]]],
], nuova: true)
->assertDispatched('avviso');
$this->assertSame(0, TerritorioMappa::count());
}
public function test_il_territorio_si_crea_dall_editor_con_tutte_le_proprieta(): void
{
$zona = Zona::create(['nome' => 'Nord', 'attivo' => true]);
$tipologia = Tipologia::create(['nome' => 'Residenziale', 'attivo' => true]);
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->set('territorioScelto', 'nuovo')
->set('numero', '42')
->set('zonaId', (string) $zona->id)
->set('tipologiaId', (string) $tipologia->id)
->set('confini', "Nord: via Roma\nSud: via Milano")
->call('salva')
->assertHasNoErrors();
->call('creaTerritorio')
->assertHasNoErrors()
->assertSet('territorioScelto', fn ($v) => $v !== 'nuovo' && $v !== '');
$territorio = Territorio::where('numero', '42')->sole();
$this->assertTrue((bool) $territorio->attivo);
$this->assertSame($zona->id, $territorio->zona_id);
$this->assertSame($tipologia->id, $territorio->tipologia_id);
$this->assertStringContainsString('via Roma', $territorio->confini);
$this->assertSame($territorio->id, TerritorioMappa::sole()->territorio_id);
$this->assertSame($territorio->id, $mappa->fresh()->territorio_id);
}
public function test_la_scheda_aggiorna_un_territorio_gia_esistente(): void
public function test_in_creazione_i_campi_non_salvano_niente_prima_del_pulsante(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->set('territorioScelto', 'nuovo')
->set('numero', '4');
$this->assertSame(0, Territorio::count());
$this->assertNull($mappa->fresh()->territorio_id);
}
public function test_un_numero_gia_in_uso_non_crea_un_secondo_territorio(): void
{
Territorio::create(['numero' => '42', 'attivo' => true]);
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->set('territorioScelto', 'nuovo')
->set('numero', '42')
->call('creaTerritorio')
->assertHasErrors('numero');
$this->assertSame(1, Territorio::where('numero', '42')->count());
$this->assertNull($mappa->fresh()->territorio_id);
}
public function test_scegliere_un_territorio_collega_subito_la_piantina(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true, 'confini' => 'Nord: via Verdi']);
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->set('territorioScelto', (string) $territorio->id)
->set('numero', '12')
->set('confini', 'Nord: via Verdi')
->call('salva')
->assertHasNoErrors()
->assertSet('numero', '12')
->assertSet('confini', 'Nord: via Verdi');
$this->assertSame($territorio->id, $mappa->fresh()->territorio_id);
}
/** La tendina leggeva `attivo` senza averlo caricato: tutti "disattivati". */
public function test_la_tendina_segna_disattivato_solo_chi_lo_e(): void
{
Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->assertSee('Territorio 12')
->assertDontSee('(disattivato)');
}
public function test_i_dati_del_territorio_si_salvano_subito_campo_per_campo(): void
{
$zona = Zona::create(['nome' => 'Sud', 'attivo' => true]);
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->set('confini', 'Est: via Garibaldi')
->set('zonaId', (string) $zona->id)
->assertHasNoErrors();
$this->assertSame('Nord: via Verdi', $territorio->fresh()->confini);
$this->assertSame(1, Territorio::count());
$territorio->refresh();
$this->assertSame('Est: via Garibaldi', $territorio->confini);
$this->assertSame($zona->id, $territorio->zona_id);
}
public function test_un_campo_non_valido_non_trascina_gli_altri(): void
{
Territorio::create(['numero' => '7', 'attivo' => true]);
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->set('numero', '7')
->assertHasErrors('numero')
->set('confini', 'Ovest: via Dante');
$territorio->refresh();
$this->assertSame('12', $territorio->numero);
$this->assertSame('Ovest: via Dante', $territorio->confini);
}
public function test_non_si_ruba_il_territorio_a_un_altra_piantina(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
$seconda = TerritorioMappa::create(['geojson' => json_encode(self::ALTRA_GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $seconda->id])
->set('territorioScelto', (string) $territorio->id)
->assertHasErrors('territorioScelto')
->assertSet('territorioScelto', '');
$this->assertNull($seconda->fresh()->territorio_id);
}
public function test_scollegare_lascia_un_etichetta_riconoscibile(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->set('territorioScelto', '');
$mappa->refresh();
$this->assertNull($mappa->territorio_id);
$this->assertSame('Ex territorio 12', $mappa->nome);
$this->assertNotNull(Territorio::find($territorio->id));
}
public function test_un_territorio_nel_cestino_resta_collegato(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
$territorio->delete();
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->assertSet('territorioScelto', (string) $territorio->id)
->set('nome', 'Prova');
$mappa->refresh();
$this->assertSame($territorio->id, $mappa->territorio_id);
$this->assertNotNull($mappa->motivoNonPubblicabile());
}
public function test_zona_e_tipologia_si_creano_dall_editor_e_restano_selezionate(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
$componente = Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->set('territorioScelto', 'nuovo')
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->set('nuovaZona', 'Litorale')
->call('creaZona')
->assertHasNoErrors()
@@ -126,6 +308,10 @@ class PiantineEditorTest extends TestCase
$componente->assertSet('zonaId', (string) $zona->id);
$componente->assertSet('tipologiaId', (string) $tipologia->id);
$territorio->refresh();
$this->assertSame($zona->id, $territorio->zona_id);
$this->assertSame($tipologia->id, $territorio->tipologia_id);
}
public function test_una_zona_gia_esistente_non_viene_duplicata(): void
@@ -141,70 +327,80 @@ class PiantineEditorTest extends TestCase
$this->assertSame(1, Zona::where('nome', 'Nord')->count());
}
public function test_un_numero_gia_in_uso_non_crea_un_secondo_territorio(): void
public function test_pubblica_apre_la_finestra_solo_se_si_puo(): void
{
Territorio::create(['numero' => '42', 'attivo' => true]);
$scollegata = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
->set('territorioScelto', 'nuovo')
->set('numero', '42')
->call('salva')
->assertHasErrors('numero');
->test(PiantineEditor::class, ['mappa' => $scollegata->id])
->call('pubblica')
->assertNotDispatched('pubblica-piantina')
->assertDispatched('avviso');
$this->assertSame(1, Territorio::where('numero', '42')->count());
$this->assertSame(0, TerritorioMappa::count());
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$collegata = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::ALTRA_GEOMETRIA)]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $collegata->id])
->call('pubblica')
->assertDispatchedTo(Pubblicazione::class, 'pubblica-piantina', mappaId: $collegata->id);
}
public function test_ridisegnare_sostituisce_il_poligono_senza_duplicare_la_piantina(): void
public function test_scartare_le_modifiche_riporta_il_confine_pubblicato(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create([
'nome' => 'Zona porto',
'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
'territorio_id' => $territorio->id,
'geojson' => json_encode(self::ALTRA_GEOMETRIA),
'geojson_pubblicato' => json_encode(self::GEOMETRIA),
'pubblicata_at' => now(),
]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
->call('salva');
->call('scartaModifiche');
$this->assertSame(1, TerritorioMappa::count());
$this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria());
}
public function test_una_geometria_non_valida_viene_ignorata(): void
{
Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->dispatch('poligono-disegnato', geometry: ['type' => 'Point', 'coordinates' => [1, 2]])
->call('salva');
$this->assertSame(0, TerritorioMappa::count());
}
public function test_non_si_ruba_il_territorio_a_un_altra_piantina(): void
public function test_eliminare_toglie_disegno_e_immagine_ma_non_il_territorio(): void
{
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'x');
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
TerritorioMappa::create([
$mappa = TerritorioMappa::create([
'territorio_id' => $territorio->id,
'geojson' => json_encode(self::GEOMETRIA),
'image_path' => 'territori-mappe/piantina-1.jpg',
]);
Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
->set('territorioScelto', (string) $territorio->id)
->set('numero', $territorio->numero)
->call('salva');
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->call('elimina')
->assertSet('mappaId', null)
->assertDispatched('piantina-rimossa', mappaId: $mappa->id);
// La seconda piantina non deve essere stata creata.
$this->assertSame(1, TerritorioMappa::count());
$this->assertSame(0, TerritorioMappa::count());
$this->assertNotNull(Territorio::find($territorio->id));
Storage::disk('public')->assertMissing('territori-mappe/piantina-1.jpg');
}
public function test_dati_mappa_esclude_le_geometrie_illeggibili(): void
{
TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
TerritorioMappa::create(['geojson' => 'non è json']);
$dati = Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->instance()
->datiMappa(app(\App\Services\Piantine\PiantineArea::class));
$this->assertCount(1, $dati['piantine']);
$this->assertSame(TerritorioMappa::STATO_BOZZA, $dati['piantine'][0]['stato']);
}
private function admin(): User
{
Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web']));
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);

View File

@@ -17,7 +17,7 @@ use Tests\TestCase;
/**
* La pagina di gestione delle piantine: accesso riservato, area della città,
* e il legame — che si può fare e disfare — fra una piantina e un territorio.
* stato di ogni piantina con ricerca e filtri, pubblicazione e storico.
*/
class PiantineManagerTest extends TestCase
{
@@ -77,50 +77,6 @@ XML;
->assertHasErrors('kmlFile');
}
public function test_una_piantina_si_collega_a_un_territorio(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = $this->piantina(['nome' => 'Zona porto']);
Livewire::actingAs($this->admin())
->test(PiantineManager::class)
->call('apriCollegamento', $mappa->id)
->set('territorioDaCollegare', $territorio->id)
->call('collega', $mappa->id);
$this->assertSame($territorio->id, $mappa->fresh()->territorio_id);
}
public function test_un_territorio_che_ha_gia_una_piantina_non_ne_accetta_una_seconda(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$this->piantina(['territorio_id' => $territorio->id]);
$seconda = $this->piantina(['nome' => 'Doppione']);
Livewire::actingAs($this->admin())
->test(PiantineManager::class)
->call('apriCollegamento', $seconda->id)
->set('territorioDaCollegare', $territorio->id)
->call('collega', $seconda->id);
$this->assertNull($seconda->fresh()->territorio_id);
}
public function test_scollegare_conserva_la_piantina_e_le_da_un_nome(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
Livewire::actingAs($this->admin())
->test(PiantineManager::class)
->call('scollega', $mappa->id);
$mappa->refresh();
$this->assertNull($mappa->territorio_id);
$this->assertSame('Ex territorio 12', $mappa->nome);
$this->assertDatabaseCount('territorio_mappe', 1);
}
/**
* La cascata di prima si portava via il disegno insieme al territorio — e
* l'import XML svuota `territori` in blocco a ogni ripristino.
@@ -136,52 +92,128 @@ XML;
$this->assertNull($mappa->fresh()->territorio_id);
}
public function test_senza_territorio_il_pdf_non_si_genera(): void
public function test_l_elenco_mostra_lo_stato_di_ogni_piantina(): void
{
$mappa = $this->piantina(['nome' => 'Zona porto']);
[$territorio, $pubblicata] = $this->pubblicata('7');
$this->piantina(['nome' => 'Zona porto']);
Livewire::actingAs($this->admin())
->test(PiantineManager::class)
->call('generaPdf', $mappa->id);
$this->assertDatabaseCount('territorio_pdf_versioni', 0);
->assertSee('Terr. 7')
->assertSee('Zona porto')
->assertSee('Pubblicata')
->assertSee('Bozza')
->assertSee('Scollegata');
}
/**
* L'anteprima serve a guardare la cartolina PRIMA di mandarla in vigore:
* se toccasse pdf_path o lo storico, non sarebbe un'anteprima.
*/
public function test_l_anteprima_mostra_il_pdf_senza_sostituire_niente(): void
public function test_i_filtri_separano_bozze_pubblicate_e_da_pubblicare(): void
{
$territorio = Territorio::create(['numero' => '7', 'attivo' => true, 'confini' => 'Nord: via Roma']);
$mappa = $this->piantina([
[, $pubblicata] = $this->pubblicata('7');
[$t8, $modificata] = $this->pubblicata('8');
$t8->update(['confini' => 'cambiati dopo la pubblicazione']);
$this->piantina(['nome' => 'Zona porto']);
$componente = Livewire::actingAs($this->admin())->test(PiantineManager::class);
$componente->set('filtro', 'pubblicate')
->assertViewHas('elenco', fn ($e) => $e->pluck('id')->all() === [$pubblicata->id]);
$componente->set('filtro', 'da-pubblicare')
->assertViewHas('elenco', fn ($e) => $e->pluck('id')->all() === [$modificata->id]);
$componente->set('filtro', 'scollegate')
->assertViewHas('elenco', fn ($e) => $e->count() === 1 && $e->first()->nome === 'Zona porto');
$componente->set('filtro', 'inventato')
->assertSet('filtro', 'tutte')
->assertViewHas('elenco', fn ($e) => $e->count() === 3);
}
public function test_la_ricerca_trova_numero_nome_e_zona(): void
{
$zona = \App\Models\Zona::create(['nome' => 'Litorale', 'attivo' => true]);
$territorio = Territorio::create(['numero' => '12', 'attivo' => true, 'zona_id' => $zona->id]);
$this->piantina(['territorio_id' => $territorio->id]);
$this->piantina(['nome' => 'Zona porto']);
Livewire::actingAs($this->admin())
->test(PiantineManager::class)
->set('cerca', 'litor')
->assertViewHas('elenco', fn ($e) => $e->count() === 1 && $e->first()->territorio_id === $territorio->id)
->set('cerca', 'PORTO')
->assertViewHas('elenco', fn ($e) => $e->count() === 1 && $e->first()->nome === 'Zona porto');
}
public function test_pubblica_apre_la_finestra_condivisa_solo_se_si_puo(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$collegata = $this->piantina(['territorio_id' => $territorio->id]);
$scollegata = $this->piantina(['nome' => 'Zona porto']);
Livewire::actingAs($this->admin())
->test(PiantineManager::class)
->call('pubblica', $collegata->id)
->assertDispatchedTo(\App\Livewire\Piantine\Pubblicazione::class, 'pubblica-piantina', mappaId: $collegata->id);
Livewire::actingAs($this->admin())
->test(PiantineManager::class)
->call('pubblica', $scollegata->id)
->assertNotDispatched('pubblica-piantina')
->assertDispatched('avviso');
}
public function test_eliminare_toglie_la_piantina_ma_non_il_territorio(): void
{
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
Livewire::actingAs($this->admin())
->test(PiantineManager::class)
->call('elimina', $mappa->id)
->assertDispatched('avviso');
$this->assertDatabaseCount('territorio_mappe', 0);
$this->assertNotNull($territorio->fresh());
}
public function test_ripristinare_una_versione_riporta_la_piantina_da_pubblicare(): void
{
[$territorio, $mappa] = $this->pubblicata('7');
Storage::disk('public')->put('territori-pdf-versioni/vecchia.pdf', '%PDF-vecchia');
$versione = \App\Models\TerritorioPdfVersione::create([
'territorio_id' => $territorio->id,
'image_path' => 'territori-mappe/piantina-prova.png',
'pdf_path' => 'territori-pdf-versioni/vecchia.pdf',
'origine' => 'piantine',
'sostituito_il' => now()->subDay(),
]);
$immagine = imagecreatetruecolor(80, 50);
ob_start();
imagepng($immagine);
Storage::disk('public')->put($mappa->image_path, ob_get_clean());
imagedestroy($immagine);
Livewire::actingAs($this->admin())
->test(PiantineManager::class)
->call('ripristinaVersione', $versione->id)
->assertDispatched('avviso');
$risposta = $this->actingAs($this->admin())->get(route('piantine.anteprima', $mappa));
$risposta->assertOk();
$risposta->assertHeader('Content-Type', 'application/pdf');
$this->assertStringStartsWith('%PDF', $risposta->getContent());
$this->assertNull($territorio->fresh()->pdf_path);
$this->assertSame(0, \App\Models\TerritorioPdfVersione::count());
$this->assertStringStartsWith('territori-pdf/ripristino-', $territorio->fresh()->pdf_path);
$this->assertSame(TerritorioMappa::STATO_MODIFICATA, $mappa->fresh()->stato());
}
public function test_l_anteprima_di_una_piantina_scollegata_non_esiste(): void
/** Una piantina collegata e segnata come pubblicata, senza passare dal renderer. */
private function pubblicata(string $numero): array
{
$mappa = $this->piantina(['nome' => 'Scollegata']);
$territorio = Territorio::create(['numero' => $numero, 'attivo' => true]);
$pdf = 'territori-pdf/piantina-' . $territorio->id . '-20261001-120000.pdf';
Storage::disk('public')->put($pdf, '%PDF-piantina');
$territorio->update(['pdf_path' => $pdf]);
$this->actingAs($this->admin())
->get(route('piantine.anteprima', $mappa))
->assertNotFound();
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
$mappa->load('territorio.zona');
$mappa->update([
'geojson_pubblicato' => $mappa->geojson,
'impronta_pubblicata' => $mappa->impronta(),
'pubblicata_at' => now(),
]);
return [$territorio, $mappa->fresh()];
}
private function piantina(array $valori = []): TerritorioMappa
@@ -193,7 +225,7 @@ XML;
private function admin(): User
{
Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web']));
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);

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;
}
}