Editor piantine: bozza automatica e pubblicazione con anteprima

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

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

View File

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