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:
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal 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
|
||||||
122
HANDOFF.md
122
HANDOFF.md
@@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
|
|||||||
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
||||||
può dare: perché le cose sono come sono, e cosa resta aperto.
|
può dare: perché le cose sono come sono, e cosa resta aperto.
|
||||||
|
|
||||||
Ultimo aggiornamento: **28 agosto 2026**.
|
Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,6 +75,74 @@ e l'unica che richiede una decisione, non del lavoro.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Lavori in corso — 12 ottobre 2026 (riprendere da qui)
|
||||||
|
|
||||||
|
### Già fatto e committato su `dev` (commit `d433c93`, NON ancora pushato)
|
||||||
|
|
||||||
|
- **Modulo S-13** riscritto su modulo ufficiale (FPDI), regole decise con l'utente:
|
||||||
|
20 righe/foglio, tutti i territori attivi, ciclo da 4 con riporto, riporto
|
||||||
|
iniziale = ultimo rientro prima dell'anno, assegnazioni a cavallo d'anno su
|
||||||
|
entrambi i fogli (sul vecchio senza rientro), nomi "Cognome I.", scelta per
|
||||||
|
data. Codice in `app/Services/Registro/ModuloS13.php`.
|
||||||
|
- **Blocco 1 della revisione** (integrità dati): date export/import che
|
||||||
|
slittavano di un giorno, import XML che accettava file estranei, caratteri di
|
||||||
|
controllo, territori senza zona scartati, admin estromesso dall'import,
|
||||||
|
doppia assegnazione (`Assegnazione::sovrappostaA()` + lock), ultimo admin,
|
||||||
|
`APP_KEY` sovrascritta dall'entrypoint (`APP_KEY_ROTATE=1`). Vedi la tabella
|
||||||
|
"Bug corretti". Suite: 328 test verdi.
|
||||||
|
- Il push su `dev` fa partire il deploy su demo: va fatto quando si decide.
|
||||||
|
|
||||||
|
### Finito, NON ancora committato: riprogettazione dell'Editor Piantine
|
||||||
|
|
||||||
|
Decisioni dell'utente: due pagine separate (gestione / editor); **un solo
|
||||||
|
pulsante "Pubblica"** (immagine + PDF) con **anteprima obbligatoria** da
|
||||||
|
confermare, disponibile da editor e gestione; dall'editor **si modificano i
|
||||||
|
dati del territorio** (salvati subito nel gestionale); uso soprattutto da PC ma
|
||||||
|
anche **tablet**; **stato di bozza**: in bozza va solo il disegno, salvata
|
||||||
|
**automaticamente**; stati Bozza / Pubblicata / Modifiche da pubblicare.
|
||||||
|
Come funziona: sezione [Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||||||
|
|
||||||
|
Stato al 12/10/2026:
|
||||||
|
- Codice completo: modello, servizio di pubblicazione + job, finestra condivisa,
|
||||||
|
editor e gestione riscritti, JS e CSS. Migrazione
|
||||||
|
`2026_10_09_000010_bozza_e_pubblicazione_piantine`.
|
||||||
|
- Test: `PiantineEditorTest` (22), `PiantineManagerTest`,
|
||||||
|
`PubblicazionePiantinaTest` (nuovo). Suite intera: **360 test verdi**.
|
||||||
|
- Provato in browser (Playwright + Edge, in locale) su PC 1440, iPad
|
||||||
|
orizzontale e verticale, iPhone: disegno, modifica vertici (8 maniglie su
|
||||||
|
poligono), collegamento, anteprima generata dal renderer vero, conferma,
|
||||||
|
stato "Pubblicata", filtri della gestione. Corretti lì: tendina territori che
|
||||||
|
segnava tutti "(disattivato)" (colonna `attivo` non caricata), zoom sopra la
|
||||||
|
ricerca dell'elenco, barra strumenti sopra il pannello su tablet e fuori
|
||||||
|
schermo su telefono, crediti mappa stampati sul foglio del pannello.
|
||||||
|
- Aggiunto `.gitattributes` (`*.sh text eol=lf`): con `core.autocrlf=true`
|
||||||
|
l'entrypoint usciva con CRLF e il container locale non partiva ("no such
|
||||||
|
file or directory"). Nel repository era già LF: il deploy non era a rischio.
|
||||||
|
- **Non provato:** modifica vertici al tocco su un tablet vero, MultiPolygon
|
||||||
|
in modifica vertici (si modifica il primo poligono).
|
||||||
|
- Visto e **non** corretto (fuori tema): su telefono la barra superiore
|
||||||
|
dell'app trabocca con nome/email lunghi ("Esci" fuori schermo);
|
||||||
|
`/favicon.ico` risponde 404.
|
||||||
|
|
||||||
|
### Revisione profonda: blocchi ancora aperti
|
||||||
|
|
||||||
|
- **Blocco 2:** avvisi in tutte le ~10 pagine che oggi li perdono (usare il
|
||||||
|
trait `Avvisa`); PDF dei territori serviti da nginx su `/storage` senza
|
||||||
|
autenticazione (spostare su disco privato); ripristino non atomico e blocco
|
||||||
|
"una sola operazione" ignorato da import XML/PDF; piantine e storico PDF
|
||||||
|
assenti dal backup; import PDF da cartella (`1-A` sovrascrive `1`, solo 20
|
||||||
|
file per `max_file_uploads`, `Thumbs.db` blocca tutto, `12 B` ambiguo).
|
||||||
|
- **Blocco 3:** `/cambia-password` senza password attuale, sessioni non
|
||||||
|
invalidate, `trustProxies('*')`, nomi in chiaro nell'activity log, export XML
|
||||||
|
con hash/token, campagne (ultimo giorno, percentuali), `retry_after` 90 s,
|
||||||
|
`stop_grace_period` del worker, Redis `allkeys-lru`, cache statica di
|
||||||
|
`Setting` nel worker, azzeramento che lascia piantine e KML.
|
||||||
|
- Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC
|
||||||
|
di sviluppo, più la demo da `dev`). Nessuna correzione una tantum delle date
|
||||||
|
slittate da fare.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Da dove veniamo
|
## Da dove veniamo
|
||||||
|
|
||||||
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
|
Il progetto aveva un difetto strutturale nel deploy: il `Dockerfile` compilava
|
||||||
@@ -212,13 +280,48 @@ non esiste, lo si crea dall'editor col solo numero. La chiave esterna è
|
|||||||
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
|
`nullOnDelete`, non più `cascadeOnDelete`: cancellare un territorio, o svuotare
|
||||||
`territori` come fa ogni import XML, non porta più via i disegni.
|
`territori` come fa ogni import XML, non porta più via i disegni.
|
||||||
|
|
||||||
**Due pagine, non una.** `/piantine` tiene area, generazione, collegamenti e
|
**Due pagine, non una.** `/piantine` è il quadro d'insieme: stato di ogni
|
||||||
storico; `/piantine/editor` è l'editor a schermo intero, con un telaio suo
|
piantina con ricerca e filtri (tutte / bozze / da pubblicare / pubblicate /
|
||||||
(`layouts/editor`) senza barra laterale. Tagliare un confine su una mappa larga
|
scollegate), area KML e storico dei PDF sostituiti (paginato). Da lì si apre,
|
||||||
un terzo di schermo vuol dire sbagliarlo, e quel disegno finisce stampato.
|
si pubblica e si elimina. `/piantine/editor` è l'editor a schermo intero, con
|
||||||
|
un telaio suo (`layouts/editor`) senza barra laterale: tagliare un confine su
|
||||||
|
una mappa larga un terzo di schermo vuol dire sbagliarlo. Elenco e pannello
|
||||||
|
galleggiano sopra la mappa; barra strumenti e controlli Leaflet si spostano
|
||||||
|
nello spazio libero (variabili `--libero-sx`/`--libero-dx`); sotto i 720px il
|
||||||
|
pannello sale dal basso. Su tablet i vertici sono più grandi (`pointer: coarse`).
|
||||||
|
|
||||||
La rigenerazione massiva è stata tolta: job, stato in cache e interfaccia. Si
|
**Bozza e pubblicazione (ottobre 2026).** Nell'editor tutto si salva da solo:
|
||||||
rigenera un territorio alla volta dall'elenco.
|
il confine a ogni disegno o spostamento di vertice, nome e collegamento appena
|
||||||
|
cambiano. È bozza: il PDF del territorio non si tocca. I dati del territorio
|
||||||
|
(numero, zona, tipologia, confini) sono invece dati del gestionale e si salvano
|
||||||
|
subito, colonna per colonna (un campo non valido non trascina gli altri). Il
|
||||||
|
PDF cambia solo con **Pubblica**, uguale in editor e gestione
|
||||||
|
(`Livewire\Piantine\Pubblicazione`): `PubblicazionePiantina::avvia()` mette in
|
||||||
|
coda `PreparaAnteprimaPiantina` (il primo rendering può superare i 100 s di
|
||||||
|
Cloudflare), che genera immagine e PDF sul disco **privato**
|
||||||
|
(`storage/app/piantine-anteprime/`, stato in cache sotto un token di 40
|
||||||
|
caratteri, servito da `piantine.anteprima`); la finestra controlla ogni 2 s e
|
||||||
|
mostra il PDF; solo **Conferma** lo rende ufficiale. Se nel frattempo è cambiato
|
||||||
|
qualcosa di stampato, la conferma rifiuta.
|
||||||
|
|
||||||
|
Gli stati (`TerritorioMappa::stato()`): **Bozza** (mai pubblicata),
|
||||||
|
**Pubblicata**, **Modifiche da pubblicare**. Il confronto è sull'`impronta()`:
|
||||||
|
sha256 di confine, territorio, numero, nome della zona e confini — cambia uno
|
||||||
|
qualunque e la piantina torna da pubblicare anche col disegno identico. Vale
|
||||||
|
"Pubblicata" solo se anche il PDF in uso è ancora uno composto da una piantina
|
||||||
|
(`territori-pdf/piantina-{territorio}-…`): un ripristino dallo storico, un
|
||||||
|
upload a mano o un import la riportano "da pubblicare". "Ripristina il confine
|
||||||
|
pubblicato" riporta la bozza a `geojson_pubblicato`. La migrazione
|
||||||
|
`2026_10_09_000010` ha segnato pubblicate le piantine esistenti solo se il PDF
|
||||||
|
in vigore del territorio veniva da una piantina.
|
||||||
|
|
||||||
|
Disegnare crea **sempre** una piantina nuova; per cambiare quella aperta ci
|
||||||
|
sono "Modifica vertici" e "Ridisegna confine", espliciti. Con la mappa si
|
||||||
|
parla per eventi: i dati di tutte le piantine li chiede il JS una volta
|
||||||
|
(`datiMappa()`), poi arriva solo la piantina cambiata (`piantina-aggiornata`).
|
||||||
|
Gli avvisi passano dall'evento `avviso` (trait `Livewire\Concerns\Avvisa`,
|
||||||
|
componente `<x-avvisi>`): i flash di sessione nel telaio non si vedono dopo
|
||||||
|
un'azione Livewire.
|
||||||
|
|
||||||
**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è
|
**La cartolina e la piantina sono due pagine distinte.** Il riquadro dell'S-12 è
|
||||||
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
|
largo sette centimetri: una mappa di quartiere lì dentro non si legge. Quindi
|
||||||
@@ -234,11 +337,6 @@ nome divergono prima o poi, e sulla carta finisce quello sbagliato. L'elenco
|
|||||||
marca i territori collegati senza zona: senza, l'intestazione della cartolina
|
marca i territori collegati senza zona: senza, l'intestazione della cartolina
|
||||||
esce vuota e ce se ne accorge in stampa.
|
esce vuota e ce se ne accorge in stampa.
|
||||||
|
|
||||||
**L'anteprima non tocca niente.** `piantine.anteprima` compone il PDF al volo e
|
|
||||||
lo serve `inline`: non aggiorna `pdf_path`, non archivia versioni, non lascia
|
|
||||||
file. «Genera PDF» manda il documento in vigore, e scoprire un confine sbagliato
|
|
||||||
dopo significa trovarselo già nello storico.
|
|
||||||
|
|
||||||
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
|
**L'immagine è JPEG, sovracampionata e nella forma della pagina.** Tre scelte
|
||||||
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):
|
che vanno lette insieme (`config/piantine.php`, `docker/renderer/server.js`):
|
||||||
|
|
||||||
|
|||||||
@@ -3,37 +3,30 @@
|
|||||||
namespace App\Http\Controllers\Settings;
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\TerritorioMappa;
|
use App\Services\Piantine\PubblicazionePiantina;
|
||||||
use App\Services\Piantine\PiantinaPdfService;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anteprima della cartolina, senza sostituire niente.
|
* Il PDF dell'anteprima di una pubblicazione, da guardare prima di confermare.
|
||||||
*
|
*
|
||||||
* Il PDF viene composto al volo e servito da leggere: non tocca `pdf_path` del
|
* Lo prepara PubblicazionePiantina in una cartella privata: qui lo si serve
|
||||||
* territorio, non archivia versioni, non lascia file sul disco. Serve a
|
* soltanto, senza toccare il PDF in vigore del territorio. Il token è casuale
|
||||||
* guardare come viene prima di decidere — perché "Genera PDF" è un'operazione
|
* e scade con l'anteprima; la rotta resta comunque riservata
|
||||||
* che manda il documento in vigore, e vederlo solo dopo significa scoprire un
|
* all'amministratore come il resto delle piantine.
|
||||||
* confine sbagliato quando è già nello storico.
|
|
||||||
*/
|
*/
|
||||||
class PiantinaAnteprimaController extends Controller
|
class PiantinaAnteprimaController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(TerritorioMappa $mappa, PiantinaPdfService $pdfService): Response
|
public function __invoke(string $token, PubblicazionePiantina $pubblicazione): Response
|
||||||
{
|
{
|
||||||
$mappa->load('territorio.zona');
|
$percorso = $pubblicazione->pdfAnteprima($token);
|
||||||
|
|
||||||
abort_unless($mappa->collegata(), 404);
|
abort_if($percorso === null, 404);
|
||||||
|
|
||||||
$pdf = $pdfService->genera($mappa->territorio, $mappa);
|
return response()->file($percorso, [
|
||||||
|
|
||||||
return response($pdf, 200, [
|
|
||||||
'Content-Type' => 'application/pdf',
|
'Content-Type' => 'application/pdf',
|
||||||
// `inline`: si apre nel visualizzatore del browser invece di
|
// `inline`: si apre nel visualizzatore del browser invece di
|
||||||
// finire fra i download, che di un'anteprima non se ne fa niente.
|
// finire fra i download, che di un'anteprima non se ne fa niente.
|
||||||
'Content-Disposition' => 'inline; filename="anteprima-territorio-'
|
'Content-Disposition' => 'inline; filename="anteprima-piantina.pdf"',
|
||||||
. $mappa->territorio->numero . '.pdf"',
|
|
||||||
// Un'anteprima rispecchia lo stato di adesso: se la cache la
|
|
||||||
// congela, si continua a guardare il confine di prima.
|
|
||||||
'Cache-Control' => 'no-store, max-age=0',
|
'Cache-Control' => 'no-store, max-age=0',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
44
app/Jobs/PreparaAnteprimaPiantina.php
Normal file
44
app/Jobs/PreparaAnteprimaPiantina.php
Normal 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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Livewire/Concerns/Avvisa.php
Normal file
25
app/Livewire/Concerns/Avvisa.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,45 +2,54 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Piantine;
|
namespace App\Livewire\Piantine;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\TerritorioMappa;
|
use App\Models\TerritorioMappa;
|
||||||
use App\Models\Tipologia;
|
use App\Models\Tipologia;
|
||||||
use App\Models\Zona;
|
use App\Models\Zona;
|
||||||
use App\Services\Piantine\PiantinaRenderer;
|
|
||||||
use App\Services\Piantine\PiantineArea;
|
use App\Services\Piantine\PiantineArea;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editor delle piantine a schermo intero (solo amministratore).
|
* Editor delle piantine a schermo intero (solo amministratore).
|
||||||
*
|
*
|
||||||
* Una pagina sola con la mappa grande quanto la finestra e i comandi in una
|
* Tutto quello che si fa qui è bozza e si salva da solo: il confine a ogni
|
||||||
* barra sottile in alto: tagliare un confine su una cartografia larga un terzo
|
* disegno o spostamento di vertice, il nome e il collegamento appena cambiano.
|
||||||
* di schermo significa sbagliarlo, e questo disegno finisce stampato su un
|
* La bozza non tocca il PDF del territorio, che cambia solo con "Pubblica"
|
||||||
* modulo che poi gira di mano in mano.
|
* (anteprima e conferma, nel componente Pubblicazione). I dati del territorio
|
||||||
|
* — numero, zona, tipologia, confini — sono invece dati del gestionale e si
|
||||||
|
* salvano subito, come dalla scheda territorio.
|
||||||
*
|
*
|
||||||
* Il disegno viene prima del numero. Si taglia l'area guardando le strade, e
|
* Disegnare crea sempre una piantina nuova: per cambiare il confine di quella
|
||||||
* solo dopo si compila la scheda del territorio — che può anche non esistere
|
* aperta ci sono "Modifica vertici" e "Ridisegna", espliciti. Prima un
|
||||||
* ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o
|
* disegno fatto con una piantina aperta ne sostituiva il confine in silenzio.
|
||||||
* la tipologia mancano si creano sul posto, senza abbandonare il disegno.
|
*
|
||||||
|
* Con la mappa si parla per eventi, e solo con il necessario: i dati di tutte
|
||||||
|
* le piantine li chiede la mappa una volta (datiMappa), poi riceve solo la
|
||||||
|
* piantina cambiata. Prima ogni richiesta rispediva tutte le geometrie e
|
||||||
|
* l'area KML, fino a una decina di MB per aprire un pannello.
|
||||||
*/
|
*/
|
||||||
#[Layout('components.layouts.editor')]
|
#[Layout('components.layouts.editor')]
|
||||||
class PiantineEditor extends Component
|
class PiantineEditor extends Component
|
||||||
{
|
{
|
||||||
/** Piantina aperta, o null quando se ne sta disegnando una nuova. */
|
use Avvisa;
|
||||||
|
|
||||||
|
/** Piantina aperta, o null quando non ce n'è nessuna selezionata. */
|
||||||
|
#[Locked]
|
||||||
public ?int $mappaId = null;
|
public ?int $mappaId = null;
|
||||||
|
|
||||||
public string $nome = '';
|
public string $nome = '';
|
||||||
|
|
||||||
/** Geometria appena disegnata, non ancora salvata. */
|
/** Ora dell'ultimo salvataggio automatico, per l'indicazione in testata. */
|
||||||
public ?string $geometriaInAttesa = null;
|
public string $salvatoAlle = '';
|
||||||
|
|
||||||
public bool $pannelloAperto = false;
|
// ─── Territorio ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ─── Scheda del territorio ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
|
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
|
||||||
public string $territorioScelto = '';
|
public string $territorioScelto = '';
|
||||||
@@ -53,7 +62,6 @@ class PiantineEditor extends Component
|
|||||||
|
|
||||||
public string $confini = '';
|
public string $confini = '';
|
||||||
|
|
||||||
/** Campi di creazione al volo, aperti solo quando servono. */
|
|
||||||
public string $nuovaZona = '';
|
public string $nuovaZona = '';
|
||||||
|
|
||||||
public string $nuovaTipologia = '';
|
public string $nuovaTipologia = '';
|
||||||
@@ -61,7 +69,7 @@ class PiantineEditor extends Component
|
|||||||
public function mount(?int $mappa = null): void
|
public function mount(?int $mappa = null): void
|
||||||
{
|
{
|
||||||
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
|
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
|
||||||
$this->apri($mappa);
|
$this->carica(TerritorioMappa::with('territorio')->find($mappa));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,38 +81,46 @@ class PiantineEditor extends Component
|
|||||||
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
|
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
|
||||||
|
|
||||||
if (! $mappa) {
|
if (! $mappa) {
|
||||||
|
$this->avvisaErrore('Questa piantina non esiste più: forse è stata eliminata da un\'altra scheda.');
|
||||||
|
$this->dispatch('piantina-rimossa', mappaId: (int) $mappaId);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->mappaId = $mappa->id;
|
$this->carica($mappa);
|
||||||
$this->nome = (string) $mappa->nome;
|
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: true);
|
||||||
$this->geometriaInAttesa = null;
|
|
||||||
$this->pannelloAperto = true;
|
|
||||||
|
|
||||||
$this->caricaScheda($mappa->territorio);
|
|
||||||
$this->aggiornaMappaJs(inquadra: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Nuova piantina": si deseleziona e la mappa entra in modalità disegno. */
|
||||||
public function nuova(): void
|
public function nuova(): void
|
||||||
{
|
{
|
||||||
|
$this->svuota();
|
||||||
|
$this->dispatch('piantina-selezionata', mappaId: null, inquadra: false);
|
||||||
|
$this->dispatch('editor-disegna');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function carica(TerritorioMappa $mappa): void
|
||||||
|
{
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->mappaId = $mappa->id;
|
||||||
|
$this->nome = (string) $mappa->nome;
|
||||||
|
$this->salvatoAlle = '';
|
||||||
|
$this->nuovaZona = '';
|
||||||
|
$this->nuovaTipologia = '';
|
||||||
|
$this->caricaTerritorio($mappa->territorio);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function svuota(): void
|
||||||
|
{
|
||||||
|
$this->resetValidation();
|
||||||
$this->reset([
|
$this->reset([
|
||||||
'mappaId', 'nome', 'geometriaInAttesa',
|
'mappaId', 'nome', 'salvatoAlle',
|
||||||
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
|
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
|
||||||
'nuovaZona', 'nuovaTipologia',
|
'nuovaZona', 'nuovaTipologia',
|
||||||
]);
|
]);
|
||||||
$this->resetValidation();
|
|
||||||
$this->pannelloAperto = true;
|
|
||||||
|
|
||||||
$this->aggiornaMappaJs();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function chiudiPannello(): void
|
private function caricaTerritorio(?Territorio $territorio): void
|
||||||
{
|
|
||||||
$this->pannelloAperto = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Riempie i campi della scheda dal territorio collegato (o li svuota). */
|
|
||||||
private function caricaScheda(?Territorio $territorio): void
|
|
||||||
{
|
{
|
||||||
$this->territorioScelto = $territorio ? (string) $territorio->id : '';
|
$this->territorioScelto = $territorio ? (string) $territorio->id : '';
|
||||||
$this->numero = (string) ($territorio->numero ?? '');
|
$this->numero = (string) ($territorio->numero ?? '');
|
||||||
@@ -113,12 +129,118 @@ class PiantineEditor extends Component
|
|||||||
$this->confini = (string) ($territorio->confini ?? '');
|
$this->confini = (string) ($territorio->confini ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cambiare territorio nella tendina ricarica la sua scheda. */
|
// ─── Confine (bozza, salvataggio automatico) ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arriva dalla mappa dopo un disegno o lo spostamento di un vertice.
|
||||||
|
* $nuova: il disegno è una piantina nuova (strumento "Disegna"), non il
|
||||||
|
* confine ridisegnato di quella aperta.
|
||||||
|
*/
|
||||||
|
#[On('geometria-modificata')]
|
||||||
|
public function salvaGeometria($geometry, bool $nuova = false): void
|
||||||
|
{
|
||||||
|
if (! $this->geometriaValida($geometry)) {
|
||||||
|
$this->avvisaErrore('Il confine disegnato non è valido: ridisegnalo.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$geojson = json_encode($geometry);
|
||||||
|
$mappa = (! $nuova && $this->mappaId) ? TerritorioMappa::find($this->mappaId) : null;
|
||||||
|
|
||||||
|
if ($mappa) {
|
||||||
|
$mappa->update(['geojson' => $geojson]);
|
||||||
|
} else {
|
||||||
|
$mappa = TerritorioMappa::create(['geojson' => $geojson]);
|
||||||
|
$this->carica($mappa);
|
||||||
|
activity()->causedBy(auth()->user())->log('piantina_creata');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->salvato($mappa, seleziona: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anelli chiusi di almeno quattro punti con coordinate nei limiti: un
|
||||||
|
* record malformato faceva fallire il disegno di tutte le piantine.
|
||||||
|
*/
|
||||||
|
private function geometriaValida($geometry): bool
|
||||||
|
{
|
||||||
|
if (! is_array($geometry) || ! is_array($geometry['coordinates'] ?? null)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$poligoni = match ($geometry['type'] ?? null) {
|
||||||
|
'Polygon' => [$geometry['coordinates']],
|
||||||
|
'MultiPolygon' => $geometry['coordinates'],
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! $poligoni) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($poligoni as $anelli) {
|
||||||
|
if (! is_array($anelli) || $anelli === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ($anelli as $anello) {
|
||||||
|
if (! is_array($anello) || count($anello) < 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ($anello as $punto) {
|
||||||
|
if (! is_array($punto) || count($punto) < 2
|
||||||
|
|| ! is_numeric($punto[0]) || ! is_numeric($punto[1])
|
||||||
|
|| abs($punto[0]) > 180 || abs($punto[1]) > 90) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Riporta il confine a quello pubblicato. */
|
||||||
|
public function scartaModifiche(): void
|
||||||
|
{
|
||||||
|
$mappa = $this->mappaAperta();
|
||||||
|
|
||||||
|
if (! $mappa || ! $mappa->bozzaScartabile()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappa->update(['geojson' => $mappa->geojson_pubblicato]);
|
||||||
|
$this->salvato($mappa, seleziona: true);
|
||||||
|
$this->avvisa('Confine riportato a quello pubblicato.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedNome(): void
|
||||||
|
{
|
||||||
|
$mappa = $this->mappaAperta();
|
||||||
|
|
||||||
|
if (! $mappa) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nome = trim($this->nome);
|
||||||
|
$mappa->update(['nome' => $nome !== '' ? mb_substr($nome, 0, 120) : null]);
|
||||||
|
$this->salvato($mappa);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Collegamento al territorio ─────────────────────────────────────────
|
||||||
|
|
||||||
public function updatedTerritorioScelto(string $valore): void
|
public function updatedTerritorioScelto(string $valore): void
|
||||||
{
|
{
|
||||||
$this->resetValidation();
|
$this->resetValidation();
|
||||||
|
$mappa = $this->mappaAperta();
|
||||||
|
|
||||||
|
if (! $mappa) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($valore === 'nuovo') {
|
if ($valore === 'nuovo') {
|
||||||
|
// Si compilano i campi e si crea con il pulsante: un territorio
|
||||||
|
// non nasce da un salvataggio automatico a metà digitazione.
|
||||||
$this->numero = '';
|
$this->numero = '';
|
||||||
$this->zonaId = '';
|
$this->zonaId = '';
|
||||||
$this->tipologiaId = '';
|
$this->tipologiaId = '';
|
||||||
@@ -127,34 +249,158 @@ class PiantineEditor extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->caricaScheda($valore === '' ? null : Territorio::find($valore));
|
if ($valore === '') {
|
||||||
}
|
$numero = $mappa->territorio?->numero;
|
||||||
|
$mappa->update([
|
||||||
|
'territorio_id' => null,
|
||||||
|
// Senza territorio serve un'etichetta, altrimenti la piantina
|
||||||
|
// finisce in elenco come un anonimo "Piantina #12".
|
||||||
|
'nome' => $mappa->nome ?: ($numero ? "Ex territorio {$numero}" : null),
|
||||||
|
]);
|
||||||
|
$this->nome = (string) $mappa->nome;
|
||||||
|
$this->caricaTerritorio(null);
|
||||||
|
$this->salvato($mappa);
|
||||||
|
|
||||||
// ─── Disegno ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[On('poligono-disegnato')]
|
|
||||||
public function poligonoDisegnato($geometry): void
|
|
||||||
{
|
|
||||||
if (! is_array($geometry)
|
|
||||||
|| ! in_array($geometry['type'] ?? null, ['Polygon', 'MultiPolygon'], true)
|
|
||||||
|| ! is_array($geometry['coordinates'] ?? null)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->geometriaInAttesa = json_encode($geometry);
|
$territorio = Territorio::find($valore);
|
||||||
$this->pannelloAperto = true;
|
if (! $territorio) {
|
||||||
|
$this->addError('territorioScelto', 'Il territorio scelto non esiste più.');
|
||||||
|
$this->caricaTerritorio($mappa->territorio);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scartaDisegno(): void
|
$occupata = TerritorioMappa::where('territorio_id', $territorio->id)->whereKeyNot($mappa->id)->exists();
|
||||||
|
if ($occupata) {
|
||||||
|
$this->addError('territorioScelto', "Il territorio {$territorio->numero} ha già una piantina.");
|
||||||
|
$this->caricaTerritorio($mappa->territorio);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappa->update(['territorio_id' => $territorio->id]);
|
||||||
|
$this->caricaTerritorio($territorio);
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
|
||||||
|
$this->salvato($mappa);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creaTerritorio(): void
|
||||||
{
|
{
|
||||||
$this->geometriaInAttesa = null;
|
$mappa = $this->mappaAperta();
|
||||||
$this->aggiornaMappaJs();
|
|
||||||
|
if (! $mappa || $this->territorioScelto !== 'nuovo') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->numero = trim($this->numero);
|
||||||
|
$this->confini = trim($this->confini);
|
||||||
|
|
||||||
|
$this->validate(
|
||||||
|
[
|
||||||
|
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')],
|
||||||
|
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
||||||
|
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
||||||
|
'confini' => ['nullable', 'string', 'max:5000'],
|
||||||
|
],
|
||||||
|
$this->messaggiTerritorio(),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$territorio = DB::transaction(function () use ($mappa) {
|
||||||
|
$territorio = Territorio::create($this->valoriTerritorio() + ['attivo' => true]);
|
||||||
|
$mappa->update(['territorio_id' => $territorio->id]);
|
||||||
|
|
||||||
|
return $territorio;
|
||||||
|
});
|
||||||
|
} catch (\Illuminate\Database\UniqueConstraintViolationException) {
|
||||||
|
$this->addError('numero', 'Esiste già un territorio con questo numero.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
|
||||||
|
|
||||||
|
$this->caricaTerritorio($territorio);
|
||||||
|
$this->salvato($mappa);
|
||||||
|
$this->avvisa("Territorio {$territorio->numero} creato e collegato.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dati del territorio (salvati subito nel gestionale) ────────────────
|
||||||
|
|
||||||
|
public function updatedNumero(): void
|
||||||
|
{
|
||||||
|
$this->salvaCampoTerritorio('numero');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedZonaId(): void
|
||||||
|
{
|
||||||
|
$this->salvaCampoTerritorio('zonaId');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedTipologiaId(): void
|
||||||
|
{
|
||||||
|
$this->salvaCampoTerritorio('tipologiaId');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedConfini(): void
|
||||||
|
{
|
||||||
|
$this->salvaCampoTerritorio('confini');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function salvaCampoTerritorio(string $campo): void
|
||||||
|
{
|
||||||
|
// In creazione i campi si raccolgono e basta: li salva creaTerritorio().
|
||||||
|
$territorio = $this->territorioCollegato();
|
||||||
|
if (! $territorio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->numero = trim($this->numero);
|
||||||
|
|
||||||
|
$regole = [
|
||||||
|
'numero' => ['required', 'string', 'max:20', Rule::unique('territori', 'numero')->ignore($territorio->id)],
|
||||||
|
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
||||||
|
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
||||||
|
'confini' => ['nullable', 'string', 'max:5000'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->validateOnly($campo, $regole, $this->messaggiTerritorio());
|
||||||
|
|
||||||
|
// Solo la colonna del campo cambiato: con un altro campo ancora non
|
||||||
|
// valido a schermo, riscrivere tutto salverebbe anche quel valore.
|
||||||
|
$colonna = ['numero' => 'numero', 'zonaId' => 'zona_id', 'tipologiaId' => 'tipologia_id', 'confini' => 'confini'][$campo];
|
||||||
|
$territorio->update([$colonna => $this->valoriTerritorio()[$colonna]]);
|
||||||
|
|
||||||
|
$this->salvato($this->mappaAperta());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function valoriTerritorio(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'numero' => trim($this->numero),
|
||||||
|
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
|
||||||
|
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
|
||||||
|
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function messaggiTerritorio(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'numero.required' => 'Il territorio ha bisogno di un numero.',
|
||||||
|
'numero.unique' => 'Esiste già un territorio con questo numero.',
|
||||||
|
'numero.max' => 'Il numero può avere al massimo 20 caratteri.',
|
||||||
|
'confini.max' => 'I confini possono avere al massimo 5000 caratteri.',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Zona e tipologia create sul posto ──────────────────────────────────
|
// ─── Zona e tipologia create sul posto ──────────────────────────────────
|
||||||
|
|
||||||
public function creaZona()
|
public function creaZona(): void
|
||||||
{
|
{
|
||||||
|
$this->nuovaZona = trim($this->nuovaZona);
|
||||||
$dati = $this->validate(
|
$dati = $this->validate(
|
||||||
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
|
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
|
||||||
[
|
[
|
||||||
@@ -163,16 +409,17 @@ class PiantineEditor extends Component
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$zona = Zona::create(['nome' => trim($dati['nuovaZona']), 'attivo' => true]);
|
$zona = Zona::create(['nome' => $dati['nuovaZona'], 'attivo' => true]);
|
||||||
|
|
||||||
$this->zonaId = (string) $zona->id;
|
$this->zonaId = (string) $zona->id;
|
||||||
$this->nuovaZona = '';
|
$this->nuovaZona = '';
|
||||||
|
$this->updatedZonaId();
|
||||||
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata.");
|
$this->avvisa("Zona «{$zona->nome}» creata e selezionata.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function creaTipologia()
|
public function creaTipologia(): void
|
||||||
{
|
{
|
||||||
|
$this->nuovaTipologia = trim($this->nuovaTipologia);
|
||||||
$dati = $this->validate(
|
$dati = $this->validate(
|
||||||
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
|
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
|
||||||
[
|
[
|
||||||
@@ -181,221 +428,155 @@ class PiantineEditor extends Component
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$tipologia = Tipologia::create(['nome' => trim($dati['nuovaTipologia']), 'attivo' => true]);
|
$tipologia = Tipologia::create(['nome' => $dati['nuovaTipologia'], 'attivo' => true]);
|
||||||
|
|
||||||
$this->tipologiaId = (string) $tipologia->id;
|
$this->tipologiaId = (string) $tipologia->id;
|
||||||
$this->nuovaTipologia = '';
|
$this->nuovaTipologia = '';
|
||||||
|
$this->updatedTipologiaId();
|
||||||
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata.");
|
$this->avvisa("Tipologia «{$tipologia->nome}» creata e selezionata.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Salvataggio ────────────────────────────────────────────────────────
|
// ─── Pubblicazione ed eliminazione ──────────────────────────────────────
|
||||||
|
|
||||||
public function salva()
|
public function pubblica(): void
|
||||||
{
|
{
|
||||||
$mappa = $this->mappaId ? TerritorioMappa::find($this->mappaId) : null;
|
$mappa = $this->mappaAperta();
|
||||||
|
|
||||||
if (! $mappa && ! $this->geometriaInAttesa) {
|
|
||||||
session()->flash('error', 'Disegna il poligono prima di salvare.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$territorio = $this->salvaScheda($mappa);
|
|
||||||
|
|
||||||
// salvaScheda() torna false quando la validazione della scheda non
|
|
||||||
// passa: in quel caso il messaggio è già a schermo e non si tocca nulla.
|
|
||||||
if ($territorio === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$valori = [
|
|
||||||
'territorio_id' => $territorio?->id,
|
|
||||||
'nome' => trim($this->nome) !== '' ? trim($this->nome) : null,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($this->geometriaInAttesa) {
|
|
||||||
$valori['geojson'] = $this->geometriaInAttesa;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($mappa) {
|
|
||||||
$mappa->update($valori);
|
|
||||||
} else {
|
|
||||||
$mappa = TerritorioMappa::create($valori);
|
|
||||||
$this->mappaId = $mappa->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->geometriaInAttesa = null;
|
|
||||||
|
|
||||||
activity()->causedBy(auth()->user())->log('piantina_salvata');
|
|
||||||
session()->flash('success', $mappa->fresh()->load('territorio')->etichetta() . ' salvata.');
|
|
||||||
|
|
||||||
$this->aggiornaMappaJs();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea o aggiorna il territorio della scheda.
|
|
||||||
*
|
|
||||||
* @return Territorio|null|false Il territorio collegato, null se la
|
|
||||||
* piantina resta scollegata, false se la
|
|
||||||
* scheda non è valida.
|
|
||||||
*/
|
|
||||||
private function salvaScheda(?TerritorioMappa $mappa): Territorio|null|false
|
|
||||||
{
|
|
||||||
if ($this->territorioScelto === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$esistente = $this->territorioScelto === 'nuovo'
|
|
||||||
? null
|
|
||||||
: Territorio::find($this->territorioScelto);
|
|
||||||
|
|
||||||
if ($this->territorioScelto !== 'nuovo' && ! $esistente) {
|
|
||||||
session()->flash('error', 'Il territorio scelto non esiste più.');
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->validate(
|
|
||||||
[
|
|
||||||
'numero' => [
|
|
||||||
'required', 'string', 'max:20',
|
|
||||||
Rule::unique('territori', 'numero')->ignore($esistente?->id),
|
|
||||||
],
|
|
||||||
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
|
|
||||||
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
|
|
||||||
'confini' => ['nullable', 'string', 'max:5000'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'numero.required' => 'Il territorio ha bisogno di un numero.',
|
|
||||||
'numero.unique' => 'Esiste già un territorio con questo numero.',
|
|
||||||
],
|
|
||||||
['numero' => 'numero'],
|
|
||||||
);
|
|
||||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
||||||
$this->pannelloAperto = true;
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Un territorio ha una piantina sola.
|
|
||||||
if ($esistente) {
|
|
||||||
$occupato = TerritorioMappa::where('territorio_id', $esistente->id)
|
|
||||||
->when($mappa, fn ($q) => $q->where('id', '!=', $mappa->id))
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($occupato) {
|
|
||||||
session()->flash('error', "Il territorio {$esistente->numero} ha già una piantina: scollegala prima di assegnargli questa.");
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$valori = [
|
|
||||||
'numero' => trim($this->numero),
|
|
||||||
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
|
|
||||||
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
|
|
||||||
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($esistente) {
|
|
||||||
$esistente->update($valori);
|
|
||||||
return $esistente;
|
|
||||||
}
|
|
||||||
|
|
||||||
$territorio = Territorio::create($valori + ['attivo' => true]);
|
|
||||||
$this->territorioScelto = (string) $territorio->id;
|
|
||||||
|
|
||||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
|
|
||||||
|
|
||||||
return $territorio;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Immagine ed eliminazione ───────────────────────────────────────────
|
|
||||||
|
|
||||||
public function generaImmagine(PiantinaRenderer $renderer)
|
|
||||||
{
|
|
||||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
|
||||||
|
|
||||||
if (! $mappa) {
|
if (! $mappa) {
|
||||||
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.');
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
||||||
|
$this->avvisaErrore($motivo);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
|
||||||
$renderer->render($mappa);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
session()->flash('error', $e->getMessage());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
#[On('piantina-pubblicata')]
|
||||||
}
|
public function pubblicata(int $mappaId): void
|
||||||
|
|
||||||
public function elimina()
|
|
||||||
{
|
{
|
||||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
|
||||||
|
|
||||||
|
if ($mappa) {
|
||||||
|
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function elimina(): void
|
||||||
|
{
|
||||||
|
$mappa = $this->mappaAperta();
|
||||||
|
|
||||||
if (! $mappa) {
|
if (! $mappa) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$etichetta = $mappa->etichetta();
|
$etichetta = $mappa->etichetta();
|
||||||
|
$id = $mappa->id;
|
||||||
|
|
||||||
if ($mappa->image_path) {
|
if ($mappa->image_path) {
|
||||||
Storage::disk('public')->delete($mappa->image_path);
|
Storage::disk('public')->delete($mappa->image_path);
|
||||||
}
|
}
|
||||||
$mappa->delete();
|
$mappa->delete();
|
||||||
|
|
||||||
$this->nuova();
|
$this->svuota();
|
||||||
session()->flash('success', "{$etichetta} eliminata.");
|
$this->dispatch('piantina-rimossa', mappaId: $id);
|
||||||
|
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Dati per la mappa ──────────────────────────────────────────────────
|
// ─── Dati per la mappa ──────────────────────────────────────────────────
|
||||||
|
|
||||||
public function datiMappa(?PiantineArea $area = null): array
|
/** Chiamata una volta dalla mappa all'avvio ($wire.datiMappa()). */
|
||||||
|
public function datiMappa(PiantineArea $area): array
|
||||||
{
|
{
|
||||||
$area ??= app(PiantineArea::class);
|
|
||||||
|
|
||||||
$piantine = TerritorioMappa::with('territorio')->get()
|
|
||||||
->filter(fn ($m) => $m->geometria() !== null)
|
|
||||||
->map(fn ($m) => [
|
|
||||||
'mappaId' => $m->id,
|
|
||||||
'etichetta' => $m->etichetta(),
|
|
||||||
'collegata' => $m->collegata(),
|
|
||||||
'geometry' => $m->geometria(),
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'area' => $area->area(),
|
'area' => $area->area(),
|
||||||
'piantine' => $piantine,
|
'piantine' => TerritorioMappa::with('territorio.zona')->get()
|
||||||
|
->filter(fn (TerritorioMappa $m) => $m->geometria() !== null)
|
||||||
|
->map(fn (TerritorioMappa $m) => $this->datiPiantina($m))
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
'selezionata' => $this->mappaId,
|
'selezionata' => $this->mappaId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function aggiornaMappaJs(bool $inquadra = false): void
|
private function datiPiantina(TerritorioMappa $mappa): array
|
||||||
{
|
{
|
||||||
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa(), inquadra: $inquadra);
|
return [
|
||||||
|
'mappaId' => $mappa->id,
|
||||||
|
'etichetta' => $mappa->etichetta(),
|
||||||
|
'collegata' => $mappa->collegata(),
|
||||||
|
'stato' => $mappa->stato(),
|
||||||
|
'statoEtichetta' => $mappa->statoEtichetta(),
|
||||||
|
'geometry' => $mappa->geometria(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(PiantineArea $area)
|
/** Dopo ogni salvataggio automatico: ora in testata, mappa e indirizzo allineati. */
|
||||||
|
private function salvato(?TerritorioMappa $mappa, bool $seleziona = false): void
|
||||||
{
|
{
|
||||||
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
|
if (! $mappa) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappa = $mappa->fresh(['territorio.zona']);
|
||||||
|
$this->salvatoAlle = now()->format('H:i');
|
||||||
|
|
||||||
|
$this->dispatch('piantina-aggiornata', piantina: $this->datiPiantina($mappa));
|
||||||
|
|
||||||
|
if ($seleziona) {
|
||||||
|
$this->dispatch('piantina-selezionata', mappaId: $mappa->id, inquadra: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// La piantina aperta resta nell'indirizzo: ricaricando la pagina si
|
||||||
|
// torna su questa e non su un editor vuoto.
|
||||||
|
$this->dispatch('editor-indirizzo', url: route('piantine.editor', $mappa->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mappaAperta(): ?TerritorioMappa
|
||||||
|
{
|
||||||
|
return $this->mappaId ? TerritorioMappa::with('territorio.zona')->find($this->mappaId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function territorioCollegato(): ?Territorio
|
||||||
|
{
|
||||||
|
if ($this->territorioScelto === '' || $this->territorioScelto === 'nuovo') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappa = $this->mappaAperta();
|
||||||
|
|
||||||
|
return $mappa?->territorio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$mappa = $this->mappaAperta();
|
||||||
|
|
||||||
|
// Nella tendina: i territori attivi senza piantina, più quello già
|
||||||
|
// collegato anche se nel frattempo è stato disattivato o cestinato.
|
||||||
|
$occupati = TerritorioMappa::whereNotNull('territorio_id')
|
||||||
|
->when($mappa, fn ($q) => $q->whereKeyNot($mappa->id))
|
||||||
|
->pluck('territorio_id');
|
||||||
|
|
||||||
|
$territori = Territorio::attivi()
|
||||||
|
->whereNotIn('id', $occupati)
|
||||||
|
->orderByRaw('LENGTH(numero), numero')
|
||||||
|
->get(['id', 'numero', 'attivo', 'deleted_at']);
|
||||||
|
|
||||||
|
if ($mappa?->territorio && ! $territori->contains('id', $mappa->territorio_id)) {
|
||||||
|
$territori->prepend($mappa->territorio);
|
||||||
|
}
|
||||||
|
|
||||||
return view('livewire.piantine.piantine-editor', [
|
return view('livewire.piantine.piantine-editor', [
|
||||||
'mappa' => $mappa,
|
'mappa' => $mappa,
|
||||||
'areaCaricata' => $area->area() !== null,
|
'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
|
||||||
'datiMappaIniziali' => $this->datiMappa($area),
|
'territori' => $territori,
|
||||||
'territori' => Territorio::attivi()
|
|
||||||
->orderByRaw('LENGTH(numero), numero')
|
|
||||||
->get(['id', 'numero']),
|
|
||||||
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
||||||
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
|
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
|
||||||
'title' => 'Editor delle piantine',
|
'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,38 +2,60 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Piantine;
|
namespace App\Livewire\Piantine;
|
||||||
|
|
||||||
use App\Models\Territorio;
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use App\Models\TerritorioMappa;
|
use App\Models\TerritorioMappa;
|
||||||
use App\Models\TerritorioPdfVersione;
|
use App\Models\TerritorioPdfVersione;
|
||||||
use App\Services\Piantine\PiantinaPdfService;
|
use App\Services\Piantine\PiantinaPdfService;
|
||||||
use App\Services\Piantine\PiantinaRenderer;
|
|
||||||
use App\Services\Piantine\PiantineArea;
|
use App\Services\Piantine\PiantineArea;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pagina di gestione delle piantine (solo amministratore).
|
* Pagina di gestione delle piantine (solo amministratore).
|
||||||
*
|
*
|
||||||
* Qui si tiene l'area della città, si generano immagini e PDF, si collegano
|
* È il quadro d'insieme: a che punto è ogni piantina (bozza, pubblicata,
|
||||||
* le piantine ai territori e si ripescano le versioni sostituite. Il disegno
|
* con modifiche da pubblicare), l'area della città e lo storico dei PDF
|
||||||
* non sta qui: ha una pagina sua a schermo intero (PiantineEditor), perché su
|
* sostituiti. Il disegno e i dati del territorio si toccano nell'editor;
|
||||||
* una mappa larga un terzo di schermo non si taglia un confine.
|
* da qui si apre, si pubblica (con la stessa finestra di anteprima
|
||||||
|
* dell'editor) e si elimina.
|
||||||
*/
|
*/
|
||||||
class PiantineManager extends Component
|
class PiantineManager extends Component
|
||||||
{
|
{
|
||||||
|
use Avvisa;
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public const FILTRI = [
|
||||||
|
'tutte' => 'Tutte',
|
||||||
|
'bozze' => 'Bozze',
|
||||||
|
'da-pubblicare' => 'Da pubblicare',
|
||||||
|
'pubblicate' => 'Pubblicate',
|
||||||
|
'scollegate' => 'Scollegate',
|
||||||
|
];
|
||||||
|
|
||||||
|
#[Url(as: 'q', except: '')]
|
||||||
|
public string $cerca = '';
|
||||||
|
|
||||||
|
#[Url(except: 'tutte')]
|
||||||
|
public string $filtro = 'tutte';
|
||||||
|
|
||||||
public $kmlFile = null;
|
public $kmlFile = null;
|
||||||
|
|
||||||
/** Piantina di cui è aperto il pannello "collega a un territorio". */
|
public function updatedFiltro(): void
|
||||||
public ?int $collegamentoAperto = null;
|
{
|
||||||
|
if (! array_key_exists($this->filtro, self::FILTRI)) {
|
||||||
public ?int $territorioDaCollegare = null;
|
$this->filtro = 'tutte';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Area città (KML) ───────────────────────────────────────────────────
|
// ─── Area città (KML) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
public function caricaKml(PiantineArea $area)
|
public function caricaKml(PiantineArea $area): void
|
||||||
{
|
{
|
||||||
$this->validate(
|
$this->validate(
|
||||||
['kmlFile' => 'required|file|max:10240'],
|
['kmlFile' => 'required|file|max:10240'],
|
||||||
@@ -62,111 +84,42 @@ class PiantineManager extends Component
|
|||||||
$this->kmlFile = null;
|
$this->kmlFile = null;
|
||||||
|
|
||||||
activity()->causedBy(auth()->user())->log('piantine_kml_caricato');
|
activity()->causedBy(auth()->user())->log('piantine_kml_caricato');
|
||||||
session()->flash('success', "Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
|
$this->avvisa("Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Generazione ────────────────────────────────────────────────────────
|
// ─── Piantine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function generaImmagine(int $mappaId, PiantinaRenderer $renderer)
|
public function pubblica(int $mappaId): void
|
||||||
{
|
{
|
||||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
$mappa = TerritorioMappa::with('territorio.zona')->find($mappaId);
|
||||||
|
|
||||||
try {
|
if (! $mappa) {
|
||||||
$renderer->render($mappa);
|
$this->avvisaErrore('La piantina non esiste più.');
|
||||||
} catch (\Throwable $e) {
|
|
||||||
session()->flash('error', $e->getMessage());
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
if ($motivo = $mappa->motivoNonPubblicabile()) {
|
||||||
}
|
$this->avvisaErrore("{$mappa->etichetta()}: {$motivo}");
|
||||||
|
|
||||||
public function generaPdf(int $mappaId, PiantinaPdfService $pdfService)
|
|
||||||
{
|
|
||||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
|
||||||
|
|
||||||
if (! $mappa->collegata()) {
|
|
||||||
session()->flash('error', 'Questa piantina non è collegata a nessun territorio: collegala prima di generare il PDF.');
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$this->dispatch('pubblica-piantina', mappaId: $mappa->id)->to(Pubblicazione::class);
|
||||||
$pdfService->sostituisci($mappa->territorio, $mappa);
|
}
|
||||||
} catch (\Throwable $e) {
|
|
||||||
session()->flash('error', $e->getMessage());
|
|
||||||
|
|
||||||
|
/** La finestra di pubblicazione ha finito: basta ridisegnare l'elenco. */
|
||||||
|
#[On('piantina-pubblicata')]
|
||||||
|
public function pubblicata(): void {}
|
||||||
|
|
||||||
|
public function elimina(int $mappaId): void
|
||||||
|
{
|
||||||
|
$mappa = TerritorioMappa::with('territorio')->find($mappaId);
|
||||||
|
|
||||||
|
if (! $mappa) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', "PDF del territorio {$mappa->territorio->numero} sostituito. Il precedente è nello storico versioni.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Collegamento al territorio ─────────────────────────────────────────
|
|
||||||
|
|
||||||
public function apriCollegamento(int $mappaId): void
|
|
||||||
{
|
|
||||||
$this->collegamentoAperto = $mappaId;
|
|
||||||
$this->territorioDaCollegare = TerritorioMappa::find($mappaId)?->territorio_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function chiudiCollegamento(): void
|
|
||||||
{
|
|
||||||
$this->collegamentoAperto = null;
|
|
||||||
$this->territorioDaCollegare = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function collega(int $mappaId)
|
|
||||||
{
|
|
||||||
$mappa = TerritorioMappa::findOrFail($mappaId);
|
|
||||||
|
|
||||||
if (! $this->territorioDaCollegare) {
|
|
||||||
session()->flash('error', 'Scegli il territorio a cui collegare la piantina.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$territorio = Territorio::findOrFail($this->territorioDaCollegare);
|
|
||||||
|
|
||||||
// Un territorio ha una piantina sola: se ne aveva già un'altra il
|
|
||||||
// vincolo di unicità la respingerebbe con un errore di database, che
|
|
||||||
// non spiega niente a chi sta guardando la pagina.
|
|
||||||
$occupato = TerritorioMappa::where('territorio_id', $territorio->id)
|
|
||||||
->where('id', '!=', $mappa->id)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($occupato) {
|
|
||||||
session()->flash('error', "Il territorio {$territorio->numero} ha già una piantina: scollegala prima di assegnargli questa.");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mappa->update(['territorio_id' => $territorio->id]);
|
|
||||||
$this->chiudiCollegamento();
|
|
||||||
|
|
||||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
|
|
||||||
session()->flash('success', "Piantina collegata al territorio {$territorio->numero}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scollega(int $mappaId)
|
|
||||||
{
|
|
||||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
|
||||||
$numero = $mappa->territorio?->numero ?? '?';
|
|
||||||
|
|
||||||
// Senza territorio serve un'etichetta, altrimenti la piantina sparisce
|
|
||||||
// dall'elenco sotto un anonimo "Piantina #12".
|
|
||||||
$mappa->update([
|
|
||||||
'territorio_id' => null,
|
|
||||||
'nome' => $mappa->nome ?: "Ex territorio {$numero}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->flash('success', "Piantina scollegata dal territorio {$numero}. Il PDF del territorio resta com'è.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function eliminaPiantina(int $mappaId)
|
|
||||||
{
|
|
||||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
|
||||||
$etichetta = $mappa->etichetta();
|
$etichetta = $mappa->etichetta();
|
||||||
|
|
||||||
if ($mappa->image_path) {
|
if ($mappa->image_path) {
|
||||||
@@ -174,59 +127,106 @@ class PiantineManager extends Component
|
|||||||
}
|
}
|
||||||
$mappa->delete();
|
$mappa->delete();
|
||||||
|
|
||||||
session()->flash('success', "{$etichetta}: poligono e immagine eliminati. Il PDF del territorio resta com'è.");
|
$this->avvisa("{$etichetta} eliminata. Il territorio e il suo PDF restano com'erano.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Storico versioni ───────────────────────────────────────────────────
|
// ─── Storico versioni ───────────────────────────────────────────────────
|
||||||
|
|
||||||
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService)
|
public function ripristinaVersione(int $versioneId, PiantinaPdfService $pdfService): void
|
||||||
{
|
{
|
||||||
$versione = TerritorioPdfVersione::with('territorio')->findOrFail($versioneId);
|
$versione = TerritorioPdfVersione::with('territorio')->find($versioneId);
|
||||||
|
|
||||||
|
if (! $versione) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$numero = $versione->territorio?->numero ?? '?';
|
$numero = $versione->territorio?->numero ?? '?';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdfService->ripristina($versione);
|
$pdfService->ripristina($versione);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
session()->flash('error', $e->getMessage());
|
$this->avvisaErrore($e->getMessage());
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', "Versione precedente ripristinata per il territorio {$numero}.");
|
$this->avvisa("Versione precedente ripristinata per il territorio {$numero}. Il PDF che c'era è nello storico.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function eliminaVersione(int $versioneId)
|
public function eliminaVersione(int $versioneId): void
|
||||||
{
|
{
|
||||||
$versione = TerritorioPdfVersione::findOrFail($versioneId);
|
$versione = TerritorioPdfVersione::find($versioneId);
|
||||||
|
|
||||||
if (Storage::disk('public')->exists($versione->pdf_path)) {
|
if (! $versione) {
|
||||||
Storage::disk('public')->delete($versione->pdf_path);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Storage::disk('public')->delete($versione->pdf_path);
|
||||||
$versione->delete();
|
$versione->delete();
|
||||||
|
|
||||||
session()->flash('success', 'Versione eliminata definitivamente.');
|
$this->avvisa('Versione eliminata definitivamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dati per la vista ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tutte le piantine con il loro stato, in memoria: sono qualche centinaio
|
||||||
|
* al massimo, e lo stato dipende dall'impronta, che si calcola in PHP.
|
||||||
|
*
|
||||||
|
* @return Collection<int, TerritorioMappa>
|
||||||
|
*/
|
||||||
|
private function piantine(): Collection
|
||||||
|
{
|
||||||
|
return TerritorioMappa::with('territorio.zona')
|
||||||
|
->get()
|
||||||
|
->sortBy(fn (TerritorioMappa $m) => [
|
||||||
|
$m->territorio_id === null ? 1 : 0,
|
||||||
|
strlen((string) $m->territorio?->numero),
|
||||||
|
(string) $m->territorio?->numero,
|
||||||
|
mb_strtolower((string) $m->nome),
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function corrisponde(TerritorioMappa $mappa, string $filtro): bool
|
||||||
|
{
|
||||||
|
return match ($filtro) {
|
||||||
|
'bozze' => $mappa->stato() === TerritorioMappa::STATO_BOZZA,
|
||||||
|
'da-pubblicare' => $mappa->stato() === TerritorioMappa::STATO_MODIFICATA,
|
||||||
|
'pubblicate' => $mappa->stato() === TerritorioMappa::STATO_PUBBLICATA,
|
||||||
|
'scollegate' => ! $mappa->collegata(),
|
||||||
|
default => true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(PiantineArea $area)
|
public function render(PiantineArea $area)
|
||||||
{
|
{
|
||||||
|
$piantine = $this->piantine();
|
||||||
|
|
||||||
|
$conteggi = collect(self::FILTRI)
|
||||||
|
->map(fn ($etichetta, $filtro) => $piantine->filter(fn ($m) => $this->corrisponde($m, $filtro))->count());
|
||||||
|
|
||||||
|
$cerca = mb_strtolower(trim($this->cerca));
|
||||||
|
|
||||||
|
$elenco = $piantine
|
||||||
|
->filter(fn ($m) => $this->corrisponde($m, $this->filtro))
|
||||||
|
->filter(fn (TerritorioMappa $m) => $cerca === '' || str_contains(mb_strtolower(implode(' ', [
|
||||||
|
$m->etichetta(),
|
||||||
|
$m->territorio?->numero,
|
||||||
|
$m->nome,
|
||||||
|
$m->territorio?->zona?->nome,
|
||||||
|
])), $cerca))
|
||||||
|
->values();
|
||||||
|
|
||||||
return view('livewire.piantine.piantine-manager', [
|
return view('livewire.piantine.piantine-manager', [
|
||||||
'config' => $area->config(),
|
'config' => $area->config(),
|
||||||
'piantine' => TerritorioMappa::with('territorio')
|
'elenco' => $elenco,
|
||||||
->get()
|
'totale' => $piantine->count(),
|
||||||
->sortBy(fn ($m) => [
|
'conteggi' => $conteggi,
|
||||||
$m->collegata() ? 0 : 1,
|
|
||||||
strlen((string) $m->territorio?->numero),
|
|
||||||
(string) $m->territorio?->numero,
|
|
||||||
(string) $m->nome,
|
|
||||||
])
|
|
||||||
->values(),
|
|
||||||
'territori' => Territorio::attivi()
|
|
||||||
->orderByRaw('LENGTH(numero), numero')
|
|
||||||
->get(['id', 'numero']),
|
|
||||||
'versioni' => TerritorioPdfVersione::with('territorio')
|
'versioni' => TerritorioPdfVersione::with('territorio')
|
||||||
->orderByDesc('sostituito_il')
|
->orderByDesc('sostituito_il')
|
||||||
->limit(30)
|
->orderByDesc('id')
|
||||||
->get(),
|
->paginate(15, pageName: 'versioni'),
|
||||||
'title' => 'Piantine dei territori',
|
'title' => 'Piantine dei territori',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
124
app/Livewire/Piantine/Pubblicazione.php
Normal file
124
app/Livewire/Piantine/Pubblicazione.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,15 +11,29 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il
|
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il
|
||||||
* campo `nome` come etichetta; una volta collegato comanda il numero del
|
* campo `nome` come etichetta; una volta collegato comanda il numero del
|
||||||
* territorio, che e' il riferimento con cui lo chiamano tutti.
|
* territorio, che e' il riferimento con cui lo chiamano tutti.
|
||||||
|
*
|
||||||
|
* `geojson` e' la bozza: l'editor la salva da solo a ogni modifica, e non
|
||||||
|
* tocca il PDF del territorio. Il PDF cambia solo con la pubblicazione, che
|
||||||
|
* ricorda cosa e' stato pubblicato (`geojson_pubblicato`,
|
||||||
|
* `impronta_pubblicata`, `pubblicata_at`).
|
||||||
*/
|
*/
|
||||||
class TerritorioMappa extends Model
|
class TerritorioMappa extends Model
|
||||||
{
|
{
|
||||||
|
public const STATO_BOZZA = 'bozza';
|
||||||
|
|
||||||
|
public const STATO_PUBBLICATA = 'pubblicata';
|
||||||
|
|
||||||
|
public const STATO_MODIFICATA = 'modificata';
|
||||||
|
|
||||||
protected $table = 'territorio_mappe';
|
protected $table = 'territorio_mappe';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'territorio_id',
|
'territorio_id',
|
||||||
'nome',
|
'nome',
|
||||||
'geojson',
|
'geojson',
|
||||||
|
'geojson_pubblicato',
|
||||||
|
'impronta_pubblicata',
|
||||||
|
'pubblicata_at',
|
||||||
'image_path',
|
'image_path',
|
||||||
'rendered_at',
|
'rendered_at',
|
||||||
];
|
];
|
||||||
@@ -28,12 +42,19 @@ class TerritorioMappa extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'rendered_at' => 'datetime',
|
'rendered_at' => 'datetime',
|
||||||
|
'pubblicata_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* withTrashed: un territorio nel cestino resta collegato. Senza, la
|
||||||
|
* relazione tornava null, l'editor mostrava la piantina come scollegata e
|
||||||
|
* il primo salvataggio cancellava il collegamento — che al ripristino del
|
||||||
|
* territorio non c'era piu'.
|
||||||
|
*/
|
||||||
public function territorio()
|
public function territorio()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Territorio::class, 'territorio_id');
|
return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,15 +63,22 @@ class TerritorioMappa extends Model
|
|||||||
*/
|
*/
|
||||||
public function geometria(): ?array
|
public function geometria(): ?array
|
||||||
{
|
{
|
||||||
$decoded = json_decode($this->geojson, true);
|
$decoded = json_decode((string) $this->geojson, true);
|
||||||
|
|
||||||
return is_array($decoded) ? $decoded : null;
|
return is_array($decoded) ? $decoded : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Se è collegata a un territorio, il PDF si può generare. */
|
/** Collegata a un territorio vivo: è la condizione per pubblicare. */
|
||||||
public function collegata(): bool
|
public function collegata(): bool
|
||||||
{
|
{
|
||||||
return $this->territorio_id !== null && $this->territorio !== null;
|
return $this->territorio_id !== null
|
||||||
|
&& $this->territorio !== null
|
||||||
|
&& ! $this->territorio->trashed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function territorioNelCestino(): bool
|
||||||
|
{
|
||||||
|
return $this->territorio !== null && $this->territorio->trashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +88,7 @@ class TerritorioMappa extends Model
|
|||||||
*/
|
*/
|
||||||
public function etichetta(): string
|
public function etichetta(): string
|
||||||
{
|
{
|
||||||
if ($this->collegata()) {
|
if ($this->territorio !== null) {
|
||||||
return 'Terr. ' . $this->territorio->numero;
|
return 'Terr. ' . $this->territorio->numero;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,4 +96,87 @@ class TerritorioMappa extends Model
|
|||||||
|
|
||||||
return $nome !== '' ? $nome : 'Piantina #' . $this->id;
|
return $nome !== '' ? $nome : 'Piantina #' . $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Bozza e pubblicazione ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Impronta di tutto cio' che finisce nel PDF: confine, territorio e i
|
||||||
|
* suoi dati stampati sulla cartolina (numero, zona come «Località»,
|
||||||
|
* confini). Se cambia uno qualunque di questi, il PDF in vigore non
|
||||||
|
* corrisponde piu' e la piantina ha modifiche da pubblicare — anche quando
|
||||||
|
* il disegno e' rimasto lo stesso.
|
||||||
|
*/
|
||||||
|
public function impronta(): string
|
||||||
|
{
|
||||||
|
$territorio = $this->territorio;
|
||||||
|
|
||||||
|
return hash('sha256', json_encode([
|
||||||
|
$this->geometria(),
|
||||||
|
$this->territorio_id,
|
||||||
|
$territorio ? trim((string) $territorio->numero) : null,
|
||||||
|
$territorio?->zona?->nome,
|
||||||
|
$territorio ? trim((string) $territorio->confini) : null,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stato(): string
|
||||||
|
{
|
||||||
|
if ($this->pubblicata_at === null) {
|
||||||
|
return self::STATO_BOZZA;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->impronta() === $this->impronta_pubblicata && $this->pdfInUsoDallaPiantina()
|
||||||
|
? self::STATO_PUBBLICATA
|
||||||
|
: self::STATO_MODIFICATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il PDF in vigore del territorio è uno composto da una piantina. Se nel
|
||||||
|
* frattempo è stato sostituito per altre vie (ripristino di una versione,
|
||||||
|
* caricamento a mano, import), quello che arriva ai proclamatori non è
|
||||||
|
* più questo disegno, anche se l'impronta torna.
|
||||||
|
*/
|
||||||
|
public function pdfInUsoDallaPiantina(): bool
|
||||||
|
{
|
||||||
|
return $this->territorio_id !== null
|
||||||
|
&& str_starts_with((string) $this->territorio?->pdf_path, 'territori-pdf/piantina-' . $this->territorio_id . '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Il testo dell'etichetta di stato, uguale in ogni pagina. */
|
||||||
|
public function statoEtichetta(): string
|
||||||
|
{
|
||||||
|
return match ($this->stato()) {
|
||||||
|
self::STATO_BOZZA => 'Bozza',
|
||||||
|
self::STATO_PUBBLICATA => 'Pubblicata',
|
||||||
|
self::STATO_MODIFICATA => 'Modifiche da pubblicare',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perché non si può pubblicare, o null se si può. Il testo va a schermo
|
||||||
|
* accanto al pulsante: dice cosa fare, non solo che manca qualcosa.
|
||||||
|
*/
|
||||||
|
public function motivoNonPubblicabile(): ?string
|
||||||
|
{
|
||||||
|
if ($this->geometria() === null) {
|
||||||
|
return 'Disegna il confine della piantina.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->territorioNelCestino()) {
|
||||||
|
return 'Il territorio collegato è nel cestino: ripristinalo o collega un altro territorio.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->collegata()) {
|
||||||
|
return 'Collega la piantina a un territorio: il PDF è quello del territorio.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Il confine pubblicato è diverso da quello in bozza: si può scartare. */
|
||||||
|
public function bozzaScartabile(): bool
|
||||||
|
{
|
||||||
|
return $this->geojson_pubblicato !== null
|
||||||
|
&& json_decode($this->geojson_pubblicato, true) != $this->geometria();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,15 +36,24 @@ class PiantinaPdfService
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Il PDF completo, come stringa binaria. */
|
/**
|
||||||
public function genera(Territorio $territorio, TerritorioMappa $mappa): string
|
* Il PDF completo, come stringa binaria.
|
||||||
|
*
|
||||||
|
* $immagine è il percorso assoluto di un'immagine diversa da quella
|
||||||
|
* salvata nella piantina: l'anteprima della pubblicazione compone il PDF
|
||||||
|
* con l'immagine appena generata, senza averla ancora resa ufficiale.
|
||||||
|
*/
|
||||||
|
public function genera(Territorio $territorio, TerritorioMappa $mappa, ?string $immagine = null): string
|
||||||
{
|
{
|
||||||
|
if ($immagine === null) {
|
||||||
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
|
if (! $mappa->image_path || ! Storage::disk('public')->exists($mappa->image_path)) {
|
||||||
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
|
throw new RuntimeException('Prima di generare il PDF va generata l\'immagine della piantina.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$immagine = Storage::disk('public')->path($mappa->image_path);
|
$immagine = Storage::disk('public')->path($mappa->image_path);
|
||||||
$dimensioni = getimagesize($immagine);
|
}
|
||||||
|
|
||||||
|
$dimensioni = @getimagesize($immagine);
|
||||||
if ($dimensioni === false) {
|
if ($dimensioni === false) {
|
||||||
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
|
throw new RuntimeException('L\'immagine della piantina non è leggibile: rigenerala.');
|
||||||
}
|
}
|
||||||
@@ -92,7 +101,7 @@ class PiantinaPdfService
|
|||||||
($h - $imgH) / 2,
|
($h - $imgH) / 2,
|
||||||
$imgW,
|
$imgW,
|
||||||
$imgH,
|
$imgH,
|
||||||
$this->tipoImmagine($mappa->image_path)
|
$this->tipoImmagine($immagine)
|
||||||
);
|
);
|
||||||
|
|
||||||
return $pdf->Output('S');
|
return $pdf->Output('S');
|
||||||
@@ -179,13 +188,26 @@ class PiantinaPdfService
|
|||||||
*/
|
*/
|
||||||
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void
|
public function sostituisci(Territorio $territorio, TerritorioMappa $mappa, string $origine = 'piantine'): void
|
||||||
{
|
{
|
||||||
$binario = $this->genera($territorio, $mappa);
|
$this->sostituisciConPdf($territorio, $this->genera($territorio, $mappa), $origine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rende ufficiale un PDF già composto, archiviando il precedente.
|
||||||
|
*
|
||||||
|
* Prima si scrive il nuovo file, poi si archivia il vecchio: nell'ordine
|
||||||
|
* inverso un errore di scrittura (disco pieno) lasciava pdf_path puntato
|
||||||
|
* a un file già spostato nello storico, e i link delle assegnazioni in 404.
|
||||||
|
*/
|
||||||
|
public function sostituisciConPdf(Territorio $territorio, string $binario, string $origine = 'piantine'): void
|
||||||
|
{
|
||||||
$disk = Storage::disk('public');
|
$disk = Storage::disk('public');
|
||||||
|
|
||||||
$this->archiviaCorrente($territorio, $origine);
|
|
||||||
|
|
||||||
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
|
$nuovoPath = 'territori-pdf/piantina-' . $territorio->id . '-' . now()->format('Ymd-His') . '.pdf';
|
||||||
$disk->put($nuovoPath, $binario);
|
if (! $disk->put($nuovoPath, $binario)) {
|
||||||
|
throw new RuntimeException('Non è stato possibile scrivere il nuovo PDF sul disco: il PDF in vigore non è stato toccato.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->archiviaCorrente($territorio, $origine);
|
||||||
|
|
||||||
$territorio->update([
|
$territorio->update([
|
||||||
'pdf_path' => $nuovoPath,
|
'pdf_path' => $nuovoPath,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services\Piantine;
|
namespace App\Services\Piantine;
|
||||||
|
|
||||||
use App\Models\TerritorioMappa;
|
use App\Models\TerritorioMappa;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -14,6 +15,11 @@ use RuntimeException;
|
|||||||
*/
|
*/
|
||||||
class PiantinaRenderer
|
class PiantinaRenderer
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Rendering e archiviazione come immagine della piantina.
|
||||||
|
*
|
||||||
|
* @return string il percorso sul disco pubblico
|
||||||
|
*/
|
||||||
public function render(TerritorioMappa $mappa): string
|
public function render(TerritorioMappa $mappa): string
|
||||||
{
|
{
|
||||||
$geometry = $mappa->geometria();
|
$geometry = $mappa->geometria();
|
||||||
@@ -22,8 +28,22 @@ class PiantinaRenderer
|
|||||||
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
|
throw new RuntimeException('La geometria salvata per questa piantina non è leggibile: ridisegna il poligono.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[$immagine, $estensione] = $this->immagine($geometry);
|
||||||
|
|
||||||
|
return $this->archivia($mappa, $immagine, $estensione);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solo il rendering, senza toccare niente: serve all'anteprima della
|
||||||
|
* pubblicazione, che non deve cambiare la piantina finché non si conferma.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string} contenuto e estensione ('jpg'|'png')
|
||||||
|
*/
|
||||||
|
public function immagine(array $geometry): array
|
||||||
|
{
|
||||||
$formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg';
|
$formato = config('piantine.formato') === 'png' ? 'png' : 'jpeg';
|
||||||
|
|
||||||
|
try {
|
||||||
$response = Http::timeout((int) config('piantine.renderer_timeout'))
|
$response = Http::timeout((int) config('piantine.renderer_timeout'))
|
||||||
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
|
->post(rtrim((string) config('piantine.renderer_url'), '/') . '/render', [
|
||||||
'geometry' => $geometry,
|
'geometry' => $geometry,
|
||||||
@@ -35,6 +55,11 @@ class PiantinaRenderer
|
|||||||
'format' => $formato,
|
'format' => $formato,
|
||||||
'quality' => (int) config('piantine.qualita'),
|
'quality' => (int) config('piantine.qualita'),
|
||||||
]);
|
]);
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
// Container fermo, nome non risolto o tempo scaduto: il messaggio
|
||||||
|
// di cURL in inglese non dice niente a chi sta pubblicando.
|
||||||
|
throw new RuntimeException('Il servizio che disegna le mappe non risponde (container map-renderer). Riprova fra qualche minuto; se continua, va controllato il server.', 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status());
|
$dettaglio = $response->json('error') ?? ('HTTP ' . $response->status());
|
||||||
@@ -47,15 +72,28 @@ class PiantinaRenderer
|
|||||||
throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.');
|
throw new RuntimeException('Il renderer delle mappe non ha restituito un\'immagine valida.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Il vecchio file puo' avere un'estensione diversa da quella nuova (si
|
return [$immagine, $formato === 'png' ? 'png' : 'jpg'];
|
||||||
// e' passati da PNG a JPEG): senza questa rimozione resterebbe sul
|
|
||||||
// volume per sempre, senza che niente lo referenzi piu'.
|
|
||||||
if ($mappa->image_path && $mappa->image_path !== $this->percorso($mappa, $formato)) {
|
|
||||||
Storage::disk('public')->delete($mappa->image_path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $this->percorso($mappa, $formato);
|
/**
|
||||||
Storage::disk('public')->put($path, $immagine);
|
* Salva l'immagine come quella della piantina, rimuovendo la precedente.
|
||||||
|
*
|
||||||
|
* Il nome cambia a ogni rendering: nginx e Cloudflare tengono in cache i
|
||||||
|
* file di /storage per giorni, e con un nome fisso il link mostrava
|
||||||
|
* l'immagine vecchia anche dopo averla rigenerata.
|
||||||
|
*/
|
||||||
|
public function archivia(TerritorioMappa $mappa, string $immagine, string $estensione): string
|
||||||
|
{
|
||||||
|
$disco = Storage::disk('public');
|
||||||
|
$path = 'territori-mappe/piantina-' . $mappa->id . '-' . now()->format('YmdHis') . '.' . $estensione;
|
||||||
|
|
||||||
|
if (! $disco->put($path, $immagine)) {
|
||||||
|
throw new RuntimeException('Non è stato possibile salvare l\'immagine della piantina sul disco.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mappa->image_path && $mappa->image_path !== $path) {
|
||||||
|
$disco->delete($mappa->image_path);
|
||||||
|
}
|
||||||
|
|
||||||
$mappa->update([
|
$mappa->update([
|
||||||
'image_path' => $path,
|
'image_path' => $path,
|
||||||
@@ -65,11 +103,6 @@ class PiantinaRenderer
|
|||||||
return $path;
|
return $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function percorso(TerritorioMappa $mappa, string $formato): string
|
|
||||||
{
|
|
||||||
return 'territori-mappe/piantina-' . $mappa->id . '.' . ($formato === 'png' ? 'png' : 'jpg');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */
|
/** I primi byte bastano a distinguere un'immagine da una pagina d'errore. */
|
||||||
private function immagineValida(string $dati, string $formato): bool
|
private function immagineValida(string $dati, string $formato): bool
|
||||||
{
|
{
|
||||||
|
|||||||
234
app/Services/Piantine/PubblicazionePiantina.php
Normal file
234
app/Services/Piantine/PubblicazionePiantina.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -557,6 +557,64 @@ html { scroll-behavior: smooth; }
|
|||||||
background: var(--tm-superficie-alt);
|
background: var(--tm-superficie-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Ricerca e filtri sopra un elenco ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.tm-filtri {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.tm-filtri-cerca {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid var(--tm-bordo-forte);
|
||||||
|
border-radius: var(--tm-raggio-interno);
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--tm-testo);
|
||||||
|
}
|
||||||
|
.tm-filtri-cerca:focus {
|
||||||
|
outline: 2px solid var(--tm-accento);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.tm-filtri-schede {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.tm-filtri-scheda {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--tm-testo-medio);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tm-filtri-scheda:hover { border-color: var(--tm-bordo-forte); }
|
||||||
|
.tm-filtri-scheda:focus-visible {
|
||||||
|
outline: 2px solid var(--tm-accento);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.tm-filtri-scheda-attiva {
|
||||||
|
border-color: var(--tm-accento);
|
||||||
|
background: var(--tm-accento-tenue);
|
||||||
|
color: var(--tm-accento-scuro);
|
||||||
|
}
|
||||||
|
.tm-filtri-scheda .tm-conteggio {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Etichette di stato ──────────────────────────────────────────────────── */
|
/* ─── Etichette di stato ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.tm-tag {
|
.tm-tag {
|
||||||
@@ -989,9 +1047,9 @@ html { scroll-behavior: smooth; }
|
|||||||
.tm-editor-barra {
|
.tm-editor-barra {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 9px 14px;
|
padding: 8px 14px;
|
||||||
background: var(--tm-superficie);
|
background: var(--tm-superficie);
|
||||||
border-bottom: 1px solid var(--tm-bordo);
|
border-bottom: 1px solid var(--tm-bordo);
|
||||||
box-shadow: var(--tm-ombra-1);
|
box-shadow: var(--tm-ombra-1);
|
||||||
@@ -1026,6 +1084,8 @@ html { scroll-behavior: smooth; }
|
|||||||
gap: 9px;
|
gap: 9px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tm-editor-titolo h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1034,6 +1094,11 @@ html { scroll-behavior: smooth; }
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.tm-editor-salvato {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--tm-testo-tenue);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.tm-editor-azioni {
|
.tm-editor-azioni {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1042,10 +1107,26 @@ html { scroll-behavior: smooth; }
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tm-editor-mappa {
|
/* Le tre zone: elenco, mappa, pannello. Elenco e pannello galleggiano sopra
|
||||||
|
la mappa invece di restringerla: se togliessero spazio, aprirli per cambiare
|
||||||
|
un nome sposterebbe il disegno sotto il puntatore. */
|
||||||
|
.tm-editor-corpo {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
/* Lo spazio che elenco e pannello aperti tolgono ai lati: barra degli
|
||||||
|
strumenti e controlli della mappa stanno nella parte libera, invece di
|
||||||
|
finire sotto (o sopra) un pannello. */
|
||||||
|
--libero-sx: 12px;
|
||||||
|
--libero-dx: 12px;
|
||||||
|
}
|
||||||
|
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(270px, 100vw - 24px) + 24px); }
|
||||||
|
.tm-editor-corpo.con-pannello { --libero-dx: calc(min(370px, 100vw - 24px) + 24px); }
|
||||||
|
.tm-mappa .leaflet-left { left: calc(var(--libero-sx) - 12px); }
|
||||||
|
.tm-mappa .leaflet-right { right: calc(var(--libero-dx) - 12px); }
|
||||||
|
.tm-editor-mappa {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
}
|
}
|
||||||
.tm-mappa {
|
.tm-mappa {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1056,7 +1137,7 @@ html { scroll-behavior: smooth; }
|
|||||||
.tm-editor-nota {
|
.tm-editor-nota {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 14px;
|
bottom: 18px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
max-width: min(92vw, 460px);
|
max-width: min(92vw, 460px);
|
||||||
@@ -1072,16 +1153,132 @@ html { scroll-behavior: smooth; }
|
|||||||
}
|
}
|
||||||
.tm-editor-nota a { color: inherit; font-weight: 600; }
|
.tm-editor-nota a { color: inherit; font-weight: 600; }
|
||||||
|
|
||||||
/* Il pannello galleggia sopra la mappa invece di restringerla: se togliesse
|
/* ── Barra degli strumenti di disegno ── */
|
||||||
spazio, aprirlo per cambiare un nome sposterebbe il disegno sotto il
|
.tm-strumenti {
|
||||||
puntatore. */
|
|
||||||
.tm-editor-pannello {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
left: var(--libero-sx);
|
||||||
|
right: var(--libero-dx);
|
||||||
|
z-index: 650;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: fit-content;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
box-shadow: var(--tm-ombra-3);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.tm-strumenti:empty { display: none; }
|
||||||
|
.tm-strumento {
|
||||||
|
flex: none;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tm-testo-medio);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .15s ease, color .15s ease;
|
||||||
|
}
|
||||||
|
.tm-strumento:hover { background: var(--tm-superficie-alt); color: var(--tm-testo); }
|
||||||
|
.tm-strumento:focus-visible { outline: 2px solid var(--tm-accento); outline-offset: 1px; }
|
||||||
|
.tm-strumento-principale { background: var(--tm-accento); color: #fff; }
|
||||||
|
.tm-strumento-principale:hover { background: var(--tm-accento-scuro); color: #fff; }
|
||||||
|
.tm-strumenti-modo {
|
||||||
|
flex: none;
|
||||||
|
padding: 0 8px 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tm-pericolo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Elenco ── */
|
||||||
|
.tm-editor-elenco {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
z-index: 600;
|
z-index: 600;
|
||||||
width: min(360px, calc(100vw - 24px));
|
width: min(270px, calc(100vw - 24px));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: var(--tm-raggio);
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
box-shadow: var(--tm-ombra-3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-cerca {
|
||||||
|
flex: none;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--tm-bordo);
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-cerca input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--tm-bordo-forte);
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-righe {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px;
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-voce {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--tm-testo);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tm-editor-elenco-voce:hover { background: var(--tm-superficie-alt); }
|
||||||
|
.tm-editor-elenco-voce.is-selezionata { background: var(--tm-accento-tenue); color: var(--tm-accento-scuro); font-weight: 600; }
|
||||||
|
.tm-editor-elenco-stato {
|
||||||
|
flex: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tm-testo-tenue);
|
||||||
|
}
|
||||||
|
.tm-stato-bozza, .tm-stato-modificata { color: var(--tm-attesa); }
|
||||||
|
.tm-stato-pubblicata { color: var(--tm-ok); }
|
||||||
|
.tm-editor-elenco-vuoto {
|
||||||
|
padding: 14px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--tm-testo-tenue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pannello della piantina ── */
|
||||||
|
.tm-editor-pannello {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 600;
|
||||||
|
width: min(370px, calc(100vw - 24px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border: 1px solid var(--tm-bordo);
|
border: 1px solid var(--tm-bordo);
|
||||||
@@ -1090,7 +1287,6 @@ html { scroll-behavior: smooth; }
|
|||||||
box-shadow: var(--tm-ombra-3);
|
box-shadow: var(--tm-ombra-3);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.tm-editor-pannello[hidden] { display: none; }
|
|
||||||
|
|
||||||
.tm-editor-pannello-testata {
|
.tm-editor-pannello-testata {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1098,7 +1294,7 @@ html { scroll-behavior: smooth; }
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 13px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--tm-bordo);
|
border-bottom: 1px solid var(--tm-bordo);
|
||||||
}
|
}
|
||||||
.tm-editor-pannello-testata h2 {
|
.tm-editor-pannello-testata h2 {
|
||||||
@@ -1130,7 +1326,7 @@ html { scroll-behavior: smooth; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid var(--tm-bordo);
|
border-top: 1px solid var(--tm-bordo);
|
||||||
@@ -1141,14 +1337,164 @@ html { scroll-behavior: smooth; }
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: var(--tm-testo-tenue);
|
color: var(--tm-testo-tenue);
|
||||||
}
|
}
|
||||||
|
.tm-stato-piantina p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--tm-testo-medio); }
|
||||||
|
.tm-azioni-riga {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.tm-azioni-riga:empty { display: none; }
|
||||||
|
.tm-passi {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--tm-testo-medio);
|
||||||
|
}
|
||||||
|
.tm-collegamento-piccolo {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 0;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-solo-stretto { display: none; }
|
||||||
|
|
||||||
|
/* Tablet: l'elenco si apre a richiesta e copre la mappa per il tempo della
|
||||||
|
scelta. */
|
||||||
|
@media (max-width: 1099px) {
|
||||||
|
.tm-editor-elenco { width: min(300px, calc(100vw - 24px)); }
|
||||||
|
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(300px, 100vw - 24px) + 24px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Telefono e tablet in verticale: il pannello sale dal basso. */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.tm-editor-barra { flex-wrap: wrap; }
|
.tm-editor-barra { flex-wrap: wrap; }
|
||||||
.tm-editor-titolo { order: 3; flex-basis: 100%; }
|
.tm-editor-titolo { order: 3; flex-basis: 100%; }
|
||||||
.tm-editor-pannello { top: auto; height: min(70dvh, 460px); }
|
.tm-editor-azioni .tm-btn span { display: none; }
|
||||||
|
.tm-solo-stretto { display: inline-flex; }
|
||||||
|
.tm-editor-pannello {
|
||||||
|
top: auto;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: auto;
|
||||||
|
height: min(62dvh, 520px);
|
||||||
|
border-radius: var(--tm-raggio) var(--tm-raggio) 0 0;
|
||||||
|
}
|
||||||
|
/* Elenco e pannello qui coprono la mappa per intero o dal basso: la
|
||||||
|
barra resta in alto, sopra il foglio del pannello. */
|
||||||
|
.tm-editor-corpo,
|
||||||
|
.tm-editor-corpo.con-elenco,
|
||||||
|
.tm-editor-corpo.con-pannello { --libero-sx: 12px; --libero-dx: 12px; }
|
||||||
|
/* A destra dei pulsanti dello zoom, e su più righe invece di uscire
|
||||||
|
dallo schermo. */
|
||||||
|
.tm-strumenti { left: 58px; flex-wrap: wrap; }
|
||||||
|
.tm-mappa .leaflet-left { left: 0; }
|
||||||
|
/* Il foglio copre crediti e scala della mappa finché è aperto, invece di
|
||||||
|
averli stampati sopra i campi. */
|
||||||
|
.tm-editor-pannello { z-index: 1001; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Avvisi galleggianti (solo telaio a schermo intero) ───────────────────── */
|
/* Dita, non puntatore: vertici e maniglie di leaflet-draw più grandi, così
|
||||||
|
si prendono senza zoomare al massimo. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.leaflet-editing-icon,
|
||||||
|
.leaflet-touch-icon {
|
||||||
|
width: 22px !important;
|
||||||
|
height: 22px !important;
|
||||||
|
margin-left: -11px !important;
|
||||||
|
margin-top: -11px !important;
|
||||||
|
}
|
||||||
|
.tm-strumento { min-height: 42px; padding: 8px 14px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Finestra di pubblicazione ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tm-modale-sfondo {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(28, 26, 23, .45);
|
||||||
|
}
|
||||||
|
.tm-modale {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
max-height: calc(100dvh - 32px);
|
||||||
|
border-radius: var(--tm-raggio);
|
||||||
|
background: var(--tm-superficie);
|
||||||
|
box-shadow: var(--tm-ombra-3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tm-modale-larga { max-width: 820px; }
|
||||||
|
.tm-modale-testata {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--tm-bordo);
|
||||||
|
}
|
||||||
|
.tm-modale-testata h2 { margin: 0; font-size: 15px; font-weight: 600; }
|
||||||
|
.tm-modale-corpo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.tm-modale-piede {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-top: 1px solid var(--tm-bordo);
|
||||||
|
}
|
||||||
|
.tm-anteprima-pdf {
|
||||||
|
width: 100%;
|
||||||
|
height: min(62dvh, 640px);
|
||||||
|
border: 1px solid var(--tm-bordo);
|
||||||
|
border-radius: var(--tm-raggio-interno);
|
||||||
|
background: var(--tm-superficie-alt);
|
||||||
|
}
|
||||||
|
.tm-attesa {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 4px;
|
||||||
|
}
|
||||||
|
.tm-attesa-titolo { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
|
||||||
|
.tm-attesa-giro {
|
||||||
|
flex: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 3px solid var(--tm-accento-tenue);
|
||||||
|
border-top-color: var(--tm-accento);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: tm-giro .9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes tm-giro { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .tm-attesa-giro { animation-duration: 3s; } }
|
||||||
|
.tm-nota-errore {
|
||||||
|
border-color: #edc9c6;
|
||||||
|
background: var(--tm-pericolo-tenue);
|
||||||
|
color: var(--tm-pericolo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Avvisi galleggianti (componente <x-avvisi>) ───────────────────── */
|
||||||
|
|
||||||
.tm-avvisi {
|
.tm-avvisi {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
// Editor delle piantine (pagina /piantine/editor, solo amministratore).
|
// Editor delle piantine (pagina /piantine/editor, solo amministratore).
|
||||||
//
|
//
|
||||||
// Mappa Leaflet su tessere OpenStreetMap: mostra l'area del KML, le piantine
|
// Mappa Leaflet su tessere OpenStreetMap con l'area del KML e le piantine, più
|
||||||
// già salvate e permette di disegnarne di nuove con leaflet-draw. Parla con il
|
// l'elenco con ricerca a sinistra. Disegno e modifica dei vertici usano i
|
||||||
// componente Livewire attraverso eventi:
|
// gestori di leaflet-draw, comandati da una barra di strumenti nostra: pulsanti
|
||||||
// JS → Livewire: 'poligono-disegnato', 'piantina-selezionata-mappa'
|
// grandi e con il testo, che si usano anche al tablet.
|
||||||
// Livewire → JS: 'piantine-dati-aggiornati'
|
//
|
||||||
|
// Modalità, sempre esplicite:
|
||||||
|
// vista si guarda, si sceglie una piantina (clic sulla mappa o in elenco)
|
||||||
|
// disegno si traccia una piantina NUOVA
|
||||||
|
// ridisegno si traccia da capo il confine di quella aperta (con conferma)
|
||||||
|
// vertici si trascinano i vertici di quella aperta
|
||||||
|
//
|
||||||
|
// Con il componente Livewire si parla per eventi:
|
||||||
|
// JS → Livewire: 'piantina-selezionata-mappa', 'geometria-modificata'
|
||||||
|
// Livewire → JS: 'piantina-aggiornata', 'piantina-rimossa',
|
||||||
|
// 'piantina-selezionata', 'editor-disegna', 'editor-indirizzo'
|
||||||
|
// I dati di tutte le piantine si chiedono una volta sola ($wire.datiMappa());
|
||||||
|
// dopo arriva solo la piantina cambiata.
|
||||||
|
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
@@ -12,12 +24,13 @@ import 'leaflet-draw';
|
|||||||
import 'leaflet-draw/dist/leaflet.draw.css';
|
import 'leaflet-draw/dist/leaflet.draw.css';
|
||||||
|
|
||||||
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
|
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
|
||||||
// Una piantina collegata a un territorio è "a posto"; una scollegata è un
|
const STILI = {
|
||||||
// lavoro ancora aperto, e si vede senza dover aprire il pannello.
|
pubblicata: { color: '#2f6f4e', weight: 2, fillColor: '#2f6f4e', fillOpacity: 0.08 },
|
||||||
const STILE_COLLEGATA = { color: '#8a8578', weight: 2, fillColor: '#8a8578', fillOpacity: 0.1 };
|
modificata: { color: '#a06a22', weight: 2, fillColor: '#c98b3a', fillOpacity: 0.1 },
|
||||||
const STILE_SCOLLEGATA = { color: '#b4762a', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 };
|
bozza: { color: '#a06a22', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 },
|
||||||
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, fillColor: '#605cb2', fillOpacity: 0.18 };
|
};
|
||||||
const STILE_IN_ATTESA = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
|
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, dashArray: null, fillColor: '#605cb2', fillOpacity: 0.18 };
|
||||||
|
const STILE_DISEGNO = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
|
||||||
|
|
||||||
function initPiantine() {
|
function initPiantine() {
|
||||||
const contenitore = document.getElementById('piantine-map');
|
const contenitore = document.getElementById('piantine-map');
|
||||||
@@ -26,143 +39,294 @@ function initPiantine() {
|
|||||||
}
|
}
|
||||||
contenitore.dataset.inizializzato = '1';
|
contenitore.dataset.inizializzato = '1';
|
||||||
|
|
||||||
let dati = { area: null, piantine: [], selezionata: null };
|
const radice = contenitore.closest('[wire\\:id]');
|
||||||
try {
|
const componente = () => window.Livewire.find(radice.getAttribute('wire:id'));
|
||||||
dati = JSON.parse(document.getElementById('piantine-dati').textContent);
|
|
||||||
} catch {
|
|
||||||
// si parte comunque con la mappa vuota
|
|
||||||
}
|
|
||||||
|
|
||||||
// Etichette in italiano per la barra di disegno.
|
|
||||||
L.drawLocal.draw.toolbar.buttons.polygon = 'Disegna il confine di una piantina';
|
|
||||||
L.drawLocal.draw.toolbar.actions = { title: 'Annulla il disegno', text: 'Annulla' };
|
|
||||||
L.drawLocal.draw.toolbar.finish = { title: 'Concludi il disegno', text: 'Concludi' };
|
|
||||||
L.drawLocal.draw.toolbar.undo = { title: 'Rimuovi l\'ultimo punto', text: 'Ultimo punto' };
|
|
||||||
L.drawLocal.draw.handlers.polygon.tooltip = {
|
L.drawLocal.draw.handlers.polygon.tooltip = {
|
||||||
start: 'Clicca sulla mappa per iniziare il confine.',
|
start: 'Tocca o clicca sulla mappa per il primo punto del confine.',
|
||||||
cont: 'Continua a cliccare lungo il confine.',
|
cont: 'Continua lungo il confine.',
|
||||||
end: 'Clicca sul primo punto per chiudere il poligono.',
|
end: 'Chiudi sul primo punto, o premi «Concludi».',
|
||||||
};
|
};
|
||||||
|
L.drawLocal.edit.handlers.edit.tooltip = { text: 'Trascina i vertici per correggere il confine.', subtext: '' };
|
||||||
|
|
||||||
const mappa = L.map(contenitore, { zoomControl: true });
|
const mappa = L.map(contenitore, { zoomControl: true });
|
||||||
|
|
||||||
L.control.scale({ imperial: false }).addTo(mappa);
|
L.control.scale({ imperial: false }).addTo(mappa);
|
||||||
|
|
||||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
}).addTo(mappa);
|
}).addTo(mappa);
|
||||||
|
|
||||||
|
const gruppo = L.featureGroup().addTo(mappa);
|
||||||
|
const layers = new Map(); // mappaId → { dati, layer }
|
||||||
let layerArea = null;
|
let layerArea = null;
|
||||||
const layerSalvate = L.featureGroup().addTo(mappa);
|
let selezionata = null;
|
||||||
let layerInAttesa = null;
|
let modo = 'vista';
|
||||||
|
let gestoreDisegno = null;
|
||||||
|
let poligonoInModifica = null;
|
||||||
|
|
||||||
mappa.addControl(new L.Control.Draw({
|
// ── Disegno delle piantine ──────────────────────────────────────────────
|
||||||
draw: {
|
|
||||||
polygon: { allowIntersection: false, showArea: false, shapeOptions: STILE_IN_ATTESA },
|
function stileDi(dati) {
|
||||||
polyline: false,
|
if (dati.mappaId === selezionata) return STILE_SELEZIONATA;
|
||||||
rectangle: false,
|
return { dashArray: null, ...(STILI[dati.stato] ?? STILI.bozza) };
|
||||||
circle: false,
|
}
|
||||||
circlemarker: false,
|
|
||||||
marker: false,
|
function disegna(dati) {
|
||||||
},
|
rimuovi(dati.mappaId, false);
|
||||||
edit: false,
|
if (!dati.geometry) return;
|
||||||
|
|
||||||
|
let layer;
|
||||||
|
try {
|
||||||
|
layer = L.geoJSON({ type: 'Feature', properties: {}, geometry: dati.geometry }, { style: stileDi(dati) });
|
||||||
|
} catch {
|
||||||
|
return; // una geometria rovinata non deve fermare le altre
|
||||||
|
}
|
||||||
|
layer.bindTooltip(`${dati.etichetta} · ${dati.statoEtichetta}`, { sticky: true });
|
||||||
|
layer.on('click', () => {
|
||||||
|
if (modo !== 'vista') return;
|
||||||
|
componente().dispatch('piantina-selezionata-mappa', { mappaId: dati.mappaId });
|
||||||
|
});
|
||||||
|
gruppo.addLayer(layer);
|
||||||
|
layers.set(dati.mappaId, { dati, layer });
|
||||||
|
}
|
||||||
|
|
||||||
|
function rimuovi(mappaId, aggiornaElenco = true) {
|
||||||
|
const voce = layers.get(mappaId);
|
||||||
|
if (voce) {
|
||||||
|
gruppo.removeLayer(voce.layer);
|
||||||
|
layers.delete(mappaId);
|
||||||
|
}
|
||||||
|
if (aggiornaElenco) disegnaElenco();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ristila() {
|
||||||
|
for (const { dati, layer } of layers.values()) {
|
||||||
|
layer.setStyle(stileDi(dati));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inquadra(mappaId) {
|
||||||
|
const voce = layers.get(mappaId);
|
||||||
|
if (voce) mappa.fitBounds(voce.layer.getBounds().pad(0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Elenco con ricerca ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const elenco = document.getElementById('piantine-elenco');
|
||||||
|
const cerca = document.getElementById('piantine-cerca');
|
||||||
|
|
||||||
|
function disegnaElenco() {
|
||||||
|
if (!elenco) return;
|
||||||
|
const filtro = (cerca?.value ?? '').trim().toLowerCase();
|
||||||
|
const voci = [...layers.values()].map((v) => v.dati)
|
||||||
|
.filter((d) => !filtro || d.etichetta.toLowerCase().includes(filtro))
|
||||||
|
.sort((a, b) => a.etichetta.localeCompare(b.etichetta, 'it', { numeric: true }));
|
||||||
|
|
||||||
|
elenco.replaceChildren(...voci.map((d) => {
|
||||||
|
const riga = document.createElement('li');
|
||||||
|
const pulsante = document.createElement('button');
|
||||||
|
pulsante.type = 'button';
|
||||||
|
pulsante.className = 'tm-editor-elenco-voce' + (d.mappaId === selezionata ? ' is-selezionata' : '');
|
||||||
|
pulsante.setAttribute('aria-current', d.mappaId === selezionata ? 'true' : 'false');
|
||||||
|
const nome = document.createElement('span');
|
||||||
|
nome.textContent = d.etichetta;
|
||||||
|
const stato = document.createElement('span');
|
||||||
|
stato.className = 'tm-editor-elenco-stato tm-stato-' + d.stato;
|
||||||
|
stato.textContent = d.statoEtichetta;
|
||||||
|
pulsante.append(nome, stato);
|
||||||
|
pulsante.addEventListener('click', () => {
|
||||||
|
if (modo !== 'vista') return;
|
||||||
|
componente().dispatch('piantina-selezionata-mappa', { mappaId: d.mappaId });
|
||||||
|
});
|
||||||
|
riga.append(pulsante);
|
||||||
|
return riga;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function stileDi(piantina, selezionata) {
|
if (!voci.length) {
|
||||||
if (selezionata) return STILE_SELEZIONATA;
|
const vuoto = document.createElement('li');
|
||||||
return piantina.collegata ? STILE_COLLEGATA : STILE_SCOLLEGATA;
|
vuoto.className = 'tm-editor-elenco-vuoto';
|
||||||
|
vuoto.textContent = filtro ? 'Nessuna piantina trovata.' : 'Nessuna piantina ancora.';
|
||||||
|
elenco.append(vuoto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ridisegna(nuoviDati, adattaVista) {
|
cerca?.addEventListener('input', disegnaElenco);
|
||||||
dati = nuoviDati;
|
|
||||||
|
|
||||||
if (layerArea) {
|
// ── Barra degli strumenti ───────────────────────────────────────────────
|
||||||
mappa.removeLayer(layerArea);
|
|
||||||
layerArea = null;
|
const barra = document.getElementById('piantine-strumenti');
|
||||||
|
|
||||||
|
function pulsante(testo, azione, principale = false) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'tm-strumento' + (principale ? ' tm-strumento-principale' : '');
|
||||||
|
b.textContent = testo;
|
||||||
|
b.addEventListener('click', azione);
|
||||||
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aggiornaBarra() {
|
||||||
|
if (!barra) return;
|
||||||
|
const comandi = [];
|
||||||
|
|
||||||
|
if (modo === 'vista') {
|
||||||
|
comandi.push(pulsante('Disegna nuova piantina', () => avviaDisegno(false), selezionata === null));
|
||||||
|
if (selezionata !== null && layers.has(selezionata)) {
|
||||||
|
comandi.push(pulsante('Modifica vertici', avviaVertici));
|
||||||
|
comandi.push(pulsante('Ridisegna confine', () => {
|
||||||
|
if (window.confirm('Ridisegnare da capo il confine di questa piantina? Quello attuale viene sostituito.')) {
|
||||||
|
avviaDisegno(true);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (modo === 'disegno' || modo === 'ridisegno') {
|
||||||
|
const titolo = document.createElement('span');
|
||||||
|
titolo.className = 'tm-strumenti-modo';
|
||||||
|
titolo.textContent = modo === 'disegno' ? 'Nuova piantina' : 'Nuovo confine';
|
||||||
|
comandi.push(titolo);
|
||||||
|
comandi.push(pulsante('Togli ultimo punto', () => gestoreDisegno?.deleteLastVertex()));
|
||||||
|
comandi.push(pulsante('Concludi', () => gestoreDisegno?.completeShape(), true));
|
||||||
|
comandi.push(pulsante('Annulla', fermaDisegno));
|
||||||
|
} else if (modo === 'vertici') {
|
||||||
|
const titolo = document.createElement('span');
|
||||||
|
titolo.className = 'tm-strumenti-modo';
|
||||||
|
titolo.textContent = 'Trascina i vertici';
|
||||||
|
comandi.push(titolo);
|
||||||
|
comandi.push(pulsante('Fine modifica', fermaVertici, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
barra.replaceChildren(...comandi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disegno ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let ridisegno = false;
|
||||||
|
|
||||||
|
function avviaDisegno(diQuellaAperta) {
|
||||||
|
fermaVertici(false);
|
||||||
|
ridisegno = diQuellaAperta;
|
||||||
|
modo = diQuellaAperta ? 'ridisegno' : 'disegno';
|
||||||
|
gestoreDisegno = new L.Draw.Polygon(mappa, { allowIntersection: false, showArea: false, shapeOptions: STILE_DISEGNO });
|
||||||
|
gestoreDisegno.enable();
|
||||||
|
aggiornaBarra();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fermaDisegno() {
|
||||||
|
gestoreDisegno?.disable();
|
||||||
|
gestoreDisegno = null;
|
||||||
|
modo = 'vista';
|
||||||
|
aggiornaBarra();
|
||||||
|
}
|
||||||
|
|
||||||
|
mappa.on(L.Draw.Event.CREATED, (evento) => {
|
||||||
|
const geometry = evento.layer.toGeoJSON().geometry;
|
||||||
|
const nuova = !ridisegno;
|
||||||
|
fermaDisegno();
|
||||||
|
componente().dispatch('geometria-modificata', { geometry, nuova });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Il gestore si spegne da solo anche con Esc: la barra deve seguirlo.
|
||||||
|
mappa.on(L.Draw.Event.DRAWSTOP, () => {
|
||||||
|
if (modo === 'disegno' || modo === 'ridisegno') {
|
||||||
|
gestoreDisegno = null;
|
||||||
|
modo = 'vista';
|
||||||
|
aggiornaBarra();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Modifica dei vertici ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function avviaVertici() {
|
||||||
|
const voce = layers.get(selezionata);
|
||||||
|
if (!voce) return;
|
||||||
|
poligonoInModifica = voce.layer.getLayers()[0];
|
||||||
|
if (!poligonoInModifica?.editing) return;
|
||||||
|
poligonoInModifica.editing.enable();
|
||||||
|
modo = 'vertici';
|
||||||
|
aggiornaBarra();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fermaVertici(salva = true) {
|
||||||
|
if (!poligonoInModifica) {
|
||||||
|
if (modo === 'vertici') { modo = 'vista'; aggiornaBarra(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const poligono = poligonoInModifica;
|
||||||
|
poligonoInModifica = null;
|
||||||
|
const modificato = poligono.edited;
|
||||||
|
poligono.editing.disable();
|
||||||
|
modo = 'vista';
|
||||||
|
aggiornaBarra();
|
||||||
|
|
||||||
|
if (salva && modificato) {
|
||||||
|
componente().dispatch('geometria-modificata', { geometry: poligono.toGeoJSON().geometry, nuova: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eventi dal server ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function seleziona(mappaId, centra) {
|
||||||
|
if (modo === 'vertici') fermaVertici();
|
||||||
|
selezionata = mappaId ?? null;
|
||||||
|
ristila();
|
||||||
|
disegnaElenco();
|
||||||
|
aggiornaBarra();
|
||||||
|
if (centra && selezionata !== null) inquadra(selezionata);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registra() {
|
||||||
|
window.Livewire.on('piantina-aggiornata', ({ piantina }) => {
|
||||||
|
disegna(piantina);
|
||||||
|
disegnaElenco();
|
||||||
|
aggiornaBarra();
|
||||||
|
});
|
||||||
|
window.Livewire.on('piantina-rimossa', ({ mappaId }) => {
|
||||||
|
if (selezionata === mappaId) selezionata = null;
|
||||||
|
rimuovi(mappaId);
|
||||||
|
aggiornaBarra();
|
||||||
|
});
|
||||||
|
window.Livewire.on('piantina-selezionata', ({ mappaId, inquadra: centra }) => seleziona(mappaId, centra));
|
||||||
|
window.Livewire.on('editor-disegna', () => avviaDisegno(false));
|
||||||
|
window.Livewire.on('editor-indirizzo', ({ url }) => {
|
||||||
|
if (window.location.href !== url) window.history.replaceState(window.history.state, '', url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carica() {
|
||||||
|
const dati = await componente().datiMappa();
|
||||||
|
|
||||||
if (dati.area) {
|
if (dati.area) {
|
||||||
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
|
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
|
||||||
}
|
}
|
||||||
|
selezionata = dati.selezionata ?? null;
|
||||||
|
dati.piantine.forEach(disegna);
|
||||||
|
disegnaElenco();
|
||||||
|
aggiornaBarra();
|
||||||
|
|
||||||
layerSalvate.clearLayers();
|
if (selezionata !== null && layers.has(selezionata)) {
|
||||||
let layerSelezionata = null;
|
inquadra(selezionata);
|
||||||
|
} else if (layerArea) {
|
||||||
for (const piantina of dati.piantine) {
|
mappa.fitBounds(layerArea.getBounds().pad(0.08));
|
||||||
const selezionata = dati.selezionata === piantina.mappaId;
|
} else if (layers.size) {
|
||||||
const layer = L.geoJSON(
|
mappa.fitBounds(gruppo.getBounds().pad(0.08));
|
||||||
{ type: 'Feature', properties: {}, geometry: piantina.geometry },
|
|
||||||
{ style: stileDi(piantina, selezionata) }
|
|
||||||
);
|
|
||||||
layer.bindTooltip(piantina.etichetta, { sticky: true });
|
|
||||||
layer.on('click', () => {
|
|
||||||
window.Livewire.dispatch('piantina-selezionata-mappa', { mappaId: piantina.mappaId });
|
|
||||||
});
|
|
||||||
layerSalvate.addLayer(layer);
|
|
||||||
|
|
||||||
if (selezionata) {
|
|
||||||
layerSelezionata = layer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aprire una piantina dall'elenco deve portarci sopra; un salvataggio
|
|
||||||
// no, altrimenti la vista salta via mentre si sta ancora lavorando.
|
|
||||||
if (adattaVista && layerSelezionata) {
|
|
||||||
mappa.fitBounds(layerSelezionata.getBounds().pad(0.25));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!adattaVista) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const riferimento = layerArea ?? (dati.piantine.length ? layerSalvate : null);
|
|
||||||
if (riferimento) {
|
|
||||||
mappa.fitBounds(riferimento.getBounds().pad(0.08));
|
|
||||||
} else {
|
} else {
|
||||||
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
|
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ridisegna(dati, true);
|
mappa.setView([41.9, 12.5], 6);
|
||||||
|
|
||||||
mappa.on(L.Draw.Event.CREATED, (evento) => {
|
const avvia = () => { registra(); carica(); };
|
||||||
if (layerInAttesa) {
|
if (window.Livewire?.find && radice && window.Livewire.find(radice.getAttribute('wire:id'))) {
|
||||||
mappa.removeLayer(layerInAttesa);
|
avvia();
|
||||||
}
|
} else {
|
||||||
layerInAttesa = evento.layer;
|
document.addEventListener('livewire:initialized', avvia, { once: true });
|
||||||
layerInAttesa.setStyle(STILE_IN_ATTESA);
|
|
||||||
layerInAttesa.addTo(mappa);
|
|
||||||
|
|
||||||
window.Livewire.dispatch('poligono-disegnato', { geometry: evento.layer.toGeoJSON().geometry });
|
|
||||||
});
|
|
||||||
|
|
||||||
let registrato = false;
|
|
||||||
document.addEventListener('livewire:init', registraEventi);
|
|
||||||
if (window.Livewire) {
|
|
||||||
registraEventi();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function registraEventi() {
|
// Pannelli che si aprono e chiudono cambiano le misure del contenitore:
|
||||||
if (registrato) {
|
// senza, Leaflet resta convinto di quelle di partenza e le tessere si
|
||||||
return;
|
// disallineano dal puntatore.
|
||||||
}
|
new ResizeObserver(() => mappa.invalidateSize()).observe(contenitore);
|
||||||
registrato = true;
|
|
||||||
window.Livewire.on('piantine-dati-aggiornati', ({ dati: nuovi, inquadra }) => {
|
|
||||||
// Dopo un salvataggio il poligono provvisorio è diventato
|
|
||||||
// definitivo (o è stato scartato): via il tracciato rosso.
|
|
||||||
if (layerInAttesa) {
|
|
||||||
mappa.removeLayer(layerInAttesa);
|
|
||||||
layerInAttesa = null;
|
|
||||||
}
|
|
||||||
ridisegna(nuovi, Boolean(inquadra));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// La mappa nasce dentro un contenitore che il pannello laterale può
|
|
||||||
// coprire: senza questo, Leaflet resta convinto delle misure di partenza e
|
|
||||||
// le tessere si disallineano dal puntatore.
|
|
||||||
window.addEventListener('resize', () => mappa.invalidateSize());
|
|
||||||
setTimeout(() => mappa.invalidateSize(), 60);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
50
resources/views/components/avvisi.blade.php
Normal file
50
resources/views/components/avvisi.blade.php
Normal 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">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
@@ -212,6 +212,11 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Gli avvisi delle azioni Livewire (trait Avvisa). I flash della
|
||||||
|
richiesta restano nella fascia in testa al contenuto, qui sopra. --}}
|
||||||
|
<x-avvisi />
|
||||||
|
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
Serve a una cosa sola: la mappa dell'editor delle piantine, che deve
|
Serve a una cosa sola: la mappa dell'editor delle piantine, che deve
|
||||||
prendersi tutta la finestra. I messaggi non spingono il contenuto verso il
|
prendersi tutta la finestra. I messaggi non spingono il contenuto verso il
|
||||||
basso come nelle altre pagine — comparirebbe una fascia che sposta la mappa
|
basso come nelle altre pagine — comparirebbe una fascia che sposta la mappa
|
||||||
mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro,
|
mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro
|
||||||
e si tolgono da soli.
|
(<x-avvisi>, che raccoglie anche quelli lanciati dalle azioni Livewire).
|
||||||
--}}
|
--}}
|
||||||
<html lang="it" class="h-full tm-app">
|
<html lang="it" class="h-full tm-app">
|
||||||
<head>
|
<head>
|
||||||
@@ -22,33 +22,7 @@
|
|||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="tm-avvisi" role="status" aria-live="polite">
|
<x-avvisi :sessione="true" />
|
||||||
@if (session()->has('success'))
|
|
||||||
<div x-data="{ mostra: true }" x-show="mostra" x-cloak
|
|
||||||
x-init="setTimeout(() => mostra = false, 4500)"
|
|
||||||
x-transition:enter="transition ease-out duration-200"
|
|
||||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
|
||||||
x-transition:leave="transition ease-in duration-200"
|
|
||||||
x-transition:leave-end="opacity-0"
|
|
||||||
class="tm-avviso tm-avviso-ok">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
<span>{{ session('success') }}</span>
|
|
||||||
<button type="button" @click="mostra = false" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">×</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">×</button>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@livewireScripts
|
@livewireScripts
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
<div class="tm-editor">
|
@php
|
||||||
|
use App\Models\TerritorioMappa;
|
||||||
|
|
||||||
|
$stato = $mappa?->stato();
|
||||||
|
$motivo = $mappa?->motivoNonPubblicabile();
|
||||||
|
$tagStato = [
|
||||||
|
TerritorioMappa::STATO_BOZZA => 'tm-tag-attesa',
|
||||||
|
TerritorioMappa::STATO_MODIFICATA => 'tm-tag-attesa',
|
||||||
|
TerritorioMappa::STATO_PUBBLICATA => 'tm-tag-ok',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
{{--
|
||||||
|
Editor a tre zone: elenco (a sinistra), mappa, pannello della piantina (a
|
||||||
|
destra; sotto i 720px sale dal basso). Elenco e mappa li disegna
|
||||||
|
resources/js/piantine.js e stanno in wire:ignore: Livewire ridisegna solo
|
||||||
|
testata e pannello. Elenco e pannello si aprono e chiudono in Alpine, senza
|
||||||
|
giri sul server.
|
||||||
|
--}}
|
||||||
|
<div class="tm-editor"
|
||||||
|
x-data="{ elenco: window.matchMedia('(min-width: 1100px)').matches, pannello: true }"
|
||||||
|
@piantina-selezionata.window="if ($event.detail.mappaId) pannello = true">
|
||||||
@vite('resources/js/piantine.js')
|
@vite('resources/js/piantine.js')
|
||||||
|
|
||||||
{{-- ── Barra strumenti ───────────────────────────────────────────────── --}}
|
{{-- ── Testata ───────────────────────────────────────────────────────── --}}
|
||||||
<header class="tm-editor-barra">
|
<header class="tm-editor-barra">
|
||||||
<a href="{{ route('piantine.index') }}" class="tm-editor-torna">
|
<a href="{{ route('piantine.index') }}" class="tm-editor-torna">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||||
@@ -10,53 +30,59 @@
|
|||||||
|
|
||||||
<span class="tm-editor-sep" aria-hidden="true"></span>
|
<span class="tm-editor-sep" aria-hidden="true"></span>
|
||||||
|
|
||||||
<h1 class="tm-editor-titolo">
|
<x-btn variante="ghost" dimensione="sm" icona @click="elenco = ! elenco" ::aria-pressed="elenco"
|
||||||
{{ $mappa?->etichetta() ?? 'Nuova piantina' }}
|
aria-label="Mostra o nascondi l'elenco delle piantine" title="Elenco delle piantine">
|
||||||
@if($geometriaInAttesa)
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||||
<span class="tm-tag tm-tag-attesa">disegno non salvato</span>
|
</x-btn>
|
||||||
@elseif($mappa && ! $mappa->collegata())
|
|
||||||
<span class="tm-tag">scollegata</span>
|
<div class="tm-editor-titolo">
|
||||||
|
<h1>{{ $mappa?->etichetta() ?? 'Editor delle piantine' }}</h1>
|
||||||
|
@if($mappa)
|
||||||
|
<span class="tm-tag {{ $tagStato[$stato] }}">{{ $mappa->statoEtichetta() }}</span>
|
||||||
|
<span class="tm-editor-salvato" aria-live="polite">
|
||||||
|
<span wire:loading.delay.shortest>Salvataggio…</span>
|
||||||
|
<span wire:loading.remove>
|
||||||
|
@if($salvatoAlle) Salvato alle {{ $salvatoAlle }} @endif
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</h1>
|
</div>
|
||||||
|
|
||||||
<div class="tm-editor-azioni">
|
<div class="tm-editor-azioni">
|
||||||
<x-btn variante="secondary" dimensione="sm" wire:click="nuova">
|
<x-btn variante="secondary" dimensione="sm" wire:click="nuova">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||||
Nuova
|
<span>Nuova piantina</span>
|
||||||
</x-btn>
|
|
||||||
|
|
||||||
<x-btn variante="secondary" dimensione="sm" wire:click="$toggle('pannelloAperto')">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
|
|
||||||
Scheda
|
|
||||||
</x-btn>
|
</x-btn>
|
||||||
|
|
||||||
@if($mappa)
|
@if($mappa)
|
||||||
<x-btn variante="secondary" dimensione="sm"
|
<x-btn variante="ghost" dimensione="sm" class="tm-solo-stretto" @click="pannello = ! pannello">
|
||||||
wire:click="generaImmagine" wire:loading.attr="disabled" wire:target="generaImmagine">
|
Scheda
|
||||||
<span wire:loading.remove wire:target="generaImmagine">Genera immagine</span>
|
|
||||||
<span wire:loading wire:target="generaImmagine">Rendering…</span>
|
|
||||||
</x-btn>
|
</x-btn>
|
||||||
|
|
||||||
@if($mappa->collegata() && $mappa->image_path)
|
<x-btn variante="primary" dimensione="sm" wire:click="pubblica"
|
||||||
<x-btn variante="secondary" dimensione="sm"
|
:disabled="$motivo !== null || $stato === TerritorioMappa::STATO_PUBBLICATA"
|
||||||
:href="route('piantine.anteprima', $mappa)" target="_blank" rel="noopener">
|
:title="$motivo ?? ($stato === TerritorioMappa::STATO_PUBBLICATA ? 'Il PDF del territorio corrisponde già a questa piantina.' : 'Anteprima del PDF e pubblicazione')">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
|
||||||
Anteprima PDF
|
<span>Pubblica</span>
|
||||||
</x-btn>
|
</x-btn>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-btn variante="primary" dimensione="sm" wire:click="salva"
|
|
||||||
wire:loading.attr="disabled" wire:target="salva">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
||||||
Salva
|
|
||||||
</x-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{-- ── Mappa ─────────────────────────────────────────────────────────── --}}
|
<div class="tm-editor-corpo" :class="{ 'con-elenco': elenco, 'con-pannello': pannello }">
|
||||||
|
{{-- ── Elenco ────────────────────────────────────────────────────── --}}
|
||||||
|
<aside class="tm-editor-elenco" x-show="elenco" x-cloak aria-label="Elenco delle piantine" wire:ignore>
|
||||||
|
<div class="tm-editor-elenco-cerca">
|
||||||
|
<input type="search" id="piantine-cerca" placeholder="Cerca per numero o nome" autocomplete="off"
|
||||||
|
aria-label="Cerca una piantina">
|
||||||
|
</div>
|
||||||
|
<ul id="piantine-elenco" class="tm-editor-elenco-righe"></ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{-- ── Mappa ─────────────────────────────────────────────────────── --}}
|
||||||
<div class="tm-editor-mappa">
|
<div class="tm-editor-mappa">
|
||||||
<div wire:ignore id="piantine-map" class="tm-mappa"></div>
|
<div wire:ignore id="piantine-map" class="tm-mappa"></div>
|
||||||
|
<div wire:ignore id="piantine-strumenti" class="tm-strumenti" role="toolbar" aria-label="Strumenti di disegno"></div>
|
||||||
|
|
||||||
@unless($areaCaricata)
|
@unless($areaCaricata)
|
||||||
<p class="tm-editor-nota">
|
<p class="tm-editor-nota">
|
||||||
@@ -64,103 +90,130 @@
|
|||||||
Il KML si carica dalla <a href="{{ route('piantine.index') }}">pagina di gestione</a>.
|
Il KML si carica dalla <a href="{{ route('piantine.index') }}">pagina di gestione</a>.
|
||||||
</p>
|
</p>
|
||||||
@endunless
|
@endunless
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Pannello: scorre sopra la mappa, non la restringe. --}}
|
{{-- ── Pannello della piantina ───────────────────────────────────── --}}
|
||||||
<aside class="tm-editor-pannello" @if(! $pannelloAperto) hidden @endif
|
<aside class="tm-editor-pannello" x-show="pannello" x-cloak aria-label="La piantina aperta">
|
||||||
aria-label="Scheda della piantina">
|
|
||||||
<div class="tm-editor-pannello-testata">
|
<div class="tm-editor-pannello-testata">
|
||||||
<h2>Scheda</h2>
|
<h2>{{ $mappa ? 'La piantina' : 'Come si comincia' }}</h2>
|
||||||
<button type="button" wire:click="chiudiPannello" class="tm-editor-chiudi" aria-label="Chiudi il pannello">×</button>
|
<button type="button" @click="pannello = false" class="tm-editor-chiudi" aria-label="Chiudi il pannello">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-editor-pannello-corpo">
|
<div class="tm-editor-pannello-corpo">
|
||||||
@if($geometriaInAttesa)
|
@if(! $mappa)
|
||||||
<div class="tm-nota tm-nota-attesa">
|
<ol class="tm-passi">
|
||||||
<p>C'è un poligono disegnato non ancora salvato.</p>
|
<li>Apri una piantina dall'elenco o cliccandola sulla mappa,</li>
|
||||||
<x-btn variante="ghost" dimensione="sm" wire:click="scartaDisegno">Scarta il disegno</x-btn>
|
<li>oppure premi <strong>Nuova piantina</strong> e traccia il confine punto per punto.</li>
|
||||||
</div>
|
<li>Collega la piantina a un territorio e controlla i dati.</li>
|
||||||
@elseif(! $mappa)
|
<li>Premi <strong>Pubblica</strong>: vedi il PDF e lo confermi.</li>
|
||||||
|
</ol>
|
||||||
<p class="tm-editor-aiuto">
|
<p class="tm-editor-aiuto">
|
||||||
Usa lo strumento poligono in alto a sinistra sulla mappa per tracciare il confine.
|
Tutto quello che fai qui si salva da solo come bozza. Il PDF del territorio cambia
|
||||||
La scheda del territorio si compila qui sotto, anche dopo.
|
solo quando pubblichi.
|
||||||
</p>
|
</p>
|
||||||
|
@else
|
||||||
|
{{-- ── Pubblicazione ─────────────────────────────────── --}}
|
||||||
|
<section class="tm-gruppo tm-stato-piantina">
|
||||||
|
@if($stato === TerritorioMappa::STATO_BOZZA)
|
||||||
|
<p>Mai pubblicata: il territorio non ha ancora il PDF di questa piantina.</p>
|
||||||
|
@elseif($stato === TerritorioMappa::STATO_MODIFICATA)
|
||||||
|
<p>
|
||||||
|
Il PDF in uso è quello pubblicato il {{ $mappa->pubblicata_at->format('d/m/Y') }}.
|
||||||
|
Le modifiche fatte da allora non sono ancora nel PDF.
|
||||||
|
</p>
|
||||||
|
@else
|
||||||
|
<p>Il PDF del territorio corrisponde a questa piantina, pubblicata il {{ $mappa->pubblicata_at->format('d/m/Y') }}.</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- ── La piantina ───────────────────────────────────────── --}}
|
@if($motivo)
|
||||||
<fieldset class="tm-gruppo">
|
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">Per pubblicare: {{ lcfirst($motivo) }}</p>
|
||||||
<legend>La piantina</legend>
|
@endif
|
||||||
|
|
||||||
<div class="tm-campo">
|
<div class="tm-azioni-riga">
|
||||||
<label for="nome">Nome interno</label>
|
@if($mappa->collegata() && $mappa->territorio->pdf_path)
|
||||||
<input type="text" id="nome" wire:model="nome" autocomplete="off"
|
<a href="{{ asset('storage/' . $mappa->territorio->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
|
||||||
placeholder="Es. Zona porto — lato nord">
|
@endif
|
||||||
<p class="tm-campo-aiuto">
|
@if($mappa->bozzaScartabile())
|
||||||
Serve solo a ritrovarla in elenco finché non è collegata a un territorio.
|
<x-btn variante="ghost" dimensione="sm" wire:click="scartaModifiche"
|
||||||
Non viene stampato.
|
wire:confirm="Riportare il confine a quello pubblicato? Il disegno attuale va perso.">
|
||||||
</p>
|
Ripristina il confine pubblicato
|
||||||
|
</x-btn>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</section>
|
||||||
|
|
||||||
{{-- ── Il territorio ─────────────────────────────────────── --}}
|
{{-- ── Territorio ────────────────────────────────────── --}}
|
||||||
<fieldset class="tm-gruppo">
|
<fieldset class="tm-gruppo">
|
||||||
<legend>Il territorio</legend>
|
<legend>Territorio</legend>
|
||||||
|
|
||||||
<div class="tm-campo">
|
<div class="tm-campo">
|
||||||
<label for="territorioScelto">Collegamento</label>
|
<label for="territorioScelto">Collegata a</label>
|
||||||
<select id="territorioScelto" wire:model.live="territorioScelto">
|
<select id="territorioScelto" wire:model.live="territorioScelto">
|
||||||
<option value="">— nessuno, piantina scollegata —</option>
|
<option value="">— nessun territorio —</option>
|
||||||
<option value="nuovo">+ Crea un territorio nuovo</option>
|
<option value="nuovo">+ Crea un territorio nuovo</option>
|
||||||
@foreach($territori as $territorio)
|
@foreach($territori as $territorio)
|
||||||
<option value="{{ $territorio->id }}">Territorio {{ $territorio->numero }}</option>
|
<option value="{{ $territorio->id }}">
|
||||||
|
Territorio {{ $territorio->numero }}@if($territorio->trashed()) (nel cestino)@elseif(! $territorio->attivo) (disattivato)@endif
|
||||||
|
</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
@if($territorioScelto === '')
|
@error('territorioScelto') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||||
<p class="tm-campo-aiuto">
|
@if($mappa->territorioNelCestino())
|
||||||
Senza territorio la piantina si disegna e si renderizza lo stesso; per il PDF
|
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">
|
||||||
sul modulo S-12 serve il collegamento.
|
Il territorio è nel cestino: il collegamento resta, ma per pubblicare va ripristinato.
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($territorioScelto !== '')
|
@if($territorioScelto !== '')
|
||||||
|
@if($territorioScelto === 'nuovo')
|
||||||
|
<p class="tm-campo-aiuto">Compila i dati e premi <strong>Crea territorio</strong>.</p>
|
||||||
|
@else
|
||||||
|
<p class="tm-campo-aiuto">
|
||||||
|
Questi sono i dati del territorio in tutto il gestionale: le modifiche valgono
|
||||||
|
subito. Nel PDF entrano alla prossima pubblicazione.
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@php($modello = $territorioScelto === 'nuovo' ? 'wire:model' : 'wire:model.blur')
|
||||||
|
|
||||||
<div class="tm-campo">
|
<div class="tm-campo">
|
||||||
<label for="numero">Numero</label>
|
<label for="numero">Numero</label>
|
||||||
<input type="text" id="numero" wire:model="numero" autocomplete="off"
|
<input type="text" id="numero" {{ $modello }}="numero" autocomplete="off" maxlength="20" placeholder="Es. 42">
|
||||||
maxlength="20" placeholder="Es. 42">
|
|
||||||
@error('numero') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
@error('numero') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-campo">
|
<div class="tm-campo" x-data="{ crea: false }">
|
||||||
<label for="zonaId">Zona <span class="tm-campo-nota">stampata come «Località» sulla cartolina</span></label>
|
<label for="zonaId">Zona <span class="tm-campo-nota">stampata come «Località»</span></label>
|
||||||
<select id="zonaId" wire:model="zonaId">
|
<select id="zonaId" {{ $territorioScelto === 'nuovo' ? 'wire:model' : 'wire:model.live' }}="zonaId">
|
||||||
<option value="">— nessuna —</option>
|
<option value="">— nessuna —</option>
|
||||||
@foreach($zone as $zona)
|
@foreach($zone as $zona)
|
||||||
<option value="{{ $zona->id }}">{{ $zona->nome }}</option>
|
<option value="{{ $zona->id }}">{{ $zona->nome }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
<div class="tm-campo-riga tm-campo-crea">
|
@error('zonaId') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||||
<input type="text" wire:model="nuovaZona" autocomplete="off"
|
<button type="button" class="tm-collegamento tm-collegamento-piccolo" @click="crea = ! crea" x-show="! crea">+ Zona che non c'è ancora</button>
|
||||||
placeholder="Zona che non c'è ancora"
|
<div class="tm-campo-riga tm-campo-crea" x-show="crea" x-cloak>
|
||||||
aria-label="Nome della nuova zona"
|
<input type="text" wire:model="nuovaZona" autocomplete="off" placeholder="Nome della nuova zona"
|
||||||
wire:keydown.enter.prevent="creaZona">
|
aria-label="Nome della nuova zona" wire:keydown.enter.prevent="creaZona">
|
||||||
<x-btn variante="secondary" dimensione="sm" wire:click="creaZona">Crea</x-btn>
|
<x-btn variante="secondary" dimensione="sm" wire:click="creaZona">Crea</x-btn>
|
||||||
</div>
|
</div>
|
||||||
@error('nuovaZona') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
@error('nuovaZona') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tm-campo">
|
<div class="tm-campo" x-data="{ crea: false }">
|
||||||
<label for="tipologiaId">Tipologia</label>
|
<label for="tipologiaId">Tipologia</label>
|
||||||
<select id="tipologiaId" wire:model="tipologiaId">
|
<select id="tipologiaId" {{ $territorioScelto === 'nuovo' ? 'wire:model' : 'wire:model.live' }}="tipologiaId">
|
||||||
<option value="">— nessuna —</option>
|
<option value="">— nessuna —</option>
|
||||||
@foreach($tipologie as $tipologia)
|
@foreach($tipologie as $tipologia)
|
||||||
<option value="{{ $tipologia->id }}">{{ $tipologia->nome }}</option>
|
<option value="{{ $tipologia->id }}">{{ $tipologia->nome }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
<div class="tm-campo-riga tm-campo-crea">
|
@error('tipologiaId') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||||
<input type="text" wire:model="nuovaTipologia" autocomplete="off"
|
<button type="button" class="tm-collegamento tm-collegamento-piccolo" @click="crea = ! crea" x-show="! crea">+ Tipologia che non c'è ancora</button>
|
||||||
placeholder="Tipologia che non c'è ancora"
|
<div class="tm-campo-riga tm-campo-crea" x-show="crea" x-cloak>
|
||||||
aria-label="Nome della nuova tipologia"
|
<input type="text" wire:model="nuovaTipologia" autocomplete="off" placeholder="Nome della nuova tipologia"
|
||||||
wire:keydown.enter.prevent="creaTipologia">
|
aria-label="Nome della nuova tipologia" wire:keydown.enter.prevent="creaTipologia">
|
||||||
<x-btn variante="secondary" dimensione="sm" wire:click="creaTipologia">Crea</x-btn>
|
<x-btn variante="secondary" dimensione="sm" wire:click="creaTipologia">Crea</x-btn>
|
||||||
</div>
|
</div>
|
||||||
@error('nuovaTipologia') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
@error('nuovaTipologia') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||||
@@ -168,30 +221,37 @@
|
|||||||
|
|
||||||
<div class="tm-campo">
|
<div class="tm-campo">
|
||||||
<label for="confini">Confini</label>
|
<label for="confini">Confini</label>
|
||||||
<textarea id="confini" wire:model="confini" rows="6"
|
<textarea id="confini" {{ $modello }}="confini" rows="6"
|
||||||
placeholder="Nord: … Est: … Sud: … Ovest: …"></textarea>
|
placeholder="Nord: … Est: … Sud: … Ovest: …"></textarea>
|
||||||
@error('confini') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
@error('confini') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||||
<p class="tm-campo-aiuto">
|
<p class="tm-campo-aiuto">Stampati sulla prima pagina del PDF; il testo si adatta da solo allo spazio.</p>
|
||||||
Vengono stampati sulla prima pagina della cartolina, nello spazio centrale
|
|
||||||
del modulo. Il corpo del testo si adatta da solo alla lunghezza.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($territorioScelto === 'nuovo')
|
||||||
|
<x-btn variante="primary" dimensione="sm" wire:click="creaTerritorio"
|
||||||
|
wire:loading.attr="disabled" wire:target="creaTerritorio">
|
||||||
|
Crea territorio
|
||||||
|
</x-btn>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@if($mappa)
|
{{-- ── Nome interno, solo finché è scollegata ───────── --}}
|
||||||
<div class="tm-editor-pannello-piede">
|
@if(! $mappa->territorio)
|
||||||
@if($mappa->image_path)
|
<fieldset class="tm-gruppo">
|
||||||
<a href="{{ asset('storage/' . $mappa->image_path) }}" target="_blank" rel="noopener"
|
<legend>Nome interno</legend>
|
||||||
class="tm-collegamento">
|
<div class="tm-campo">
|
||||||
Immagine del {{ $mappa->rendered_at?->format('d/m/Y H:i') }}
|
<label for="nome" class="tm-solo-lettori">Nome interno</label>
|
||||||
</a>
|
<input type="text" id="nome" wire:model.blur="nome" autocomplete="off" maxlength="120"
|
||||||
@else
|
placeholder="Es. Zona porto — lato nord">
|
||||||
<span class="tm-editor-aiuto">Immagine non ancora generata.</span>
|
<p class="tm-campo-aiuto">Serve a ritrovarla in elenco finché non ha un territorio. Non viene stampato.</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<div class="tm-editor-pannello-piede">
|
||||||
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="elimina"
|
<x-btn variante="danger" tono="tenue" dimensione="sm" wire:click="elimina"
|
||||||
wire:confirm="Eliminare {{ $mappa->etichetta() }}? Poligono e immagine vengono rimossi; il territorio e il suo PDF restano.">
|
wire:confirm="Eliminare {{ $mappa->etichetta() }}? Il disegno va perso; il territorio e il suo PDF restano com'erano.">
|
||||||
Elimina piantina
|
Elimina piantina
|
||||||
</x-btn>
|
</x-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,5 +260,5 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="application/json" id="piantine-dati">@json($datiMappaIniziali)</script>
|
<livewire:piantine.pubblicazione />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
|
@php
|
||||||
|
use App\Models\TerritorioMappa;
|
||||||
|
|
||||||
|
$tagStato = [
|
||||||
|
TerritorioMappa::STATO_BOZZA => 'tm-tag-attesa',
|
||||||
|
TerritorioMappa::STATO_MODIFICATA => 'tm-tag-attesa',
|
||||||
|
TerritorioMappa::STATO_PUBBLICATA => 'tm-tag-ok',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<header class="tm-pagina-testata">
|
<header class="tm-pagina-testata">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="tm-pagina-titolo">Piantine dei territori</h1>
|
<h1 class="tm-pagina-titolo">Piantine dei territori</h1>
|
||||||
<p class="tm-pagina-nota">
|
<p class="tm-pagina-nota">
|
||||||
Il disegno dei confini si fa nell'editor a schermo intero. Qui si tiene l'area della
|
A che punto è ogni piantina. Il disegno e i dati del territorio si modificano nell'editor,
|
||||||
città, si generano immagini e PDF sul modulo S-12, e si ripescano le versioni sostituite.
|
dove le modifiche si salvano da sole come bozza; il PDF del territorio cambia solo quando
|
||||||
|
si pubblica, dopo averne visto l'anteprima.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<x-btn variante="primary" :href="route('piantine.editor')">
|
<x-btn variante="primary" :href="route('piantine.editor')">
|
||||||
@@ -13,8 +24,101 @@
|
|||||||
</x-btn>
|
</x-btn>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{{-- ── Piantine ──────────────────────────────────────────────────────── --}}
|
||||||
|
<section class="tm-card">
|
||||||
|
<div class="tm-card-testata">
|
||||||
|
<h2 class="tm-card-titolo">Piantine <span class="tm-conteggio">{{ $totale }}</span></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($totale === 0)
|
||||||
|
<div class="tm-vuoto">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||||
|
<p>Nessuna piantina disegnata.</p>
|
||||||
|
<x-btn variante="primary" dimensione="sm" :href="route('piantine.editor')">Disegna la prima</x-btn>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="tm-filtri">
|
||||||
|
<input type="search" wire:model.live.debounce.300ms="cerca" class="tm-filtri-cerca"
|
||||||
|
placeholder="Cerca numero, nome o zona…" aria-label="Cerca una piantina">
|
||||||
|
<div class="tm-filtri-schede" role="group" aria-label="Filtra per stato">
|
||||||
|
@foreach(\App\Livewire\Piantine\PiantineManager::FILTRI as $chiave => $etichetta)
|
||||||
|
<button type="button" wire:click="$set('filtro', '{{ $chiave }}')"
|
||||||
|
class="tm-filtri-scheda {{ $filtro === $chiave ? 'tm-filtri-scheda-attiva' : '' }}"
|
||||||
|
aria-pressed="{{ $filtro === $chiave ? 'true' : 'false' }}">
|
||||||
|
{{ $etichetta }} <span class="tm-conteggio">{{ $conteggi[$chiave] }}</span>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($elenco->isEmpty())
|
||||||
|
<p class="tm-campo-aiuto">Nessuna piantina corrisponde alla ricerca.</p>
|
||||||
|
@else
|
||||||
|
<ul class="tm-elenco">
|
||||||
|
@foreach($elenco as $piantina)
|
||||||
|
@php
|
||||||
|
$stato = $piantina->stato();
|
||||||
|
$motivo = $piantina->motivoNonPubblicabile();
|
||||||
|
@endphp
|
||||||
|
<li class="tm-elenco-riga" wire:key="piantina-{{ $piantina->id }}">
|
||||||
|
<div class="tm-elenco-intestazione">
|
||||||
|
<a href="{{ route('piantine.editor', $piantina->id) }}" class="tm-elenco-nome">
|
||||||
|
{{ $piantina->etichetta() }}
|
||||||
|
</a>
|
||||||
|
<span class="tm-tag {{ $tagStato[$stato] }}">{{ $piantina->statoEtichetta() }}</span>
|
||||||
|
@if($piantina->territorioNelCestino())
|
||||||
|
<span class="tm-tag tm-tag-attesa">Territorio nel cestino</span>
|
||||||
|
@elseif(! $piantina->collegata())
|
||||||
|
<span class="tm-tag">Scollegata</span>
|
||||||
|
@elseif(! $piantina->territorio->zona_id)
|
||||||
|
<span class="tm-tag tm-tag-attesa" title="La zona viene stampata come «Località»: senza, l'intestazione della cartolina resta vuota">Zona mancante</span>
|
||||||
|
@endif
|
||||||
|
<span class="tm-elenco-stato">
|
||||||
|
@if($piantina->territorio?->zona)
|
||||||
|
{{ $piantina->territorio->zona->nome }} ·
|
||||||
|
@endif
|
||||||
|
{{ $piantina->pubblicata_at
|
||||||
|
? 'pubblicata il ' . $piantina->pubblicata_at->format('d/m/Y H:i')
|
||||||
|
: 'mai pubblicata' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tm-elenco-azioni">
|
||||||
|
<x-btn variante="secondary" dimensione="sm" :href="route('piantine.editor', $piantina->id)">
|
||||||
|
Apri
|
||||||
|
</x-btn>
|
||||||
|
|
||||||
|
@if($stato !== TerritorioMappa::STATO_PUBBLICATA)
|
||||||
|
<x-btn variante="primary" tono="tenue" dimensione="sm"
|
||||||
|
wire:click="pubblica({{ $piantina->id }})"
|
||||||
|
:disabled="$motivo !== null" :title="$motivo">
|
||||||
|
Pubblica
|
||||||
|
</x-btn>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($piantina->collegata() && $piantina->territorio->pdf_path)
|
||||||
|
<a href="{{ asset('storage/' . $piantina->territorio->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-btn variante="danger" tono="tenue" dimensione="sm"
|
||||||
|
wire:click="elimina({{ $piantina->id }})"
|
||||||
|
wire:confirm="Eliminare {{ $piantina->etichetta() }}? Il disegno viene rimosso; il territorio e il suo PDF restano com'erano.">
|
||||||
|
Elimina
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($motivo && $stato !== TerritorioMappa::STATO_PUBBLICATA)
|
||||||
|
<p class="tm-campo-aiuto">{{ $motivo }}</p>
|
||||||
|
@endif
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
|
|
||||||
{{-- ── Area della città ──────────────────────────────────────────────── --}}
|
{{-- ── Area della città ──────────────────────────────────────────────── --}}
|
||||||
<section class="tm-card tm-card-primaria {{ $config['kml_nome'] ? '' : 'tm-card-manca' }}">
|
<section class="tm-card {{ $config['kml_nome'] ? '' : 'tm-card-manca' }}">
|
||||||
<div class="tm-card-testata tm-card-testata-icona">
|
<div class="tm-card-testata tm-card-testata-icona">
|
||||||
<span class="tm-card-icona" aria-hidden="true">
|
<span class="tm-card-icona" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||||
@@ -29,8 +133,7 @@
|
|||||||
@endif
|
@endif
|
||||||
</h2>
|
</h2>
|
||||||
<p class="tm-card-nota">
|
<p class="tm-card-nota">
|
||||||
Il perimetro di riferimento su cui si disegnano le piantine: è il primo passo,
|
Il perimetro di riferimento che l'editor mostra sotto le piantine.
|
||||||
senza l'editor parte su una mappa senza contorni.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,110 +162,14 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{-- ── Piantine ──────────────────────────────────────────────────────── --}}
|
|
||||||
<section class="tm-card">
|
|
||||||
<div class="tm-card-testata">
|
|
||||||
<h2 class="tm-card-titolo">Piantine <span class="tm-conteggio">{{ $piantine->count() }}</span></h2>
|
|
||||||
<p class="tm-card-nota">
|
|
||||||
Una piantina può vivere scollegata: per il PDF sul modulo S-12 serve invece un territorio.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($piantine->isEmpty())
|
|
||||||
<div class="tm-vuoto">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
|
||||||
<p>Nessuna piantina disegnata.</p>
|
|
||||||
<x-btn variante="primary" dimensione="sm" :href="route('piantine.editor')">Disegna la prima</x-btn>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<ul class="tm-elenco">
|
|
||||||
@foreach($piantine as $piantina)
|
|
||||||
<li class="tm-elenco-riga" wire:key="piantina-{{ $piantina->id }}">
|
|
||||||
<div class="tm-elenco-intestazione">
|
|
||||||
<a href="{{ route('piantine.editor', $piantina->id) }}" class="tm-elenco-nome">
|
|
||||||
{{ $piantina->etichetta() }}
|
|
||||||
</a>
|
|
||||||
@unless($piantina->collegata())
|
|
||||||
<span class="tm-tag">scollegata</span>
|
|
||||||
@endunless
|
|
||||||
@if($piantina->collegata() && ! $piantina->territorio->zona_id)
|
|
||||||
<span class="tm-tag tm-tag-attesa" title="La zona viene stampata come «Località»: senza, l'intestazione della cartolina resta vuota">Zona mancante</span>
|
|
||||||
@endif
|
|
||||||
<span class="tm-elenco-stato {{ $piantina->image_path ? 'tm-elenco-stato-ok' : '' }}">
|
|
||||||
{{ $piantina->image_path
|
|
||||||
? 'immagine del ' . $piantina->rendered_at?->format('d/m/Y H:i')
|
|
||||||
: 'immagine da generare' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tm-elenco-azioni">
|
|
||||||
<x-btn variante="secondary" tono="tenue" dimensione="sm"
|
|
||||||
wire:click="generaImmagine({{ $piantina->id }})"
|
|
||||||
wire:loading.attr="disabled" wire:target="generaImmagine({{ $piantina->id }})">
|
|
||||||
<span wire:loading.remove wire:target="generaImmagine({{ $piantina->id }})">Genera immagine</span>
|
|
||||||
<span wire:loading wire:target="generaImmagine({{ $piantina->id }})">Rendering…</span>
|
|
||||||
</x-btn>
|
|
||||||
|
|
||||||
@if($piantina->image_path)
|
|
||||||
<a href="{{ asset('storage/' . $piantina->image_path) }}" target="_blank" rel="noopener" class="tm-collegamento">Immagine</a>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($piantina->collegata())
|
|
||||||
@if($piantina->image_path)
|
|
||||||
<a href="{{ route('piantine.anteprima', $piantina) }}" target="_blank" rel="noopener" class="tm-collegamento">Anteprima PDF</a>
|
|
||||||
<x-btn variante="primary" tono="tenue" dimensione="sm"
|
|
||||||
wire:click="generaPdf({{ $piantina->id }})"
|
|
||||||
wire:loading.attr="disabled" wire:target="generaPdf({{ $piantina->id }})"
|
|
||||||
wire:confirm="Sostituire il PDF del territorio {{ $piantina->territorio->numero }}? Quello attuale finisce nello storico versioni.">
|
|
||||||
<span wire:loading.remove wire:target="generaPdf({{ $piantina->id }})">Genera PDF</span>
|
|
||||||
<span wire:loading wire:target="generaPdf({{ $piantina->id }})">Composizione…</span>
|
|
||||||
</x-btn>
|
|
||||||
@endif
|
|
||||||
<x-btn variante="ghost" dimensione="sm" wire:click="scollega({{ $piantina->id }})"
|
|
||||||
wire:confirm="Scollegare questa piantina dal territorio {{ $piantina->territorio->numero }}? Il PDF del territorio resta com'è.">
|
|
||||||
Scollega
|
|
||||||
</x-btn>
|
|
||||||
@else
|
|
||||||
<x-btn variante="secondary" dimensione="sm" wire:click="apriCollegamento({{ $piantina->id }})">
|
|
||||||
Collega a un territorio
|
|
||||||
</x-btn>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-btn variante="danger" tono="tenue" dimensione="sm"
|
|
||||||
wire:click="eliminaPiantina({{ $piantina->id }})"
|
|
||||||
wire:confirm="Eliminare {{ $piantina->etichetta() }}? Poligono e immagine vengono rimossi; il PDF del territorio resta com'è.">
|
|
||||||
Elimina
|
|
||||||
</x-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($collegamentoAperto === $piantina->id)
|
|
||||||
<div class="tm-elenco-pannello">
|
|
||||||
<div class="tm-campo-riga">
|
|
||||||
<select wire:model="territorioDaCollegare" aria-label="Territorio a cui collegare la piantina">
|
|
||||||
<option value="">— scegli il territorio —</option>
|
|
||||||
@foreach($territori as $territorio)
|
|
||||||
<option value="{{ $territorio->id }}">{{ $territorio->numero }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
<x-btn variante="primary" dimensione="sm" wire:click="collega({{ $piantina->id }})">Collega</x-btn>
|
|
||||||
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiCollegamento">Annulla</x-btn>
|
|
||||||
</div>
|
|
||||||
<p class="tm-campo-aiuto">
|
|
||||||
Il territorio da creare non c'è? Si crea dall'editor, senza perdere il disegno.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</li>
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
@endif
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{-- ── Storico versioni PDF ──────────────────────────────────────────── --}}
|
{{-- ── Storico versioni PDF ──────────────────────────────────────────── --}}
|
||||||
<section class="tm-card">
|
<section class="tm-card">
|
||||||
<div class="tm-card-testata">
|
<div class="tm-card-testata">
|
||||||
<h2 class="tm-card-titolo">Storico versioni PDF</h2>
|
<h2 class="tm-card-titolo">Storico versioni PDF <span class="tm-conteggio">{{ $versioni->total() }}</span></h2>
|
||||||
<p class="tm-card-nota">I PDF sostituiti da una piantina. Il ripristino è sempre reversibile.</p>
|
<p class="tm-card-nota">
|
||||||
|
I PDF dei territori sostituiti da una pubblicazione o da un ripristino. Ripristinare una versione
|
||||||
|
rimette in uso quel PDF: la piantina del territorio torna "da pubblicare".
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($versioni->isEmpty())
|
@if($versioni->isEmpty())
|
||||||
@@ -205,6 +212,10 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">{{ $versioni->links() }}</div>
|
||||||
@endif
|
@endif
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<livewire:piantine.pubblicazione />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
54
resources/views/livewire/piantine/pubblicazione.blade.php
Normal file
54
resources/views/livewire/piantine/pubblicazione.blade.php
Normal 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">×</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>
|
||||||
@@ -171,7 +171,9 @@ Route::middleware('auth')->group(function () {
|
|||||||
// schermo intero perché la mappa vuole tutto lo spazio che c'è.
|
// schermo intero perché la mappa vuole tutto lo spazio che c'è.
|
||||||
Route::get('piantine', PiantineManager::class)->name('piantine.index');
|
Route::get('piantine', PiantineManager::class)->name('piantine.index');
|
||||||
Route::get('piantine/editor/{mappa?}', PiantineEditor::class)->name('piantine.editor');
|
Route::get('piantine/editor/{mappa?}', PiantineEditor::class)->name('piantine.editor');
|
||||||
Route::get('piantine/{mappa}/anteprima', PiantinaAnteprimaController::class)->name('piantine.anteprima');
|
Route::get('piantine/anteprima/{token}', PiantinaAnteprimaController::class)
|
||||||
|
->where('token', '[A-Za-z0-9]{40}')
|
||||||
|
->name('piantine.anteprima');
|
||||||
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
|
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
|
||||||
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
|
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
|
||||||
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
|
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Livewire\Piantine\PiantineEditor;
|
use App\Livewire\Piantine\PiantineEditor;
|
||||||
|
use App\Livewire\Piantine\Pubblicazione;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\TerritorioMappa;
|
use App\Models\TerritorioMappa;
|
||||||
@@ -17,8 +18,9 @@ use Spatie\Permission\PermissionRegistrar;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* L'editor a schermo intero: si disegna prima e si decide dopo a chi
|
* L'editor a schermo intero: il disegno è bozza e si salva da solo, i dati
|
||||||
* appartiene il disegno — territorio compreso, che può nascere qui.
|
* del territorio si salvano subito nel gestionale, il PDF non si tocca finché
|
||||||
|
* non si pubblica.
|
||||||
*/
|
*/
|
||||||
class PiantineEditorTest extends TestCase
|
class PiantineEditorTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -29,6 +31,11 @@ class PiantineEditorTest extends TestCase
|
|||||||
'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
|
'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private const ALTRA_GEOMETRIA = [
|
||||||
|
'type' => 'Polygon',
|
||||||
|
'coordinates' => [[[13.40, 38.11], [13.42, 38.11], [13.42, 38.13], [13.40, 38.11]]],
|
||||||
|
];
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
@@ -53,67 +60,242 @@ class PiantineEditorTest extends TestCase
|
|||||||
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
|
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_una_piantina_si_salva_senza_nessun_territorio(): void
|
public function test_la_pagina_si_apre_su_una_piantina_esistente(): void
|
||||||
|
{
|
||||||
|
$mappa = TerritorioMappa::create(['nome' => 'Zona porto', 'geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
|
$this->actingAs($this->admin())
|
||||||
|
->get(route('piantine.editor', $mappa->id))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Zona porto');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_disegno_crea_subito_una_bozza_senza_territorio(): void
|
||||||
{
|
{
|
||||||
Livewire::actingAs($this->admin())
|
Livewire::actingAs($this->admin())
|
||||||
->test(PiantineEditor::class)
|
->test(PiantineEditor::class)
|
||||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
->dispatch('geometria-modificata', geometry: self::GEOMETRIA, nuova: true)
|
||||||
->set('nome', 'Zona porto — lato nord')
|
->assertDispatched('piantina-aggiornata')
|
||||||
->call('salva');
|
->assertDispatched('editor-indirizzo')
|
||||||
|
->set('nome', 'Zona porto — lato nord');
|
||||||
|
|
||||||
$mappa = TerritorioMappa::sole();
|
$mappa = TerritorioMappa::sole();
|
||||||
$this->assertNull($mappa->territorio_id);
|
$this->assertNull($mappa->territorio_id);
|
||||||
$this->assertSame('Zona porto — lato nord', $mappa->nome);
|
$this->assertSame('Zona porto — lato nord', $mappa->nome);
|
||||||
$this->assertSame(self::GEOMETRIA, $mappa->geometria());
|
$this->assertSame(self::GEOMETRIA, $mappa->geometria());
|
||||||
$this->assertSame('Zona porto — lato nord', $mappa->etichetta());
|
$this->assertSame(TerritorioMappa::STATO_BOZZA, $mappa->stato());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_il_territorio_si_crea_dalla_scheda_con_tutte_le_proprieta(): void
|
public function test_spostare_i_vertici_aggiorna_la_piantina_aperta(): void
|
||||||
|
{
|
||||||
|
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
|
->dispatch('geometria-modificata', geometry: self::ALTRA_GEOMETRIA, nuova: false);
|
||||||
|
|
||||||
|
$this->assertSame(1, TerritorioMappa::count());
|
||||||
|
$this->assertSame(self::ALTRA_GEOMETRIA, $mappa->fresh()->geometria());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_disegno_nuovo_con_una_piantina_aperta_non_ne_sostituisce_il_confine(): void
|
||||||
|
{
|
||||||
|
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
|
->dispatch('geometria-modificata', geometry: self::ALTRA_GEOMETRIA, nuova: true);
|
||||||
|
|
||||||
|
$this->assertSame(2, TerritorioMappa::count());
|
||||||
|
$this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_una_geometria_non_valida_viene_respinta(): void
|
||||||
|
{
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class)
|
||||||
|
->dispatch('geometria-modificata', geometry: ['type' => 'Point', 'coordinates' => [1, 2]], nuova: true)
|
||||||
|
->dispatch('geometria-modificata', geometry: [
|
||||||
|
'type' => 'Polygon',
|
||||||
|
'coordinates' => [[[0, 0], [1, 0], [0, 0]]],
|
||||||
|
], nuova: true)
|
||||||
|
->dispatch('geometria-modificata', geometry: [
|
||||||
|
'type' => 'Polygon',
|
||||||
|
'coordinates' => [[[0, 0], [500, 0], [1, 1], [0, 0]]],
|
||||||
|
], nuova: true)
|
||||||
|
->assertDispatched('avviso');
|
||||||
|
|
||||||
|
$this->assertSame(0, TerritorioMappa::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_territorio_si_crea_dall_editor_con_tutte_le_proprieta(): void
|
||||||
{
|
{
|
||||||
$zona = Zona::create(['nome' => 'Nord', 'attivo' => true]);
|
$zona = Zona::create(['nome' => 'Nord', 'attivo' => true]);
|
||||||
$tipologia = Tipologia::create(['nome' => 'Residenziale', 'attivo' => true]);
|
$tipologia = Tipologia::create(['nome' => 'Residenziale', 'attivo' => true]);
|
||||||
|
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
Livewire::actingAs($this->admin())
|
Livewire::actingAs($this->admin())
|
||||||
->test(PiantineEditor::class)
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
|
||||||
->set('territorioScelto', 'nuovo')
|
->set('territorioScelto', 'nuovo')
|
||||||
->set('numero', '42')
|
->set('numero', '42')
|
||||||
->set('zonaId', (string) $zona->id)
|
->set('zonaId', (string) $zona->id)
|
||||||
->set('tipologiaId', (string) $tipologia->id)
|
->set('tipologiaId', (string) $tipologia->id)
|
||||||
->set('confini', "Nord: via Roma\nSud: via Milano")
|
->set('confini', "Nord: via Roma\nSud: via Milano")
|
||||||
->call('salva')
|
->call('creaTerritorio')
|
||||||
->assertHasNoErrors();
|
->assertHasNoErrors()
|
||||||
|
->assertSet('territorioScelto', fn ($v) => $v !== 'nuovo' && $v !== '');
|
||||||
|
|
||||||
$territorio = Territorio::where('numero', '42')->sole();
|
$territorio = Territorio::where('numero', '42')->sole();
|
||||||
$this->assertTrue((bool) $territorio->attivo);
|
$this->assertTrue((bool) $territorio->attivo);
|
||||||
$this->assertSame($zona->id, $territorio->zona_id);
|
$this->assertSame($zona->id, $territorio->zona_id);
|
||||||
$this->assertSame($tipologia->id, $territorio->tipologia_id);
|
$this->assertSame($tipologia->id, $territorio->tipologia_id);
|
||||||
$this->assertStringContainsString('via Roma', $territorio->confini);
|
$this->assertStringContainsString('via Roma', $territorio->confini);
|
||||||
$this->assertSame($territorio->id, TerritorioMappa::sole()->territorio_id);
|
$this->assertSame($territorio->id, $mappa->fresh()->territorio_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_la_scheda_aggiorna_un_territorio_gia_esistente(): void
|
public function test_in_creazione_i_campi_non_salvano_niente_prima_del_pulsante(): void
|
||||||
{
|
{
|
||||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
Livewire::actingAs($this->admin())
|
Livewire::actingAs($this->admin())
|
||||||
->test(PiantineEditor::class)
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
->set('territorioScelto', 'nuovo')
|
||||||
|
->set('numero', '4');
|
||||||
|
|
||||||
|
$this->assertSame(0, Territorio::count());
|
||||||
|
$this->assertNull($mappa->fresh()->territorio_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_numero_gia_in_uso_non_crea_un_secondo_territorio(): void
|
||||||
|
{
|
||||||
|
Territorio::create(['numero' => '42', 'attivo' => true]);
|
||||||
|
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
|
->set('territorioScelto', 'nuovo')
|
||||||
|
->set('numero', '42')
|
||||||
|
->call('creaTerritorio')
|
||||||
|
->assertHasErrors('numero');
|
||||||
|
|
||||||
|
$this->assertSame(1, Territorio::where('numero', '42')->count());
|
||||||
|
$this->assertNull($mappa->fresh()->territorio_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scegliere_un_territorio_collega_subito_la_piantina(): void
|
||||||
|
{
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true, 'confini' => 'Nord: via Verdi']);
|
||||||
|
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
->set('territorioScelto', (string) $territorio->id)
|
->set('territorioScelto', (string) $territorio->id)
|
||||||
->set('numero', '12')
|
->assertHasNoErrors()
|
||||||
->set('confini', 'Nord: via Verdi')
|
->assertSet('numero', '12')
|
||||||
->call('salva')
|
->assertSet('confini', 'Nord: via Verdi');
|
||||||
|
|
||||||
|
$this->assertSame($territorio->id, $mappa->fresh()->territorio_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La tendina leggeva `attivo` senza averlo caricato: tutti "disattivati". */
|
||||||
|
public function test_la_tendina_segna_disattivato_solo_chi_lo_e(): void
|
||||||
|
{
|
||||||
|
Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
$mappa = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
|
->assertSee('Territorio 12')
|
||||||
|
->assertDontSee('(disattivato)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_i_dati_del_territorio_si_salvano_subito_campo_per_campo(): void
|
||||||
|
{
|
||||||
|
$zona = Zona::create(['nome' => 'Sud', 'attivo' => true]);
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
|
->set('confini', 'Est: via Garibaldi')
|
||||||
|
->set('zonaId', (string) $zona->id)
|
||||||
->assertHasNoErrors();
|
->assertHasNoErrors();
|
||||||
|
|
||||||
$this->assertSame('Nord: via Verdi', $territorio->fresh()->confini);
|
$territorio->refresh();
|
||||||
$this->assertSame(1, Territorio::count());
|
$this->assertSame('Est: via Garibaldi', $territorio->confini);
|
||||||
|
$this->assertSame($zona->id, $territorio->zona_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_campo_non_valido_non_trascina_gli_altri(): void
|
||||||
|
{
|
||||||
|
Territorio::create(['numero' => '7', 'attivo' => true]);
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
|
->set('numero', '7')
|
||||||
|
->assertHasErrors('numero')
|
||||||
|
->set('confini', 'Ovest: via Dante');
|
||||||
|
|
||||||
|
$territorio->refresh();
|
||||||
|
$this->assertSame('12', $territorio->numero);
|
||||||
|
$this->assertSame('Ovest: via Dante', $territorio->confini);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_si_ruba_il_territorio_a_un_altra_piantina(): void
|
||||||
|
{
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
$seconda = TerritorioMappa::create(['geojson' => json_encode(self::ALTRA_GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $seconda->id])
|
||||||
|
->set('territorioScelto', (string) $territorio->id)
|
||||||
|
->assertHasErrors('territorioScelto')
|
||||||
|
->assertSet('territorioScelto', '');
|
||||||
|
|
||||||
|
$this->assertNull($seconda->fresh()->territorio_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scollegare_lascia_un_etichetta_riconoscibile(): void
|
||||||
|
{
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
|
->set('territorioScelto', '');
|
||||||
|
|
||||||
|
$mappa->refresh();
|
||||||
|
$this->assertNull($mappa->territorio_id);
|
||||||
|
$this->assertSame('Ex territorio 12', $mappa->nome);
|
||||||
|
$this->assertNotNull(Territorio::find($territorio->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_territorio_nel_cestino_resta_collegato(): void
|
||||||
|
{
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
$territorio->delete();
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
|
->assertSet('territorioScelto', (string) $territorio->id)
|
||||||
|
->set('nome', 'Prova');
|
||||||
|
|
||||||
|
$mappa->refresh();
|
||||||
|
$this->assertSame($territorio->id, $mappa->territorio_id);
|
||||||
|
$this->assertNotNull($mappa->motivoNonPubblicabile());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_zona_e_tipologia_si_creano_dall_editor_e_restano_selezionate(): void
|
public function test_zona_e_tipologia_si_creano_dall_editor_e_restano_selezionate(): void
|
||||||
{
|
{
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
$mappa = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
$componente = Livewire::actingAs($this->admin())
|
$componente = Livewire::actingAs($this->admin())
|
||||||
->test(PiantineEditor::class)
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
->set('territorioScelto', 'nuovo')
|
|
||||||
->set('nuovaZona', 'Litorale')
|
->set('nuovaZona', 'Litorale')
|
||||||
->call('creaZona')
|
->call('creaZona')
|
||||||
->assertHasNoErrors()
|
->assertHasNoErrors()
|
||||||
@@ -126,6 +308,10 @@ class PiantineEditorTest extends TestCase
|
|||||||
|
|
||||||
$componente->assertSet('zonaId', (string) $zona->id);
|
$componente->assertSet('zonaId', (string) $zona->id);
|
||||||
$componente->assertSet('tipologiaId', (string) $tipologia->id);
|
$componente->assertSet('tipologiaId', (string) $tipologia->id);
|
||||||
|
|
||||||
|
$territorio->refresh();
|
||||||
|
$this->assertSame($zona->id, $territorio->zona_id);
|
||||||
|
$this->assertSame($tipologia->id, $territorio->tipologia_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_una_zona_gia_esistente_non_viene_duplicata(): void
|
public function test_una_zona_gia_esistente_non_viene_duplicata(): void
|
||||||
@@ -141,70 +327,80 @@ class PiantineEditorTest extends TestCase
|
|||||||
$this->assertSame(1, Zona::where('nome', 'Nord')->count());
|
$this->assertSame(1, Zona::where('nome', 'Nord')->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_un_numero_gia_in_uso_non_crea_un_secondo_territorio(): void
|
public function test_pubblica_apre_la_finestra_solo_se_si_puo(): void
|
||||||
{
|
{
|
||||||
Territorio::create(['numero' => '42', 'attivo' => true]);
|
$scollegata = TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
|
||||||
Livewire::actingAs($this->admin())
|
Livewire::actingAs($this->admin())
|
||||||
->test(PiantineEditor::class)
|
->test(PiantineEditor::class, ['mappa' => $scollegata->id])
|
||||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
->call('pubblica')
|
||||||
->set('territorioScelto', 'nuovo')
|
->assertNotDispatched('pubblica-piantina')
|
||||||
->set('numero', '42')
|
->assertDispatched('avviso');
|
||||||
->call('salva')
|
|
||||||
->assertHasErrors('numero');
|
|
||||||
|
|
||||||
$this->assertSame(1, Territorio::where('numero', '42')->count());
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
$this->assertSame(0, TerritorioMappa::count());
|
$collegata = TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => json_encode(self::ALTRA_GEOMETRIA)]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $collegata->id])
|
||||||
|
->call('pubblica')
|
||||||
|
->assertDispatchedTo(Pubblicazione::class, 'pubblica-piantina', mappaId: $collegata->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_ridisegnare_sostituisce_il_poligono_senza_duplicare_la_piantina(): void
|
public function test_scartare_le_modifiche_riporta_il_confine_pubblicato(): void
|
||||||
{
|
{
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
$mappa = TerritorioMappa::create([
|
$mappa = TerritorioMappa::create([
|
||||||
'nome' => 'Zona porto',
|
'territorio_id' => $territorio->id,
|
||||||
'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
|
'geojson' => json_encode(self::ALTRA_GEOMETRIA),
|
||||||
|
'geojson_pubblicato' => json_encode(self::GEOMETRIA),
|
||||||
|
'pubblicata_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::actingAs($this->admin())
|
Livewire::actingAs($this->admin())
|
||||||
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
->call('scartaModifiche');
|
||||||
->call('salva');
|
|
||||||
|
|
||||||
$this->assertSame(1, TerritorioMappa::count());
|
|
||||||
$this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria());
|
$this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_una_geometria_non_valida_viene_ignorata(): void
|
public function test_eliminare_toglie_disegno_e_immagine_ma_non_il_territorio(): void
|
||||||
{
|
|
||||||
Livewire::actingAs($this->admin())
|
|
||||||
->test(PiantineEditor::class)
|
|
||||||
->dispatch('poligono-disegnato', geometry: ['type' => 'Point', 'coordinates' => [1, 2]])
|
|
||||||
->call('salva');
|
|
||||||
|
|
||||||
$this->assertSame(0, TerritorioMappa::count());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_non_si_ruba_il_territorio_a_un_altra_piantina(): void
|
|
||||||
{
|
{
|
||||||
|
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'x');
|
||||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
TerritorioMappa::create([
|
$mappa = TerritorioMappa::create([
|
||||||
'territorio_id' => $territorio->id,
|
'territorio_id' => $territorio->id,
|
||||||
'geojson' => json_encode(self::GEOMETRIA),
|
'geojson' => json_encode(self::GEOMETRIA),
|
||||||
|
'image_path' => 'territori-mappe/piantina-1.jpg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::actingAs($this->admin())
|
Livewire::actingAs($this->admin())
|
||||||
->test(PiantineEditor::class)
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
->call('elimina')
|
||||||
->set('territorioScelto', (string) $territorio->id)
|
->assertSet('mappaId', null)
|
||||||
->set('numero', $territorio->numero)
|
->assertDispatched('piantina-rimossa', mappaId: $mappa->id);
|
||||||
->call('salva');
|
|
||||||
|
|
||||||
// La seconda piantina non deve essere stata creata.
|
$this->assertSame(0, TerritorioMappa::count());
|
||||||
$this->assertSame(1, TerritorioMappa::count());
|
$this->assertNotNull(Territorio::find($territorio->id));
|
||||||
|
Storage::disk('public')->assertMissing('territori-mappe/piantina-1.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dati_mappa_esclude_le_geometrie_illeggibili(): void
|
||||||
|
{
|
||||||
|
TerritorioMappa::create(['geojson' => json_encode(self::GEOMETRIA)]);
|
||||||
|
TerritorioMappa::create(['geojson' => 'non è json']);
|
||||||
|
|
||||||
|
$dati = Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineEditor::class)
|
||||||
|
->instance()
|
||||||
|
->datiMappa(app(\App\Services\Piantine\PiantineArea::class));
|
||||||
|
|
||||||
|
$this->assertCount(1, $dati['piantine']);
|
||||||
|
$this->assertSame(TerritorioMappa::STATO_BOZZA, $dati['piantine'][0]['stato']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function admin(): User
|
private function admin(): User
|
||||||
{
|
{
|
||||||
Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web']));
|
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
|
||||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
|
||||||
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use Tests\TestCase;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* La pagina di gestione delle piantine: accesso riservato, area della città,
|
* La pagina di gestione delle piantine: accesso riservato, area della città,
|
||||||
* e il legame — che si può fare e disfare — fra una piantina e un territorio.
|
* stato di ogni piantina con ricerca e filtri, pubblicazione e storico.
|
||||||
*/
|
*/
|
||||||
class PiantineManagerTest extends TestCase
|
class PiantineManagerTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -77,50 +77,6 @@ XML;
|
|||||||
->assertHasErrors('kmlFile');
|
->assertHasErrors('kmlFile');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_una_piantina_si_collega_a_un_territorio(): void
|
|
||||||
{
|
|
||||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
|
||||||
$mappa = $this->piantina(['nome' => 'Zona porto']);
|
|
||||||
|
|
||||||
Livewire::actingAs($this->admin())
|
|
||||||
->test(PiantineManager::class)
|
|
||||||
->call('apriCollegamento', $mappa->id)
|
|
||||||
->set('territorioDaCollegare', $territorio->id)
|
|
||||||
->call('collega', $mappa->id);
|
|
||||||
|
|
||||||
$this->assertSame($territorio->id, $mappa->fresh()->territorio_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_un_territorio_che_ha_gia_una_piantina_non_ne_accetta_una_seconda(): void
|
|
||||||
{
|
|
||||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
|
||||||
$this->piantina(['territorio_id' => $territorio->id]);
|
|
||||||
$seconda = $this->piantina(['nome' => 'Doppione']);
|
|
||||||
|
|
||||||
Livewire::actingAs($this->admin())
|
|
||||||
->test(PiantineManager::class)
|
|
||||||
->call('apriCollegamento', $seconda->id)
|
|
||||||
->set('territorioDaCollegare', $territorio->id)
|
|
||||||
->call('collega', $seconda->id);
|
|
||||||
|
|
||||||
$this->assertNull($seconda->fresh()->territorio_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_scollegare_conserva_la_piantina_e_le_da_un_nome(): void
|
|
||||||
{
|
|
||||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
|
||||||
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
|
|
||||||
|
|
||||||
Livewire::actingAs($this->admin())
|
|
||||||
->test(PiantineManager::class)
|
|
||||||
->call('scollega', $mappa->id);
|
|
||||||
|
|
||||||
$mappa->refresh();
|
|
||||||
$this->assertNull($mappa->territorio_id);
|
|
||||||
$this->assertSame('Ex territorio 12', $mappa->nome);
|
|
||||||
$this->assertDatabaseCount('territorio_mappe', 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* La cascata di prima si portava via il disegno insieme al territorio — e
|
* La cascata di prima si portava via il disegno insieme al territorio — e
|
||||||
* l'import XML svuota `territori` in blocco a ogni ripristino.
|
* l'import XML svuota `territori` in blocco a ogni ripristino.
|
||||||
@@ -136,52 +92,128 @@ XML;
|
|||||||
$this->assertNull($mappa->fresh()->territorio_id);
|
$this->assertNull($mappa->fresh()->territorio_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_senza_territorio_il_pdf_non_si_genera(): void
|
public function test_l_elenco_mostra_lo_stato_di_ogni_piantina(): void
|
||||||
{
|
{
|
||||||
$mappa = $this->piantina(['nome' => 'Zona porto']);
|
[$territorio, $pubblicata] = $this->pubblicata('7');
|
||||||
|
$this->piantina(['nome' => 'Zona porto']);
|
||||||
|
|
||||||
Livewire::actingAs($this->admin())
|
Livewire::actingAs($this->admin())
|
||||||
->test(PiantineManager::class)
|
->test(PiantineManager::class)
|
||||||
->call('generaPdf', $mappa->id);
|
->assertSee('Terr. 7')
|
||||||
|
->assertSee('Zona porto')
|
||||||
$this->assertDatabaseCount('territorio_pdf_versioni', 0);
|
->assertSee('Pubblicata')
|
||||||
|
->assertSee('Bozza')
|
||||||
|
->assertSee('Scollegata');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function test_i_filtri_separano_bozze_pubblicate_e_da_pubblicare(): void
|
||||||
* L'anteprima serve a guardare la cartolina PRIMA di mandarla in vigore:
|
|
||||||
* se toccasse pdf_path o lo storico, non sarebbe un'anteprima.
|
|
||||||
*/
|
|
||||||
public function test_l_anteprima_mostra_il_pdf_senza_sostituire_niente(): void
|
|
||||||
{
|
{
|
||||||
$territorio = Territorio::create(['numero' => '7', 'attivo' => true, 'confini' => 'Nord: via Roma']);
|
[, $pubblicata] = $this->pubblicata('7');
|
||||||
$mappa = $this->piantina([
|
[$t8, $modificata] = $this->pubblicata('8');
|
||||||
|
$t8->update(['confini' => 'cambiati dopo la pubblicazione']);
|
||||||
|
$this->piantina(['nome' => 'Zona porto']);
|
||||||
|
|
||||||
|
$componente = Livewire::actingAs($this->admin())->test(PiantineManager::class);
|
||||||
|
|
||||||
|
$componente->set('filtro', 'pubblicate')
|
||||||
|
->assertViewHas('elenco', fn ($e) => $e->pluck('id')->all() === [$pubblicata->id]);
|
||||||
|
|
||||||
|
$componente->set('filtro', 'da-pubblicare')
|
||||||
|
->assertViewHas('elenco', fn ($e) => $e->pluck('id')->all() === [$modificata->id]);
|
||||||
|
|
||||||
|
$componente->set('filtro', 'scollegate')
|
||||||
|
->assertViewHas('elenco', fn ($e) => $e->count() === 1 && $e->first()->nome === 'Zona porto');
|
||||||
|
|
||||||
|
$componente->set('filtro', 'inventato')
|
||||||
|
->assertSet('filtro', 'tutte')
|
||||||
|
->assertViewHas('elenco', fn ($e) => $e->count() === 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_la_ricerca_trova_numero_nome_e_zona(): void
|
||||||
|
{
|
||||||
|
$zona = \App\Models\Zona::create(['nome' => 'Litorale', 'attivo' => true]);
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true, 'zona_id' => $zona->id]);
|
||||||
|
$this->piantina(['territorio_id' => $territorio->id]);
|
||||||
|
$this->piantina(['nome' => 'Zona porto']);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineManager::class)
|
||||||
|
->set('cerca', 'litor')
|
||||||
|
->assertViewHas('elenco', fn ($e) => $e->count() === 1 && $e->first()->territorio_id === $territorio->id)
|
||||||
|
->set('cerca', 'PORTO')
|
||||||
|
->assertViewHas('elenco', fn ($e) => $e->count() === 1 && $e->first()->nome === 'Zona porto');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pubblica_apre_la_finestra_condivisa_solo_se_si_puo(): void
|
||||||
|
{
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
$collegata = $this->piantina(['territorio_id' => $territorio->id]);
|
||||||
|
$scollegata = $this->piantina(['nome' => 'Zona porto']);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineManager::class)
|
||||||
|
->call('pubblica', $collegata->id)
|
||||||
|
->assertDispatchedTo(\App\Livewire\Piantine\Pubblicazione::class, 'pubblica-piantina', mappaId: $collegata->id);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineManager::class)
|
||||||
|
->call('pubblica', $scollegata->id)
|
||||||
|
->assertNotDispatched('pubblica-piantina')
|
||||||
|
->assertDispatched('avviso');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_eliminare_toglie_la_piantina_ma_non_il_territorio(): void
|
||||||
|
{
|
||||||
|
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin())
|
||||||
|
->test(PiantineManager::class)
|
||||||
|
->call('elimina', $mappa->id)
|
||||||
|
->assertDispatched('avviso');
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('territorio_mappe', 0);
|
||||||
|
$this->assertNotNull($territorio->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_ripristinare_una_versione_riporta_la_piantina_da_pubblicare(): void
|
||||||
|
{
|
||||||
|
[$territorio, $mappa] = $this->pubblicata('7');
|
||||||
|
|
||||||
|
Storage::disk('public')->put('territori-pdf-versioni/vecchia.pdf', '%PDF-vecchia');
|
||||||
|
$versione = \App\Models\TerritorioPdfVersione::create([
|
||||||
'territorio_id' => $territorio->id,
|
'territorio_id' => $territorio->id,
|
||||||
'image_path' => 'territori-mappe/piantina-prova.png',
|
'pdf_path' => 'territori-pdf-versioni/vecchia.pdf',
|
||||||
|
'origine' => 'piantine',
|
||||||
|
'sostituito_il' => now()->subDay(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$immagine = imagecreatetruecolor(80, 50);
|
Livewire::actingAs($this->admin())
|
||||||
ob_start();
|
->test(PiantineManager::class)
|
||||||
imagepng($immagine);
|
->call('ripristinaVersione', $versione->id)
|
||||||
Storage::disk('public')->put($mappa->image_path, ob_get_clean());
|
->assertDispatched('avviso');
|
||||||
imagedestroy($immagine);
|
|
||||||
|
|
||||||
$risposta = $this->actingAs($this->admin())->get(route('piantine.anteprima', $mappa));
|
$this->assertStringStartsWith('territori-pdf/ripristino-', $territorio->fresh()->pdf_path);
|
||||||
|
$this->assertSame(TerritorioMappa::STATO_MODIFICATA, $mappa->fresh()->stato());
|
||||||
$risposta->assertOk();
|
|
||||||
$risposta->assertHeader('Content-Type', 'application/pdf');
|
|
||||||
$this->assertStringStartsWith('%PDF', $risposta->getContent());
|
|
||||||
|
|
||||||
$this->assertNull($territorio->fresh()->pdf_path);
|
|
||||||
$this->assertSame(0, \App\Models\TerritorioPdfVersione::count());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_l_anteprima_di_una_piantina_scollegata_non_esiste(): void
|
/** Una piantina collegata e segnata come pubblicata, senza passare dal renderer. */
|
||||||
|
private function pubblicata(string $numero): array
|
||||||
{
|
{
|
||||||
$mappa = $this->piantina(['nome' => 'Scollegata']);
|
$territorio = Territorio::create(['numero' => $numero, 'attivo' => true]);
|
||||||
|
$pdf = 'territori-pdf/piantina-' . $territorio->id . '-20261001-120000.pdf';
|
||||||
|
Storage::disk('public')->put($pdf, '%PDF-piantina');
|
||||||
|
$territorio->update(['pdf_path' => $pdf]);
|
||||||
|
|
||||||
$this->actingAs($this->admin())
|
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
|
||||||
->get(route('piantine.anteprima', $mappa))
|
$mappa->load('territorio.zona');
|
||||||
->assertNotFound();
|
$mappa->update([
|
||||||
|
'geojson_pubblicato' => $mappa->geojson,
|
||||||
|
'impronta_pubblicata' => $mappa->impronta(),
|
||||||
|
'pubblicata_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$territorio, $mappa->fresh()];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function piantina(array $valori = []): TerritorioMappa
|
private function piantina(array $valori = []): TerritorioMappa
|
||||||
@@ -193,7 +225,7 @@ XML;
|
|||||||
|
|
||||||
private function admin(): User
|
private function admin(): User
|
||||||
{
|
{
|
||||||
Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web']));
|
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
|
||||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||||
|
|
||||||
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
||||||
|
|||||||
286
tests/Feature/PubblicazionePiantinaTest.php
Normal file
286
tests/Feature/PubblicazionePiantinaTest.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user