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>
This commit is contained in:
@@ -2,27 +2,23 @@
|
||||
|
||||
namespace App\Livewire\Piantine;
|
||||
|
||||
use App\Jobs\RigeneraPiantine;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\Piantine\PiantinaRenderer;
|
||||
use App\Services\Piantine\PiantineArea;
|
||||
use App\Services\Piantine\PiantineOperationState;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
/**
|
||||
* Sotto-app "Piantine" (solo amministratore).
|
||||
* Pagina di gestione delle piantine (solo amministratore).
|
||||
*
|
||||
* Dal KML dell'area città si disegnano i poligoni dei territori su una mappa
|
||||
* OpenStreetMap; da ogni poligono si genera l'immagine ad alta definizione
|
||||
* (container map-renderer) e, a scelta, il PDF sul modulo S-12 che sostituisce
|
||||
* quello del territorio, con storico versioni.
|
||||
* 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.
|
||||
*/
|
||||
class PiantineManager extends Component
|
||||
{
|
||||
@@ -30,21 +26,10 @@ class PiantineManager extends Component
|
||||
|
||||
public $kmlFile = null;
|
||||
|
||||
public string $localita = '';
|
||||
/** Piantina di cui è aperto il pannello "collega a un territorio". */
|
||||
public ?int $collegamentoAperto = null;
|
||||
|
||||
public ?int $territorioSelezionato = null;
|
||||
|
||||
/** Geometria (JSON) appena disegnata sulla mappa, in attesa di salvataggio. */
|
||||
public ?string $geometriaInAttesa = null;
|
||||
|
||||
/** Id dell'operazione di rigenerazione massiva seguita dalla pagina. */
|
||||
public ?string $operazioneId = null;
|
||||
|
||||
public function mount(PiantineArea $area, PiantineOperationState $state): void
|
||||
{
|
||||
$this->localita = $area->localita();
|
||||
$this->operazioneId = $state->corrente();
|
||||
}
|
||||
public ?int $territorioDaCollegare = null;
|
||||
|
||||
// ─── Area città (KML) ───────────────────────────────────────────────────
|
||||
|
||||
@@ -77,91 +62,7 @@ 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->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function salvaLocalita(PiantineArea $area): void
|
||||
{
|
||||
$area->impostaLocalita($this->localita);
|
||||
session()->flash('success', 'Località aggiornata: comparirà sull\'intestazione dei prossimi PDF.');
|
||||
}
|
||||
|
||||
// ─── Editor dei poligoni ────────────────────────────────────────────────
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[On('territorio-selezionato-mappa')]
|
||||
public function territorioSelezionatoDaMappa($territorioId): void
|
||||
{
|
||||
$this->territorioSelezionato = is_numeric($territorioId) ? (int) $territorioId : null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function updatedTerritorioSelezionato(): void
|
||||
{
|
||||
$this->territorioSelezionato = $this->territorioSelezionato ?: null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function salvaPoligono()
|
||||
{
|
||||
if (! $this->territorioSelezionato) {
|
||||
session()->flash('error', 'Scegli prima a quale territorio associare il poligono.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->geometriaInAttesa) {
|
||||
session()->flash('error', 'Disegna prima il poligono sulla mappa.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$territorio = Territorio::findOrFail($this->territorioSelezionato);
|
||||
|
||||
TerritorioMappa::updateOrCreate(
|
||||
['territorio_id' => $territorio->id],
|
||||
['geojson' => $this->geometriaInAttesa],
|
||||
);
|
||||
|
||||
$this->geometriaInAttesa = null;
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_poligono_salvato');
|
||||
session()->flash('success', "Poligono salvato per il territorio {$territorio->numero}. Ora puoi generare l'immagine.");
|
||||
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function scartaPoligono(): void
|
||||
{
|
||||
$this->geometriaInAttesa = null;
|
||||
$this->aggiornaMappaJs();
|
||||
}
|
||||
|
||||
public function eliminaPoligono(int $mappaId)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$numero = $mappa->territorio?->numero ?? '?';
|
||||
|
||||
if ($mappa->image_path) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
session()->flash('success', "Poligono e immagine del territorio {$numero} eliminati (il PDF resta com'è).");
|
||||
$this->aggiornaMappaJs();
|
||||
session()->flash('success', "Area della città aggiornata: {$quante} geometrie riconosciute nel KML.");
|
||||
}
|
||||
|
||||
// ─── Generazione ────────────────────────────────────────────────────────
|
||||
@@ -178,55 +79,102 @@ class PiantineManager extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "Immagine generata per il territorio {$mappa->territorio?->numero}.");
|
||||
session()->flash('success', "Immagine generata per {$mappa->etichetta()}.");
|
||||
}
|
||||
|
||||
public function generaPdf(int $mappaId, PiantinaPdfService $pdfService)
|
||||
{
|
||||
$mappa = TerritorioMappa::with('territorio')->findOrFail($mappaId);
|
||||
$territorio = $mappa->territorio;
|
||||
|
||||
if (! $territorio) {
|
||||
session()->flash('error', 'Il territorio associato non esiste più.');
|
||||
if (! $mappa->collegata()) {
|
||||
session()->flash('error', 'Questa piantina non è collegata a nessun territorio: collegala prima di generare il PDF.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdfService->sostituisci($territorio, $mappa);
|
||||
$pdfService->sostituisci($mappa->territorio, $mappa);
|
||||
} catch (\Throwable $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', "PDF del territorio {$territorio->numero} sostituito. Il precedente è nello storico versioni.");
|
||||
session()->flash('success', "PDF del territorio {$mappa->territorio->numero} sostituito. Il precedente è nello storico versioni.");
|
||||
}
|
||||
|
||||
// ─── Rigenerazione massiva ──────────────────────────────────────────────
|
||||
// ─── Collegamento al territorio ─────────────────────────────────────────
|
||||
|
||||
public function rigeneraTutto(bool $anchePdf, PiantineOperationState $state)
|
||||
public function apriCollegamento(int $mappaId): void
|
||||
{
|
||||
if ($state->corrente() !== null) {
|
||||
session()->flash('error', 'C\'è già una rigenerazione in corso: attendi che finisca.');
|
||||
$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;
|
||||
}
|
||||
|
||||
$totale = TerritorioMappa::count();
|
||||
if ($totale === 0) {
|
||||
session()->flash('error', 'Nessun territorio ha ancora un poligono disegnato.');
|
||||
$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;
|
||||
}
|
||||
|
||||
$id = (string) Str::uuid();
|
||||
$state->initialize($id, $totale, $anchePdf);
|
||||
RigeneraPiantine::dispatch($id, $anchePdf);
|
||||
$mappa->update(['territorio_id' => $territorio->id]);
|
||||
$this->chiudiCollegamento();
|
||||
|
||||
$this->operazioneId = $id;
|
||||
activity()->causedBy(auth()->user())->performedOn($territorio)->log('piantina_collegata');
|
||||
session()->flash('success', "Piantina collegata al territorio {$territorio->numero}.");
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())->log($anchePdf ? 'piantine_rigenerazione_pdf' : 'piantine_rigenerazione');
|
||||
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) {
|
||||
Storage::disk('public')->delete($mappa->image_path);
|
||||
}
|
||||
$mappa->delete();
|
||||
|
||||
session()->flash('success', "{$etichetta}: poligono e immagine eliminati. Il PDF del territorio resta com'è.");
|
||||
}
|
||||
|
||||
// ─── Storico versioni ───────────────────────────────────────────────────
|
||||
@@ -259,58 +207,26 @@ class PiantineManager extends Component
|
||||
session()->flash('success', 'Versione eliminata definitivamente.');
|
||||
}
|
||||
|
||||
// ─── Dati per la mappa e rendering ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Il payload che il JavaScript della mappa usa per disegnare: area della
|
||||
* città, poligoni salvati, selezione corrente.
|
||||
*/
|
||||
public function datiMappa(?PiantineArea $area = null): array
|
||||
public function render(PiantineArea $area)
|
||||
{
|
||||
$area ??= app(PiantineArea::class);
|
||||
|
||||
$poligoni = TerritorioMappa::with('territorio')->get()
|
||||
->filter(fn ($mappa) => $mappa->territorio !== null && $mappa->geometria() !== null)
|
||||
->map(fn ($mappa) => [
|
||||
'territorioId' => $mappa->territorio_id,
|
||||
'numero' => $mappa->territorio->numero,
|
||||
'geometry' => $mappa->geometria(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'area' => $area->area(),
|
||||
'poligoni' => $poligoni,
|
||||
'selezionato' => $this->territorioSelezionato,
|
||||
];
|
||||
}
|
||||
|
||||
private function aggiornaMappaJs(): void
|
||||
{
|
||||
$this->dispatch('piantine-dati-aggiornati', dati: $this->datiMappa());
|
||||
}
|
||||
|
||||
public function render(PiantineArea $area, PiantineOperationState $state)
|
||||
{
|
||||
$mappe = TerritorioMappa::with('territorio')->get()->keyBy('territorio_id');
|
||||
|
||||
$territori = Territorio::attivi()
|
||||
->orderByRaw('LENGTH(numero), numero')
|
||||
->get(['id', 'numero', 'pdf_path']);
|
||||
|
||||
$stato = $this->operazioneId ? $state->get($this->operazioneId) : null;
|
||||
|
||||
return view('livewire.piantine.piantine-manager', [
|
||||
'config' => $area->config(),
|
||||
'datiMappaIniziali' => $this->datiMappa($area),
|
||||
'territori' => $territori,
|
||||
'mappe' => $mappe,
|
||||
'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']),
|
||||
'versioni' => TerritorioPdfVersione::with('territorio')
|
||||
->orderByDesc('sostituito_il')
|
||||
->limit(30)
|
||||
->get(),
|
||||
'stato' => $stato,
|
||||
'title' => 'Piantine dei territori',
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user