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

@@ -557,6 +557,64 @@ html { scroll-behavior: smooth; }
background: var(--tm-superficie-alt);
}
/* ─── Ricerca e filtri sopra un elenco ───────────────────────────────────── */
.tm-filtri {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.tm-filtri-cerca {
flex: 1 1 220px;
min-width: 0;
padding: 7px 11px;
border: 1px solid var(--tm-bordo-forte);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
font-size: 14px;
color: var(--tm-testo);
}
.tm-filtri-cerca:focus {
outline: 2px solid var(--tm-accento);
outline-offset: 1px;
}
.tm-filtri-schede {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tm-filtri-scheda {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--tm-bordo);
border-radius: 999px;
background: var(--tm-superficie);
font-size: 13px;
font-weight: 500;
color: var(--tm-testo-medio);
cursor: pointer;
}
.tm-filtri-scheda:hover { border-color: var(--tm-bordo-forte); }
.tm-filtri-scheda:focus-visible {
outline: 2px solid var(--tm-accento);
outline-offset: 2px;
}
.tm-filtri-scheda-attiva {
border-color: var(--tm-accento);
background: var(--tm-accento-tenue);
color: var(--tm-accento-scuro);
}
.tm-filtri-scheda .tm-conteggio {
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
}
/* ─── Etichette di stato ──────────────────────────────────────────────────── */
.tm-tag {
@@ -989,9 +1047,9 @@ html { scroll-behavior: smooth; }
.tm-editor-barra {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
flex: none;
padding: 9px 14px;
padding: 8px 14px;
background: var(--tm-superficie);
border-bottom: 1px solid var(--tm-bordo);
box-shadow: var(--tm-ombra-1);
@@ -1026,6 +1084,8 @@ html { scroll-behavior: smooth; }
gap: 9px;
flex: 1 1 auto;
min-width: 0;
}
.tm-editor-titolo h1 {
margin: 0;
font-size: 15px;
font-weight: 600;
@@ -1034,6 +1094,11 @@ html { scroll-behavior: smooth; }
overflow: hidden;
text-overflow: ellipsis;
}
.tm-editor-salvato {
font-size: 12px;
color: var(--tm-testo-tenue);
white-space: nowrap;
}
.tm-editor-azioni {
display: flex;
@@ -1042,10 +1107,26 @@ html { scroll-behavior: smooth; }
flex: none;
}
.tm-editor-mappa {
/* Le tre zone: elenco, mappa, pannello. Elenco e pannello galleggiano sopra
la mappa invece di restringerla: se togliessero spazio, aprirli per cambiare
un nome sposterebbe il disegno sotto il puntatore. */
.tm-editor-corpo {
position: relative;
flex: 1 1 auto;
min-height: 0;
/* Lo spazio che elenco e pannello aperti tolgono ai lati: barra degli
strumenti e controlli della mappa stanno nella parte libera, invece di
finire sotto (o sopra) un pannello. */
--libero-sx: 12px;
--libero-dx: 12px;
}
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(270px, 100vw - 24px) + 24px); }
.tm-editor-corpo.con-pannello { --libero-dx: calc(min(370px, 100vw - 24px) + 24px); }
.tm-mappa .leaflet-left { left: calc(var(--libero-sx) - 12px); }
.tm-mappa .leaflet-right { right: calc(var(--libero-dx) - 12px); }
.tm-editor-mappa {
position: absolute;
inset: 0;
}
.tm-mappa {
position: absolute;
@@ -1056,7 +1137,7 @@ html { scroll-behavior: smooth; }
.tm-editor-nota {
position: absolute;
left: 50%;
top: 14px;
bottom: 18px;
transform: translateX(-50%);
z-index: 500;
max-width: min(92vw, 460px);
@@ -1072,16 +1153,132 @@ html { scroll-behavior: smooth; }
}
.tm-editor-nota a { color: inherit; font-weight: 600; }
/* Il pannello galleggia sopra la mappa invece di restringerla: se togliesse
spazio, aprirlo per cambiare un nome sposterebbe il disegno sotto il
puntatore. */
.tm-editor-pannello {
/* ── Barra degli strumenti di disegno ── */
.tm-strumenti {
position: absolute;
top: 12px;
right: 12px;
left: var(--libero-sx);
right: var(--libero-dx);
z-index: 650;
display: flex;
align-items: center;
gap: 6px;
width: fit-content;
margin-inline: auto;
padding: 5px;
border: 1px solid var(--tm-bordo);
border-radius: 12px;
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
overflow-x: auto;
}
.tm-strumenti:empty { display: none; }
.tm-strumento {
flex: none;
min-height: 34px;
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: none;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--tm-testo-medio);
white-space: nowrap;
cursor: pointer;
transition: background-color .15s ease, color .15s ease;
}
.tm-strumento:hover { background: var(--tm-superficie-alt); color: var(--tm-testo); }
.tm-strumento:focus-visible { outline: 2px solid var(--tm-accento); outline-offset: 1px; }
.tm-strumento-principale { background: var(--tm-accento); color: #fff; }
.tm-strumento-principale:hover { background: var(--tm-accento-scuro); color: #fff; }
.tm-strumenti-modo {
flex: none;
padding: 0 8px 0 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--tm-pericolo);
}
/* ── Elenco ── */
.tm-editor-elenco {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px;
z-index: 600;
width: min(360px, calc(100vw - 24px));
width: min(270px, calc(100vw - 24px));
display: flex;
flex-direction: column;
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio);
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
overflow: hidden;
}
.tm-editor-elenco-cerca {
flex: none;
padding: 10px;
border-bottom: 1px solid var(--tm-bordo);
}
.tm-editor-elenco-cerca input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--tm-bordo-forte);
border-radius: 8px;
font: inherit;
font-size: 13.5px;
}
.tm-editor-elenco-righe {
flex: 1 1 auto;
margin: 0;
padding: 6px;
list-style: none;
overflow-y: auto;
}
.tm-editor-elenco-voce {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 7px 10px;
border: 0;
border-radius: 8px;
background: none;
font: inherit;
font-size: 13.5px;
text-align: left;
color: var(--tm-testo);
cursor: pointer;
}
.tm-editor-elenco-voce:hover { background: var(--tm-superficie-alt); }
.tm-editor-elenco-voce.is-selezionata { background: var(--tm-accento-tenue); color: var(--tm-accento-scuro); font-weight: 600; }
.tm-editor-elenco-stato {
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--tm-testo-tenue);
}
.tm-stato-bozza, .tm-stato-modificata { color: var(--tm-attesa); }
.tm-stato-pubblicata { color: var(--tm-ok); }
.tm-editor-elenco-vuoto {
padding: 14px 10px;
font-size: 13px;
color: var(--tm-testo-tenue);
}
/* ── Pannello della piantina ── */
.tm-editor-pannello {
position: absolute;
top: 12px;
right: 12px;
bottom: 12px;
z-index: 600;
width: min(370px, calc(100vw - 24px));
display: flex;
flex-direction: column;
border: 1px solid var(--tm-bordo);
@@ -1090,7 +1287,6 @@ html { scroll-behavior: smooth; }
box-shadow: var(--tm-ombra-3);
overflow: hidden;
}
.tm-editor-pannello[hidden] { display: none; }
.tm-editor-pannello-testata {
display: flex;
@@ -1098,7 +1294,7 @@ html { scroll-behavior: smooth; }
justify-content: space-between;
gap: 10px;
flex: none;
padding: 13px 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--tm-bordo);
}
.tm-editor-pannello-testata h2 {
@@ -1130,7 +1326,7 @@ html { scroll-behavior: smooth; }
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 10px;
padding-top: 16px;
border-top: 1px solid var(--tm-bordo);
@@ -1141,14 +1337,164 @@ html { scroll-behavior: smooth; }
line-height: 1.55;
color: var(--tm-testo-tenue);
}
.tm-stato-piantina p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--tm-testo-medio); }
.tm-azioni-riga {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.tm-azioni-riga:empty { display: none; }
.tm-passi {
margin: 0;
padding-left: 20px;
font-size: 13.5px;
line-height: 1.6;
color: var(--tm-testo-medio);
}
.tm-collegamento-piccolo {
align-self: flex-start;
padding: 0;
border-top: 0;
border-left: 0;
border-right: 0;
background: none;
font: inherit;
font-size: 12.5px;
cursor: pointer;
}
.tm-solo-stretto { display: none; }
/* Tablet: l'elenco si apre a richiesta e copre la mappa per il tempo della
scelta. */
@media (max-width: 1099px) {
.tm-editor-elenco { width: min(300px, calc(100vw - 24px)); }
.tm-editor-corpo.con-elenco { --libero-sx: calc(min(300px, 100vw - 24px) + 24px); }
}
/* Telefono e tablet in verticale: il pannello sale dal basso. */
@media (max-width: 720px) {
.tm-editor-barra { flex-wrap: wrap; }
.tm-editor-titolo { order: 3; flex-basis: 100%; }
.tm-editor-pannello { top: auto; height: min(70dvh, 460px); }
.tm-editor-azioni .tm-btn span { display: none; }
.tm-solo-stretto { display: inline-flex; }
.tm-editor-pannello {
top: auto;
left: 0;
right: 0;
bottom: 0;
width: auto;
height: min(62dvh, 520px);
border-radius: var(--tm-raggio) var(--tm-raggio) 0 0;
}
/* Elenco e pannello qui coprono la mappa per intero o dal basso: la
barra resta in alto, sopra il foglio del pannello. */
.tm-editor-corpo,
.tm-editor-corpo.con-elenco,
.tm-editor-corpo.con-pannello { --libero-sx: 12px; --libero-dx: 12px; }
/* A destra dei pulsanti dello zoom, e su più righe invece di uscire
dallo schermo. */
.tm-strumenti { left: 58px; flex-wrap: wrap; }
.tm-mappa .leaflet-left { left: 0; }
/* Il foglio copre crediti e scala della mappa finché è aperto, invece di
averli stampati sopra i campi. */
.tm-editor-pannello { z-index: 1001; }
}
/* ─── Avvisi galleggianti (solo telaio a schermo intero) ───────────────────── */
/* Dita, non puntatore: vertici e maniglie di leaflet-draw più grandi, così
si prendono senza zoomare al massimo. */
@media (pointer: coarse) {
.leaflet-editing-icon,
.leaflet-touch-icon {
width: 22px !important;
height: 22px !important;
margin-left: -11px !important;
margin-top: -11px !important;
}
.tm-strumento { min-height: 42px; padding: 8px 14px; }
}
/* ─── Finestra di pubblicazione ───────────────────────────────────────────── */
.tm-modale-sfondo {
position: fixed;
inset: 0;
z-index: 800;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(28, 26, 23, .45);
}
.tm-modale {
display: flex;
flex-direction: column;
width: 100%;
max-width: 460px;
max-height: calc(100dvh - 32px);
border-radius: var(--tm-raggio);
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
overflow: hidden;
}
.tm-modale-larga { max-width: 820px; }
.tm-modale-testata {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--tm-bordo);
}
.tm-modale-testata h2 { margin: 0; font-size: 15px; font-weight: 600; }
.tm-modale-corpo {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
overflow-y: auto;
}
.tm-modale-piede {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px;
border-top: 1px solid var(--tm-bordo);
}
.tm-anteprima-pdf {
width: 100%;
height: min(62dvh, 640px);
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie-alt);
}
.tm-attesa {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 18px 4px;
}
.tm-attesa-titolo { margin: 0 0 4px; font-size: 14px; font-weight: 600; }
.tm-attesa-giro {
flex: none;
width: 22px;
height: 22px;
border: 3px solid var(--tm-accento-tenue);
border-top-color: var(--tm-accento);
border-radius: 50%;
animation: tm-giro .9s linear infinite;
}
@keyframes tm-giro { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .tm-attesa-giro { animation-duration: 3s; } }
.tm-nota-errore {
border-color: #edc9c6;
background: var(--tm-pericolo-tenue);
color: var(--tm-pericolo);
}
/* ─── Avvisi galleggianti (componente <x-avvisi>) ───────────────────── */
.tm-avvisi {
position: fixed;

View File

@@ -1,10 +1,22 @@
// Editor delle piantine (pagina /piantine/editor, solo amministratore).
//
// Mappa Leaflet su tessere OpenStreetMap: mostra l'area del KML, le piantine
// già salvate e permette di disegnarne di nuove con leaflet-draw. Parla con il
// componente Livewire attraverso eventi:
// JS → Livewire: 'poligono-disegnato', 'piantina-selezionata-mappa'
// Livewire → JS: 'piantine-dati-aggiornati'
// Mappa Leaflet su tessere OpenStreetMap con l'area del KML e le piantine, più
// l'elenco con ricerca a sinistra. Disegno e modifica dei vertici usano i
// gestori di leaflet-draw, comandati da una barra di strumenti nostra: pulsanti
// grandi e con il testo, che si usano anche al tablet.
//
// Modalità, sempre esplicite:
// vista si guarda, si sceglie una piantina (clic sulla mappa o in elenco)
// disegno si traccia una piantina NUOVA
// ridisegno si traccia da capo il confine di quella aperta (con conferma)
// vertici si trascinano i vertici di quella aperta
//
// Con il componente Livewire si parla per eventi:
// JS → Livewire: 'piantina-selezionata-mappa', 'geometria-modificata'
// Livewire → JS: 'piantina-aggiornata', 'piantina-rimossa',
// 'piantina-selezionata', 'editor-disegna', 'editor-indirizzo'
// I dati di tutte le piantine si chiedono una volta sola ($wire.datiMappa());
// dopo arriva solo la piantina cambiata.
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
@@ -12,12 +24,13 @@ import 'leaflet-draw';
import 'leaflet-draw/dist/leaflet.draw.css';
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
// Una piantina collegata a un territorio è "a posto"; una scollegata è un
// lavoro ancora aperto, e si vede senza dover aprire il pannello.
const STILE_COLLEGATA = { color: '#8a8578', weight: 2, fillColor: '#8a8578', fillOpacity: 0.1 };
const STILE_SCOLLEGATA = { color: '#b4762a', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 };
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, fillColor: '#605cb2', fillOpacity: 0.18 };
const STILE_IN_ATTESA = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
const STILI = {
pubblicata: { color: '#2f6f4e', weight: 2, fillColor: '#2f6f4e', fillOpacity: 0.08 },
modificata: { color: '#a06a22', weight: 2, fillColor: '#c98b3a', fillOpacity: 0.1 },
bozza: { color: '#a06a22', weight: 2, dashArray: '6 5', fillColor: '#c98b3a', fillOpacity: 0.1 },
};
const STILE_SELEZIONATA = { color: '#4a45a8', weight: 3, dashArray: null, fillColor: '#605cb2', fillOpacity: 0.18 };
const STILE_DISEGNO = { color: '#b3352c', weight: 3, fillColor: '#d0504a', fillOpacity: 0.15 };
function initPiantine() {
const contenitore = document.getElementById('piantine-map');
@@ -26,143 +39,294 @@ function initPiantine() {
}
contenitore.dataset.inizializzato = '1';
let dati = { area: null, piantine: [], selezionata: null };
try {
dati = JSON.parse(document.getElementById('piantine-dati').textContent);
} catch {
// si parte comunque con la mappa vuota
}
const radice = contenitore.closest('[wire\\:id]');
const componente = () => window.Livewire.find(radice.getAttribute('wire:id'));
// Etichette in italiano per la barra di disegno.
L.drawLocal.draw.toolbar.buttons.polygon = 'Disegna il confine di una piantina';
L.drawLocal.draw.toolbar.actions = { title: 'Annulla il disegno', text: 'Annulla' };
L.drawLocal.draw.toolbar.finish = { title: 'Concludi il disegno', text: 'Concludi' };
L.drawLocal.draw.toolbar.undo = { title: 'Rimuovi l\'ultimo punto', text: 'Ultimo punto' };
L.drawLocal.draw.handlers.polygon.tooltip = {
start: 'Clicca sulla mappa per iniziare il confine.',
cont: 'Continua a cliccare lungo il confine.',
end: 'Clicca sul primo punto per chiudere il poligono.',
start: 'Tocca o clicca sulla mappa per il primo punto del confine.',
cont: 'Continua lungo il confine.',
end: 'Chiudi sul primo punto, o premi «Concludi».',
};
L.drawLocal.edit.handlers.edit.tooltip = { text: 'Trascina i vertici per correggere il confine.', subtext: '' };
const mappa = L.map(contenitore, { zoomControl: true });
L.control.scale({ imperial: false }).addTo(mappa);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(mappa);
const gruppo = L.featureGroup().addTo(mappa);
const layers = new Map(); // mappaId → { dati, layer }
let layerArea = null;
const layerSalvate = L.featureGroup().addTo(mappa);
let layerInAttesa = null;
let selezionata = null;
let modo = 'vista';
let gestoreDisegno = null;
let poligonoInModifica = null;
mappa.addControl(new L.Control.Draw({
draw: {
polygon: { allowIntersection: false, showArea: false, shapeOptions: STILE_IN_ATTESA },
polyline: false,
rectangle: false,
circle: false,
circlemarker: false,
marker: false,
},
edit: false,
}));
// ── Disegno delle piantine ──────────────────────────────────────────────
function stileDi(piantina, selezionata) {
if (selezionata) return STILE_SELEZIONATA;
return piantina.collegata ? STILE_COLLEGATA : STILE_SCOLLEGATA;
function stileDi(dati) {
if (dati.mappaId === selezionata) return STILE_SELEZIONATA;
return { dashArray: null, ...(STILI[dati.stato] ?? STILI.bozza) };
}
function ridisegna(nuoviDati, adattaVista) {
dati = nuoviDati;
function disegna(dati) {
rimuovi(dati.mappaId, false);
if (!dati.geometry) return;
if (layerArea) {
mappa.removeLayer(layerArea);
layerArea = null;
let layer;
try {
layer = L.geoJSON({ type: 'Feature', properties: {}, geometry: dati.geometry }, { style: stileDi(dati) });
} catch {
return; // una geometria rovinata non deve fermare le altre
}
layer.bindTooltip(`${dati.etichetta} · ${dati.statoEtichetta}`, { sticky: true });
layer.on('click', () => {
if (modo !== 'vista') return;
componente().dispatch('piantina-selezionata-mappa', { mappaId: dati.mappaId });
});
gruppo.addLayer(layer);
layers.set(dati.mappaId, { dati, layer });
}
function rimuovi(mappaId, aggiornaElenco = true) {
const voce = layers.get(mappaId);
if (voce) {
gruppo.removeLayer(voce.layer);
layers.delete(mappaId);
}
if (aggiornaElenco) disegnaElenco();
}
function ristila() {
for (const { dati, layer } of layers.values()) {
layer.setStyle(stileDi(dati));
}
}
function inquadra(mappaId) {
const voce = layers.get(mappaId);
if (voce) mappa.fitBounds(voce.layer.getBounds().pad(0.25));
}
// ── Elenco con ricerca ──────────────────────────────────────────────────
const elenco = document.getElementById('piantine-elenco');
const cerca = document.getElementById('piantine-cerca');
function disegnaElenco() {
if (!elenco) return;
const filtro = (cerca?.value ?? '').trim().toLowerCase();
const voci = [...layers.values()].map((v) => v.dati)
.filter((d) => !filtro || d.etichetta.toLowerCase().includes(filtro))
.sort((a, b) => a.etichetta.localeCompare(b.etichetta, 'it', { numeric: true }));
elenco.replaceChildren(...voci.map((d) => {
const riga = document.createElement('li');
const pulsante = document.createElement('button');
pulsante.type = 'button';
pulsante.className = 'tm-editor-elenco-voce' + (d.mappaId === selezionata ? ' is-selezionata' : '');
pulsante.setAttribute('aria-current', d.mappaId === selezionata ? 'true' : 'false');
const nome = document.createElement('span');
nome.textContent = d.etichetta;
const stato = document.createElement('span');
stato.className = 'tm-editor-elenco-stato tm-stato-' + d.stato;
stato.textContent = d.statoEtichetta;
pulsante.append(nome, stato);
pulsante.addEventListener('click', () => {
if (modo !== 'vista') return;
componente().dispatch('piantina-selezionata-mappa', { mappaId: d.mappaId });
});
riga.append(pulsante);
return riga;
}));
if (!voci.length) {
const vuoto = document.createElement('li');
vuoto.className = 'tm-editor-elenco-vuoto';
vuoto.textContent = filtro ? 'Nessuna piantina trovata.' : 'Nessuna piantina ancora.';
elenco.append(vuoto);
}
}
cerca?.addEventListener('input', disegnaElenco);
// ── Barra degli strumenti ───────────────────────────────────────────────
const barra = document.getElementById('piantine-strumenti');
function pulsante(testo, azione, principale = false) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tm-strumento' + (principale ? ' tm-strumento-principale' : '');
b.textContent = testo;
b.addEventListener('click', azione);
return b;
}
function aggiornaBarra() {
if (!barra) return;
const comandi = [];
if (modo === 'vista') {
comandi.push(pulsante('Disegna nuova piantina', () => avviaDisegno(false), selezionata === null));
if (selezionata !== null && layers.has(selezionata)) {
comandi.push(pulsante('Modifica vertici', avviaVertici));
comandi.push(pulsante('Ridisegna confine', () => {
if (window.confirm('Ridisegnare da capo il confine di questa piantina? Quello attuale viene sostituito.')) {
avviaDisegno(true);
}
}));
}
} else if (modo === 'disegno' || modo === 'ridisegno') {
const titolo = document.createElement('span');
titolo.className = 'tm-strumenti-modo';
titolo.textContent = modo === 'disegno' ? 'Nuova piantina' : 'Nuovo confine';
comandi.push(titolo);
comandi.push(pulsante('Togli ultimo punto', () => gestoreDisegno?.deleteLastVertex()));
comandi.push(pulsante('Concludi', () => gestoreDisegno?.completeShape(), true));
comandi.push(pulsante('Annulla', fermaDisegno));
} else if (modo === 'vertici') {
const titolo = document.createElement('span');
titolo.className = 'tm-strumenti-modo';
titolo.textContent = 'Trascina i vertici';
comandi.push(titolo);
comandi.push(pulsante('Fine modifica', fermaVertici, true));
}
barra.replaceChildren(...comandi);
}
// ── Disegno ─────────────────────────────────────────────────────────────
let ridisegno = false;
function avviaDisegno(diQuellaAperta) {
fermaVertici(false);
ridisegno = diQuellaAperta;
modo = diQuellaAperta ? 'ridisegno' : 'disegno';
gestoreDisegno = new L.Draw.Polygon(mappa, { allowIntersection: false, showArea: false, shapeOptions: STILE_DISEGNO });
gestoreDisegno.enable();
aggiornaBarra();
}
function fermaDisegno() {
gestoreDisegno?.disable();
gestoreDisegno = null;
modo = 'vista';
aggiornaBarra();
}
mappa.on(L.Draw.Event.CREATED, (evento) => {
const geometry = evento.layer.toGeoJSON().geometry;
const nuova = !ridisegno;
fermaDisegno();
componente().dispatch('geometria-modificata', { geometry, nuova });
});
// Il gestore si spegne da solo anche con Esc: la barra deve seguirlo.
mappa.on(L.Draw.Event.DRAWSTOP, () => {
if (modo === 'disegno' || modo === 'ridisegno') {
gestoreDisegno = null;
modo = 'vista';
aggiornaBarra();
}
});
// ── Modifica dei vertici ────────────────────────────────────────────────
function avviaVertici() {
const voce = layers.get(selezionata);
if (!voce) return;
poligonoInModifica = voce.layer.getLayers()[0];
if (!poligonoInModifica?.editing) return;
poligonoInModifica.editing.enable();
modo = 'vertici';
aggiornaBarra();
}
function fermaVertici(salva = true) {
if (!poligonoInModifica) {
if (modo === 'vertici') { modo = 'vista'; aggiornaBarra(); }
return;
}
const poligono = poligonoInModifica;
poligonoInModifica = null;
const modificato = poligono.edited;
poligono.editing.disable();
modo = 'vista';
aggiornaBarra();
if (salva && modificato) {
componente().dispatch('geometria-modificata', { geometry: poligono.toGeoJSON().geometry, nuova: false });
}
}
// ── Eventi dal server ───────────────────────────────────────────────────
function seleziona(mappaId, centra) {
if (modo === 'vertici') fermaVertici();
selezionata = mappaId ?? null;
ristila();
disegnaElenco();
aggiornaBarra();
if (centra && selezionata !== null) inquadra(selezionata);
}
function registra() {
window.Livewire.on('piantina-aggiornata', ({ piantina }) => {
disegna(piantina);
disegnaElenco();
aggiornaBarra();
});
window.Livewire.on('piantina-rimossa', ({ mappaId }) => {
if (selezionata === mappaId) selezionata = null;
rimuovi(mappaId);
aggiornaBarra();
});
window.Livewire.on('piantina-selezionata', ({ mappaId, inquadra: centra }) => seleziona(mappaId, centra));
window.Livewire.on('editor-disegna', () => avviaDisegno(false));
window.Livewire.on('editor-indirizzo', ({ url }) => {
if (window.location.href !== url) window.history.replaceState(window.history.state, '', url);
});
}
async function carica() {
const dati = await componente().datiMappa();
if (dati.area) {
layerArea = L.geoJSON(dati.area, { style: STILE_AREA, interactive: false }).addTo(mappa);
}
selezionata = dati.selezionata ?? null;
dati.piantine.forEach(disegna);
disegnaElenco();
aggiornaBarra();
layerSalvate.clearLayers();
let layerSelezionata = null;
for (const piantina of dati.piantine) {
const selezionata = dati.selezionata === piantina.mappaId;
const layer = L.geoJSON(
{ type: 'Feature', properties: {}, geometry: piantina.geometry },
{ style: stileDi(piantina, selezionata) }
);
layer.bindTooltip(piantina.etichetta, { sticky: true });
layer.on('click', () => {
window.Livewire.dispatch('piantina-selezionata-mappa', { mappaId: piantina.mappaId });
});
layerSalvate.addLayer(layer);
if (selezionata) {
layerSelezionata = layer;
}
}
// Aprire una piantina dall'elenco deve portarci sopra; un salvataggio
// no, altrimenti la vista salta via mentre si sta ancora lavorando.
if (adattaVista && layerSelezionata) {
mappa.fitBounds(layerSelezionata.getBounds().pad(0.25));
return;
}
if (!adattaVista) {
return;
}
const riferimento = layerArea ?? (dati.piantine.length ? layerSalvate : null);
if (riferimento) {
mappa.fitBounds(riferimento.getBounds().pad(0.08));
if (selezionata !== null && layers.has(selezionata)) {
inquadra(selezionata);
} else if (layerArea) {
mappa.fitBounds(layerArea.getBounds().pad(0.08));
} else if (layers.size) {
mappa.fitBounds(gruppo.getBounds().pad(0.08));
} else {
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
}
}
ridisegna(dati, true);
mappa.setView([41.9, 12.5], 6);
mappa.on(L.Draw.Event.CREATED, (evento) => {
if (layerInAttesa) {
mappa.removeLayer(layerInAttesa);
}
layerInAttesa = evento.layer;
layerInAttesa.setStyle(STILE_IN_ATTESA);
layerInAttesa.addTo(mappa);
window.Livewire.dispatch('poligono-disegnato', { geometry: evento.layer.toGeoJSON().geometry });
});
let registrato = false;
document.addEventListener('livewire:init', registraEventi);
if (window.Livewire) {
registraEventi();
const avvia = () => { registra(); carica(); };
if (window.Livewire?.find && radice && window.Livewire.find(radice.getAttribute('wire:id'))) {
avvia();
} else {
document.addEventListener('livewire:initialized', avvia, { once: true });
}
function registraEventi() {
if (registrato) {
return;
}
registrato = true;
window.Livewire.on('piantine-dati-aggiornati', ({ dati: nuovi, inquadra }) => {
// Dopo un salvataggio il poligono provvisorio è diventato
// definitivo (o è stato scartato): via il tracciato rosso.
if (layerInAttesa) {
mappa.removeLayer(layerInAttesa);
layerInAttesa = null;
}
ridisegna(nuovi, Boolean(inquadra));
});
}
// La mappa nasce dentro un contenitore che il pannello laterale può
// coprire: senza questo, Leaflet resta convinto delle misure di partenza e
// le tessere si disallineano dal puntatore.
window.addEventListener('resize', () => mappa.invalidateSize());
setTimeout(() => mappa.invalidateSize(), 60);
// Pannelli che si aprono e chiudono cambiano le misure del contenitore:
// senza, Leaflet resta convinto di quelle di partenza e le tessere si
// disallineano dal puntatore.
new ResizeObserver(() => mappa.invalidateSize()).observe(contenitore);
}
if (document.readyState === 'loading') {

View File

@@ -0,0 +1,50 @@
{{--
Avvisi galleggianti: raccolgono l'evento "avviso" che i componenti
Livewire lanciano con il trait Avvisa, e che arriva anche dopo un'azione
che ridisegna solo il componente.
sessione: true per mostrare anche i messaggi flash della richiesta (il
telaio a schermo intero, che non ha una fascia messaggi sua). Il telaio
normale li mostra già in testa alla pagina, quindi lì resta false.
Gli avvisi di conferma si chiudono da soli; quelli di errore restano
finché non li si chiude, perché vanno letti.
--}}
@props(['sessione' => false])
@php
$iniziali = [];
if ($sessione && session()->has('success')) {
$iniziali[] = ['tipo' => 'ok', 'testo' => session('success')];
}
if ($sessione && session()->has('error')) {
$iniziali[] = ['tipo' => 'errore', 'testo' => session('error')];
}
@endphp
<div class="tm-avvisi" role="status" aria-live="polite"
x-data="{
avvisi: [],
prossimo: 0,
aggiungi(tipo, testo) {
const id = ++this.prossimo;
this.avvisi.push({ id, tipo, testo });
if (tipo !== 'errore') setTimeout(() => this.togli(id), 4500);
},
togli(id) { this.avvisi = this.avvisi.filter(a => a.id !== id); },
}"
x-init="@js($iniziali).forEach(a => aggiungi(a.tipo, a.testo))"
@avviso.window="aggiungi($event.detail.tipo, $event.detail.testo)">
<template x-for="avviso in avvisi" :key="avviso.id">
<div class="tm-avviso" :class="avviso.tipo === 'errore' ? 'tm-avviso-errore' : 'tm-avviso-ok'"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0">
<svg x-show="avviso.tipo !== 'errore'" 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>
<svg x-show="avviso.tipo === 'errore'" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span x-text="avviso.testo"></span>
<button type="button" @click="togli(avviso.id)" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">&times;</button>
</div>
</template>
</div>

View File

@@ -212,6 +212,11 @@
</main>
</div>
</div>
{{-- Gli avvisi delle azioni Livewire (trait Avvisa). I flash della
richiesta restano nella fascia in testa al contenuto, qui sopra. --}}
<x-avvisi />
@livewireScripts
</body>
</html>

View File

@@ -5,8 +5,8 @@
Serve a una cosa sola: la mappa dell'editor delle piantine, che deve
prendersi tutta la finestra. I messaggi non spingono il contenuto verso il
basso come nelle altre pagine — comparirebbe una fascia che sposta la mappa
mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro,
e si tolgono da soli.
mentre ci si sta disegnando sopra — ma galleggiano sopra, in alto al centro
(<x-avvisi>, che raccoglie anche quelli lanciati dalle azioni Livewire).
--}}
<html lang="it" class="h-full tm-app">
<head>
@@ -22,33 +22,7 @@
{{ $slot }}
</main>
<div class="tm-avvisi" role="status" aria-live="polite">
@if (session()->has('success'))
<div x-data="{ mostra: true }" x-show="mostra" x-cloak
x-init="setTimeout(() => mostra = false, 4500)"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="tm-avviso tm-avviso-ok">
<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>
<span>{{ session('success') }}</span>
<button type="button" @click="mostra = false" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">&times;</button>
</div>
@endif
@if (session()->has('error'))
<div x-data="{ mostra: true }" x-show="mostra" x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="tm-avviso tm-avviso-errore">
<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>{{ session('error') }}</span>
<button type="button" @click="mostra = false" class="tm-avviso-chiudi" aria-label="Chiudi il messaggio">&times;</button>
</div>
@endif
</div>
<x-avvisi :sessione="true" />
@livewireScripts
</body>

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>

View File

@@ -1,10 +1,21 @@
@php
use App\Models\TerritorioMappa;
$tagStato = [
TerritorioMappa::STATO_BOZZA => 'tm-tag-attesa',
TerritorioMappa::STATO_MODIFICATA => 'tm-tag-attesa',
TerritorioMappa::STATO_PUBBLICATA => 'tm-tag-ok',
];
@endphp
<div>
<header class="tm-pagina-testata">
<div>
<h1 class="tm-pagina-titolo">Piantine dei territori</h1>
<p class="tm-pagina-nota">
Il disegno dei confini si fa nell'editor a schermo intero. Qui si tiene l'area della
città, si generano immagini e PDF sul modulo S-12, e si ripescano le versioni sostituite.
A che punto è ogni piantina. Il disegno e i dati del territorio si modificano nell'editor,
dove le modifiche si salvano da sole come bozza; il PDF del territorio cambia solo quando
si pubblica, dopo averne visto l'anteprima.
</p>
</div>
<x-btn variante="primary" :href="route('piantine.editor')">
@@ -13,8 +24,101 @@
</x-btn>
</header>
{{-- ── Piantine ──────────────────────────────────────────────────────── --}}
<section class="tm-card">
<div class="tm-card-testata">
<h2 class="tm-card-titolo">Piantine <span class="tm-conteggio">{{ $totale }}</span></h2>
</div>
@if($totale === 0)
<div class="tm-vuoto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
<p>Nessuna piantina disegnata.</p>
<x-btn variante="primary" dimensione="sm" :href="route('piantine.editor')">Disegna la prima</x-btn>
</div>
@else
<div class="tm-filtri">
<input type="search" wire:model.live.debounce.300ms="cerca" class="tm-filtri-cerca"
placeholder="Cerca numero, nome o zona…" aria-label="Cerca una piantina">
<div class="tm-filtri-schede" role="group" aria-label="Filtra per stato">
@foreach(\App\Livewire\Piantine\PiantineManager::FILTRI as $chiave => $etichetta)
<button type="button" wire:click="$set('filtro', '{{ $chiave }}')"
class="tm-filtri-scheda {{ $filtro === $chiave ? 'tm-filtri-scheda-attiva' : '' }}"
aria-pressed="{{ $filtro === $chiave ? 'true' : 'false' }}">
{{ $etichetta }} <span class="tm-conteggio">{{ $conteggi[$chiave] }}</span>
</button>
@endforeach
</div>
</div>
@if($elenco->isEmpty())
<p class="tm-campo-aiuto">Nessuna piantina corrisponde alla ricerca.</p>
@else
<ul class="tm-elenco">
@foreach($elenco as $piantina)
@php
$stato = $piantina->stato();
$motivo = $piantina->motivoNonPubblicabile();
@endphp
<li class="tm-elenco-riga" wire:key="piantina-{{ $piantina->id }}">
<div class="tm-elenco-intestazione">
<a href="{{ route('piantine.editor', $piantina->id) }}" class="tm-elenco-nome">
{{ $piantina->etichetta() }}
</a>
<span class="tm-tag {{ $tagStato[$stato] }}">{{ $piantina->statoEtichetta() }}</span>
@if($piantina->territorioNelCestino())
<span class="tm-tag tm-tag-attesa">Territorio nel cestino</span>
@elseif(! $piantina->collegata())
<span class="tm-tag">Scollegata</span>
@elseif(! $piantina->territorio->zona_id)
<span class="tm-tag tm-tag-attesa" title="La zona viene stampata come «Località»: senza, l'intestazione della cartolina resta vuota">Zona mancante</span>
@endif
<span class="tm-elenco-stato">
@if($piantina->territorio?->zona)
{{ $piantina->territorio->zona->nome }} ·
@endif
{{ $piantina->pubblicata_at
? 'pubblicata il ' . $piantina->pubblicata_at->format('d/m/Y H:i')
: 'mai pubblicata' }}
</span>
</div>
<div class="tm-elenco-azioni">
<x-btn variante="secondary" dimensione="sm" :href="route('piantine.editor', $piantina->id)">
Apri
</x-btn>
@if($stato !== TerritorioMappa::STATO_PUBBLICATA)
<x-btn variante="primary" tono="tenue" dimensione="sm"
wire:click="pubblica({{ $piantina->id }})"
:disabled="$motivo !== null" :title="$motivo">
Pubblica
</x-btn>
@endif
@if($piantina->collegata() && $piantina->territorio->pdf_path)
<a href="{{ asset('storage/' . $piantina->territorio->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm"
wire:click="elimina({{ $piantina->id }})"
wire:confirm="Eliminare {{ $piantina->etichetta() }}? Il disegno viene rimosso; il territorio e il suo PDF restano com'erano.">
Elimina
</x-btn>
</div>
@if($motivo && $stato !== TerritorioMappa::STATO_PUBBLICATA)
<p class="tm-campo-aiuto">{{ $motivo }}</p>
@endif
</li>
@endforeach
</ul>
@endif
@endif
</section>
{{-- ── Area della città ──────────────────────────────────────────────── --}}
<section class="tm-card tm-card-primaria {{ $config['kml_nome'] ? '' : 'tm-card-manca' }}">
<section class="tm-card {{ $config['kml_nome'] ? '' : 'tm-card-manca' }}">
<div class="tm-card-testata tm-card-testata-icona">
<span class="tm-card-icona" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
@@ -29,8 +133,7 @@
@endif
</h2>
<p class="tm-card-nota">
Il perimetro di riferimento su cui si disegnano le piantine: è il primo passo,
senza l'editor parte su una mappa senza contorni.
Il perimetro di riferimento che l'editor mostra sotto le piantine.
</p>
</div>
</div>
@@ -59,110 +162,14 @@
</form>
</section>
{{-- ── Piantine ──────────────────────────────────────────────────────── --}}
<section class="tm-card">
<div class="tm-card-testata">
<h2 class="tm-card-titolo">Piantine <span class="tm-conteggio">{{ $piantine->count() }}</span></h2>
<p class="tm-card-nota">
Una piantina può vivere scollegata: per il PDF sul modulo S-12 serve invece un territorio.
</p>
</div>
@if($piantine->isEmpty())
<div class="tm-vuoto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
<p>Nessuna piantina disegnata.</p>
<x-btn variante="primary" dimensione="sm" :href="route('piantine.editor')">Disegna la prima</x-btn>
</div>
@else
<ul class="tm-elenco">
@foreach($piantine as $piantina)
<li class="tm-elenco-riga" wire:key="piantina-{{ $piantina->id }}">
<div class="tm-elenco-intestazione">
<a href="{{ route('piantine.editor', $piantina->id) }}" class="tm-elenco-nome">
{{ $piantina->etichetta() }}
</a>
@unless($piantina->collegata())
<span class="tm-tag">scollegata</span>
@endunless
@if($piantina->collegata() && ! $piantina->territorio->zona_id)
<span class="tm-tag tm-tag-attesa" title="La zona viene stampata come «Località»: senza, l'intestazione della cartolina resta vuota">Zona mancante</span>
@endif
<span class="tm-elenco-stato {{ $piantina->image_path ? 'tm-elenco-stato-ok' : '' }}">
{{ $piantina->image_path
? 'immagine del ' . $piantina->rendered_at?->format('d/m/Y H:i')
: 'immagine da generare' }}
</span>
</div>
<div class="tm-elenco-azioni">
<x-btn variante="secondary" tono="tenue" dimensione="sm"
wire:click="generaImmagine({{ $piantina->id }})"
wire:loading.attr="disabled" wire:target="generaImmagine({{ $piantina->id }})">
<span wire:loading.remove wire:target="generaImmagine({{ $piantina->id }})">Genera immagine</span>
<span wire:loading wire:target="generaImmagine({{ $piantina->id }})">Rendering…</span>
</x-btn>
@if($piantina->image_path)
<a href="{{ asset('storage/' . $piantina->image_path) }}" target="_blank" rel="noopener" class="tm-collegamento">Immagine</a>
@endif
@if($piantina->collegata())
@if($piantina->image_path)
<a href="{{ route('piantine.anteprima', $piantina) }}" target="_blank" rel="noopener" class="tm-collegamento">Anteprima PDF</a>
<x-btn variante="primary" tono="tenue" dimensione="sm"
wire:click="generaPdf({{ $piantina->id }})"
wire:loading.attr="disabled" wire:target="generaPdf({{ $piantina->id }})"
wire:confirm="Sostituire il PDF del territorio {{ $piantina->territorio->numero }}? Quello attuale finisce nello storico versioni.">
<span wire:loading.remove wire:target="generaPdf({{ $piantina->id }})">Genera PDF</span>
<span wire:loading wire:target="generaPdf({{ $piantina->id }})">Composizione…</span>
</x-btn>
@endif
<x-btn variante="ghost" dimensione="sm" wire:click="scollega({{ $piantina->id }})"
wire:confirm="Scollegare questa piantina dal territorio {{ $piantina->territorio->numero }}? Il PDF del territorio resta com'è.">
Scollega
</x-btn>
@else
<x-btn variante="secondary" dimensione="sm" wire:click="apriCollegamento({{ $piantina->id }})">
Collega a un territorio
</x-btn>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm"
wire:click="eliminaPiantina({{ $piantina->id }})"
wire:confirm="Eliminare {{ $piantina->etichetta() }}? Poligono e immagine vengono rimossi; il PDF del territorio resta com'è.">
Elimina
</x-btn>
</div>
@if($collegamentoAperto === $piantina->id)
<div class="tm-elenco-pannello">
<div class="tm-campo-riga">
<select wire:model="territorioDaCollegare" aria-label="Territorio a cui collegare la piantina">
<option value="">— scegli il territorio —</option>
@foreach($territori as $territorio)
<option value="{{ $territorio->id }}">{{ $territorio->numero }}</option>
@endforeach
</select>
<x-btn variante="primary" dimensione="sm" wire:click="collega({{ $piantina->id }})">Collega</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiCollegamento">Annulla</x-btn>
</div>
<p class="tm-campo-aiuto">
Il territorio da creare non c'è? Si crea dall'editor, senza perdere il disegno.
</p>
</div>
@endif
</li>
@endforeach
</ul>
@endif
</section>
{{-- ── Storico versioni PDF ──────────────────────────────────────────── --}}
<section class="tm-card">
<div class="tm-card-testata">
<h2 class="tm-card-titolo">Storico versioni PDF</h2>
<p class="tm-card-nota">I PDF sostituiti da una piantina. Il ripristino è sempre reversibile.</p>
<h2 class="tm-card-titolo">Storico versioni PDF <span class="tm-conteggio">{{ $versioni->total() }}</span></h2>
<p class="tm-card-nota">
I PDF dei territori sostituiti da una pubblicazione o da un ripristino. Ripristinare una versione
rimette in uso quel PDF: la piantina del territorio torna "da pubblicare".
</p>
</div>
@if($versioni->isEmpty())
@@ -205,6 +212,10 @@
</tbody>
</table>
</div>
<div class="mt-4">{{ $versioni->links() }}</div>
@endif
</section>
<livewire:piantine.pubblicazione />
</div>

View File

@@ -0,0 +1,54 @@
<div>
@if($mappaId)
<div class="tm-modale-sfondo" wire:keydown.escape.window="chiudi">
<div class="tm-modale tm-modale-larga" role="dialog" aria-modal="true" aria-labelledby="pubblicazione-titolo"
@if($fase === \App\Services\Piantine\PubblicazionePiantina::IN_CORSO) wire:poll.2s="aggiorna" @endif>
<header class="tm-modale-testata">
<h2 id="pubblicazione-titolo">Pubblica {{ $etichetta }}</h2>
<button type="button" wire:click="chiudi" class="tm-editor-chiudi" aria-label="Chiudi senza pubblicare">&times;</button>
</header>
<div class="tm-modale-corpo">
@if($fase === \App\Services\Piantine\PubblicazionePiantina::IN_CORSO)
<div class="tm-attesa">
<span class="tm-attesa-giro" aria-hidden="true"></span>
<div>
<p class="tm-attesa-titolo">Preparazione dell'anteprima…</p>
<p class="tm-editor-aiuto">
Immagine ad alta definizione e PDF sul modulo S-12. La prima volta per un
territorio può richiedere fino a un paio di minuti. Niente è ancora cambiato.
</p>
</div>
</div>
@elseif($fase === \App\Services\Piantine\PubblicazionePiantina::PRONTA)
<p class="tm-editor-aiuto">
Controlla il PDF. Confermando diventa il PDF del territorio, quello che arriva con i
link delle assegnazioni; il precedente resta nello storico e si può ripristinare.
</p>
<iframe src="{{ route('piantine.anteprima', $token) }}" class="tm-anteprima-pdf"
title="Anteprima del PDF da pubblicare"></iframe>
<a href="{{ route('piantine.anteprima', $token) }}" target="_blank" rel="noopener" class="tm-collegamento">
Apri l'anteprima in una nuova scheda
</a>
@else
<div class="tm-nota tm-nota-errore">
<p>{{ $messaggio ?: 'Qualcosa non ha funzionato.' }}</p>
</div>
@endif
</div>
<footer class="tm-modale-piede">
@if($fase === \App\Services\Piantine\PubblicazionePiantina::PRONTA)
<x-btn variante="primary" wire:click="conferma" wire:loading.attr="disabled" wire:target="conferma">
<span wire:loading.remove wire:target="conferma">Conferma e pubblica</span>
<span wire:loading wire:target="conferma">Pubblicazione…</span>
</x-btn>
@elseif($fase === \App\Services\Piantine\PubblicazionePiantina::ERRORE)
<x-btn variante="secondary" wire:click="riprova">Riprova</x-btn>
@endif
<x-btn variante="ghost" wire:click="chiudi">Annulla</x-btn>
</footer>
</div>
</div>
@endif
</div>