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

@@ -11,15 +11,29 @@ use Illuminate\Database\Eloquent\Model;
* cartografia, il numero si decide dopo. Finche' resta scollegato vale il
* campo `nome` come etichetta; una volta collegato comanda il numero del
* 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
{
public const STATO_BOZZA = 'bozza';
public const STATO_PUBBLICATA = 'pubblicata';
public const STATO_MODIFICATA = 'modificata';
protected $table = 'territorio_mappe';
protected $fillable = [
'territorio_id',
'nome',
'geojson',
'geojson_pubblicato',
'impronta_pubblicata',
'pubblicata_at',
'image_path',
'rendered_at',
];
@@ -28,12 +42,19 @@ class TerritorioMappa extends Model
{
return [
'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()
{
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
{
$decoded = json_decode($this->geojson, true);
$decoded = json_decode((string) $this->geojson, true);
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
{
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
{
if ($this->collegata()) {
if ($this->territorio !== null) {
return 'Terr. ' . $this->territorio->numero;
}
@@ -68,4 +96,87 @@ class TerritorioMappa extends Model
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();
}
}