Files
termanager2/app/Livewire/Piantine/PiantineEditor.php
Francesco Picone 9c37c1ad7b Piantine staccate dai territori, editor a schermo intero, restyle
Le piantine non dipendono piu' da un territorio esistente: territorio_id e'
nullable, la chiave esterna e' nullOnDelete invece di cascadeOnDelete, e `nome`
fa da etichetta finche' il collegamento manca. Si disegna guardando le strade e
si decide dopo di chi e' il disegno; se il territorio non c'e', lo si crea
dall'editor con numero, zona, tipologia e confini — zona e tipologia comprese,
create sul posto quando mancano.

Il cambio di cascata chiude anche una perdita di dati silenziosa: ogni import
XML svuota `territori` in blocco e finora si portava via tutte le piantine.

Il disegno passa su /piantine/editor, a schermo intero con telaio proprio: su
una mappa larga un terzo di schermo un confine si sbaglia, e finisce stampato.
/piantine resta la gestione — area, generazione, collegamenti, storico.

Cartolina S-12 reimpaginata: pagina 1 il modulo con la Localita' (che ora e' il
nome della zona, non un campo duplicato) e i confini scritti nello spazio
centrale, con il corpo del testo che si adatta alla lunghezza; pagina 2 la
piantina sola al 98%. Aggiunta l'anteprima, che compone il PDF al volo senza
toccare pdf_path ne' lo storico.

Immagine: JPEG invece di PNG (0,9 MB contro 5 per territorio), composta alle
tessere dello zoom piu' alto e ridotta con Lanczos, nelle proporzioni della
pagina cosi' riempie il foglio invece di lasciare fasce bianche. Il tratto del
confine e' espresso in pixel dell'immagine finale: si vede il confine e si legge
la via che ci passa sotto. L'attribuzione OSM si dimensiona con l'immagine,
come la ODbL richiede.

Rimossa la rigenerazione massiva: job, stato in cache e interfaccia.

Sistema visivo ripassato su tutta l'applicazione: le scale gray e indigo di
Tailwind sono ridefinite nei token, cosi' le 37 viste si riallineano insieme
invece di essere riscritte una per una. Grigi caldi, un accento solo con quattro
significati, carattere servito dall'applicazione e non da Google Fonts, via il
gradiente della testata. Aggiunto il link "salta al contenuto", che mancava.

Corretto un guasto d'ambiente che bloccava TUTTI gli upload in sviluppo: dalla
1.26 il fastcgi_params di nginx passa HTTP_HOST come $host, che perde la porta,
e ogni URL firmato smetteva di validare — l'endpoint di upload di Livewire
rispondeva 401.

294 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:41:22 +02:00

402 lines
14 KiB
PHP

<?php
namespace App\Livewire\Piantine;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\Tipologia;
use App\Models\Zona;
use App\Services\Piantine\PiantinaRenderer;
use App\Services\Piantine\PiantineArea;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Editor delle piantine a schermo intero (solo amministratore).
*
* Una pagina sola con la mappa grande quanto la finestra e i comandi in una
* barra sottile in alto: tagliare un confine su una cartografia larga un terzo
* di schermo significa sbagliarlo, e questo disegno finisce stampato su un
* modulo che poi gira di mano in mano.
*
* Il disegno viene prima del numero. Si taglia l'area guardando le strade, e
* solo dopo si compila la scheda del territorio — che può anche non esistere
* ancora: numero, zona, tipologia e confini si scrivono da qui, e se la zona o
* la tipologia mancano si creano sul posto, senza abbandonare il disegno.
*/
#[Layout('components.layouts.editor')]
class PiantineEditor extends Component
{
/** Piantina aperta, o null quando se ne sta disegnando una nuova. */
public ?int $mappaId = null;
public string $nome = '';
/** Geometria appena disegnata, non ancora salvata. */
public ?string $geometriaInAttesa = null;
public bool $pannelloAperto = false;
// ─── Scheda del territorio ──────────────────────────────────────────────
/** '' = nessun territorio, 'nuovo' = da creare, altrimenti l'id. */
public string $territorioScelto = '';
public string $numero = '';
public string $zonaId = '';
public string $tipologiaId = '';
public string $confini = '';
/** Campi di creazione al volo, aperti solo quando servono. */
public string $nuovaZona = '';
public string $nuovaTipologia = '';
public function mount(?int $mappa = null): void
{
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
$this->apri($mappa);
}
}
// ─── Selezione ──────────────────────────────────────────────────────────
#[On('piantina-selezionata-mappa')]
public function apri($mappaId): void
{
$mappa = TerritorioMappa::with('territorio')->find(is_numeric($mappaId) ? (int) $mappaId : null);
if (! $mappa) {
return;
}
$this->mappaId = $mappa->id;
$this->nome = (string) $mappa->nome;
$this->geometriaInAttesa = null;
$this->pannelloAperto = true;
$this->caricaScheda($mappa->territorio);
$this->aggiornaMappaJs(inquadra: true);
}
public function nuova(): void
{
$this->reset([
'mappaId', 'nome', 'geometriaInAttesa',
'territorioScelto', 'numero', 'zonaId', 'tipologiaId', 'confini',
'nuovaZona', 'nuovaTipologia',
]);
$this->resetValidation();
$this->pannelloAperto = true;
$this->aggiornaMappaJs();
}
public function chiudiPannello(): void
{
$this->pannelloAperto = false;
}
/** Riempie i campi della scheda dal territorio collegato (o li svuota). */
private function caricaScheda(?Territorio $territorio): void
{
$this->territorioScelto = $territorio ? (string) $territorio->id : '';
$this->numero = (string) ($territorio->numero ?? '');
$this->zonaId = (string) ($territorio->zona_id ?? '');
$this->tipologiaId = (string) ($territorio->tipologia_id ?? '');
$this->confini = (string) ($territorio->confini ?? '');
}
/** Cambiare territorio nella tendina ricarica la sua scheda. */
public function updatedTerritorioScelto(string $valore): void
{
$this->resetValidation();
if ($valore === 'nuovo') {
$this->numero = '';
$this->zonaId = '';
$this->tipologiaId = '';
$this->confini = '';
return;
}
$this->caricaScheda($valore === '' ? null : Territorio::find($valore));
}
// ─── Disegno ────────────────────────────────────────────────────────────
#[On('poligono-disegnato')]
public function poligonoDisegnato($geometry): void
{
if (! is_array($geometry)
|| ! in_array($geometry['type'] ?? null, ['Polygon', 'MultiPolygon'], true)
|| ! is_array($geometry['coordinates'] ?? null)) {
return;
}
$this->geometriaInAttesa = json_encode($geometry);
$this->pannelloAperto = true;
}
public function scartaDisegno(): void
{
$this->geometriaInAttesa = null;
$this->aggiornaMappaJs();
}
// ─── Zona e tipologia create sul posto ──────────────────────────────────
public function creaZona()
{
$dati = $this->validate(
['nuovaZona' => ['required', 'string', 'max:100', Rule::unique('zone', 'nome')]],
[
'nuovaZona.required' => 'Scrivi il nome della zona.',
'nuovaZona.unique' => 'Questa zona esiste già: sceglila dall\'elenco.',
],
);
$zona = Zona::create(['nome' => trim($dati['nuovaZona']), 'attivo' => true]);
$this->zonaId = (string) $zona->id;
$this->nuovaZona = '';
session()->flash('success', "Zona «{$zona->nome}» creata e selezionata.");
}
public function creaTipologia()
{
$dati = $this->validate(
['nuovaTipologia' => ['required', 'string', 'max:100', Rule::unique('tipologie', 'nome')]],
[
'nuovaTipologia.required' => 'Scrivi il nome della tipologia.',
'nuovaTipologia.unique' => 'Questa tipologia esiste già: sceglila dall\'elenco.',
],
);
$tipologia = Tipologia::create(['nome' => trim($dati['nuovaTipologia']), 'attivo' => true]);
$this->tipologiaId = (string) $tipologia->id;
$this->nuovaTipologia = '';
session()->flash('success', "Tipologia «{$tipologia->nome}» creata e selezionata.");
}
// ─── Salvataggio ────────────────────────────────────────────────────────
public function salva()
{
$mappa = $this->mappaId ? TerritorioMappa::find($this->mappaId) : null;
if (! $mappa && ! $this->geometriaInAttesa) {
session()->flash('error', 'Disegna il poligono prima di salvare.');
return;
}
$territorio = $this->salvaScheda($mappa);
// salvaScheda() torna false quando la validazione della scheda non
// passa: in quel caso il messaggio è già a schermo e non si tocca nulla.
if ($territorio === false) {
return;
}
$valori = [
'territorio_id' => $territorio?->id,
'nome' => trim($this->nome) !== '' ? trim($this->nome) : null,
];
if ($this->geometriaInAttesa) {
$valori['geojson'] = $this->geometriaInAttesa;
}
if ($mappa) {
$mappa->update($valori);
} else {
$mappa = TerritorioMappa::create($valori);
$this->mappaId = $mappa->id;
}
$this->geometriaInAttesa = null;
activity()->causedBy(auth()->user())->log('piantina_salvata');
session()->flash('success', $mappa->fresh()->load('territorio')->etichetta() . ' salvata.');
$this->aggiornaMappaJs();
}
/**
* Crea o aggiorna il territorio della scheda.
*
* @return Territorio|null|false Il territorio collegato, null se la
* piantina resta scollegata, false se la
* scheda non è valida.
*/
private function salvaScheda(?TerritorioMappa $mappa): Territorio|null|false
{
if ($this->territorioScelto === '') {
return null;
}
$esistente = $this->territorioScelto === 'nuovo'
? null
: Territorio::find($this->territorioScelto);
if ($this->territorioScelto !== 'nuovo' && ! $esistente) {
session()->flash('error', 'Il territorio scelto non esiste più.');
return false;
}
try {
$this->validate(
[
'numero' => [
'required', 'string', 'max:20',
Rule::unique('territori', 'numero')->ignore($esistente?->id),
],
'zonaId' => ['nullable', Rule::exists('zone', 'id')],
'tipologiaId' => ['nullable', Rule::exists('tipologie', 'id')],
'confini' => ['nullable', 'string', 'max:5000'],
],
[
'numero.required' => 'Il territorio ha bisogno di un numero.',
'numero.unique' => 'Esiste già un territorio con questo numero.',
],
['numero' => 'numero'],
);
} catch (\Illuminate\Validation\ValidationException $e) {
$this->pannelloAperto = true;
throw $e;
}
// Un territorio ha una piantina sola.
if ($esistente) {
$occupato = TerritorioMappa::where('territorio_id', $esistente->id)
->when($mappa, fn ($q) => $q->where('id', '!=', $mappa->id))
->exists();
if ($occupato) {
session()->flash('error', "Il territorio {$esistente->numero} ha già una piantina: scollegala prima di assegnargli questa.");
return false;
}
}
$valori = [
'numero' => trim($this->numero),
'zona_id' => $this->zonaId !== '' ? (int) $this->zonaId : null,
'tipologia_id' => $this->tipologiaId !== '' ? (int) $this->tipologiaId : null,
'confini' => trim($this->confini) !== '' ? trim($this->confini) : null,
];
if ($esistente) {
$esistente->update($valori);
return $esistente;
}
$territorio = Territorio::create($valori + ['attivo' => true]);
$this->territorioScelto = (string) $territorio->id;
activity()->causedBy(auth()->user())->performedOn($territorio)->log('territorio_creato_da_piantine');
return $territorio;
}
// ─── Immagine ed eliminazione ───────────────────────────────────────────
public function generaImmagine(PiantinaRenderer $renderer)
{
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
if (! $mappa) {
session()->flash('error', 'Salva la piantina prima di generarne l\'immagine.');
return;
}
try {
$renderer->render($mappa);
} catch (\Throwable $e) {
session()->flash('error', $e->getMessage());
return;
}
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
}
public function elimina()
{
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
if (! $mappa) {
return;
}
$etichetta = $mappa->etichetta();
if ($mappa->image_path) {
Storage::disk('public')->delete($mappa->image_path);
}
$mappa->delete();
$this->nuova();
session()->flash('success', "{$etichetta} eliminata.");
}
// ─── Dati per la mappa ──────────────────────────────────────────────────
public function datiMappa(?PiantineArea $area = null): array
{
$area ??= app(PiantineArea::class);
$piantine = TerritorioMappa::with('territorio')->get()
->filter(fn ($m) => $m->geometria() !== null)
->map(fn ($m) => [
'mappaId' => $m->id,
'etichetta' => $m->etichetta(),
'collegata' => $m->collegata(),
'geometry' => $m->geometria(),
])
->values()
->all();
return [
'area' => $area->area(),
'piantine' => $piantine,
'selezionata' => $this->mappaId,
];
}
private function aggiornaMappaJs(bool $inquadra = false): void
{
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa(), inquadra: $inquadra);
}
public function render(PiantineArea $area)
{
$mappa = $this->mappaId ? TerritorioMappa::with('territorio')->find($this->mappaId) : null;
return view('livewire.piantine.piantine-editor', [
'mappa' => $mappa,
'areaCaricata' => $area->area() !== null,
'datiMappaIniziali' => $this->datiMappa($area),
'territori' => Territorio::attivi()
->orderByRaw('LENGTH(numero), numero')
->get(['id', 'numero']),
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
'title' => 'Editor delle piantine',
]);
}
}