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

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