- Dopo l'assegnazione si apre una finestra con i canali (copia link sempre; WhatsApp ed email solo se il proclamatore ha numero / indirizzo, l'email solo con la posta configurata) e la domanda "Hai inviato il link?". Si va avanti solo dopo la risposta. - L'email la manda l'app con notifyNow, mai in coda: l'indirizzo è cifrato nel database e non deve finire in chiaro nei job. - Procedura interrotta o "non ancora": assegnazioni.link_da_inviare resta vero e "Link ancora da inviare" compare nell'elenco Assegna e nella scheda territorio, con "Invia link" per riprendere. - Colonne link_da_inviare e link_canale (anche nell'XML); log con canale e proclamatore_id, senza dati personali. - "Nuovo territorio" apre il designer delle piantine già in disegno, con la prima piantina pronta a creare il territorio. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
14 KiB
JavaScript
340 lines
14 KiB
JavaScript
// Editor delle piantine (pagina /piantine/editor, solo amministratore).
|
|
//
|
|
// 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';
|
|
import 'leaflet-draw';
|
|
import 'leaflet-draw/dist/leaflet.draw.css';
|
|
|
|
const STILE_AREA = { color: '#4a45a8', weight: 2, dashArray: '8 6', fill: false, interactive: false };
|
|
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');
|
|
if (!contenitore || contenitore.dataset.inizializzato) {
|
|
return;
|
|
}
|
|
contenitore.dataset.inizializzato = '1';
|
|
|
|
const radice = contenitore.closest('[wire\\:id]');
|
|
const componente = () => window.Livewire.find(radice.getAttribute('wire:id'));
|
|
|
|
L.drawLocal.draw.handlers.polygon.tooltip = {
|
|
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;
|
|
let selezionata = null;
|
|
let modo = 'vista';
|
|
let gestoreDisegno = null;
|
|
let poligonoInModifica = null;
|
|
|
|
// ── Disegno delle piantine ──────────────────────────────────────────────
|
|
|
|
function stileDi(dati) {
|
|
if (dati.mappaId === selezionata) return STILE_SELEZIONATA;
|
|
return { dashArray: null, ...(STILI[dati.stato] ?? STILI.bozza) };
|
|
}
|
|
|
|
function disegna(dati) {
|
|
rimuovi(dati.mappaId, false);
|
|
if (!dati.geometry) return;
|
|
|
|
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();
|
|
|
|
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
|
|
}
|
|
|
|
// Arrivati da "Nuovo territorio": si comincia subito a disegnare.
|
|
if (componente().creaTerritorio) avviaDisegno(false);
|
|
}
|
|
|
|
mappa.setView([41.9, 12.5], 6);
|
|
|
|
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 });
|
|
}
|
|
|
|
// 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') {
|
|
document.addEventListener('DOMContentLoaded', initPiantine);
|
|
} else {
|
|
initPiantine();
|
|
}
|