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:
@@ -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;
|
||||
|
||||
@@ -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: '© <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') {
|
||||
|
||||
50
resources/views/components/avvisi.blade.php
Normal file
50
resources/views/components/avvisi.blade.php
Normal 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">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">×</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">×</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<x-avvisi :sessione="true" />
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
|
||||
@@ -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">×</button>
|
||||
<h2>{{ $mappa ? 'La piantina' : 'Come si comincia' }}</h2>
|
||||
<button type="button" @click="pannello = false" class="tm-editor-chiudi" aria-label="Chiudi il pannello">×</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: … Est: … Sud: … 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: … Est: … Sud: … 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
resources/views/livewire/piantine/pubblicazione.blade.php
Normal file
54
resources/views/livewire/piantine/pubblicazione.blade.php
Normal 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">×</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>
|
||||
Reference in New Issue
Block a user