Files
termanager2/resources/js/app.js
Francesco Picone 7384a4b39d ++ proclamatori senza email e telefono; il link si copia o si condivide
- Migrazione: tolte le colonne email e telefono dei proclamatori (vuote
  in produzione). Tolti da form, scheda, elenco, XML (un file vecchio si
  importa ignorandoli).
- Invio del link: solo "Copia link" e "Condividi…" (menu di condivisione
  del telefono, navigator.share): il destinatario lo sceglie chi usa
  l'app. Via WhatsApp diretto, canale email, notifica LinkTerritorio,
  componente x-whatsapp.
- Elenco assegnati: "Condividi" al posto di WhatsApp; a condivisione
  fatta il link risulta inviato.
- Informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:57:23 +02:00

182 lines
7.5 KiB
JavaScript

import './bootstrap';
import { proponi, normalizza } from './scelta';
// ─── Campo di scelta con ricerca (<x-scelta>) ───────────────────────────────
//
// Al posto della tendina: si scrive e sotto compaiono le proposte più vicine
// (logica in ./scelta.js). Campo vuoto o appena aperto: la tendina completa.
// Il valore scelto arriva a Livewire con x-modelable + wire:model.
document.addEventListener('alpine:init', () => {
// Elenco filtrato nel browser: ogni riga ha data-cerca (testo già senza
// accenti, minuscolo); valgono tutte le parole scritte, in qualsiasi
// ordine. Nessuna richiesta al server mentre si scrive.
window.Alpine.data('elencoCercabile', () => ({
q: '',
condivisibile: !! navigator.share,
// Condividi il link della riga (data-messaggio): il menu del telefono
// fa scegliere app e contatto. A condivisione fatta il link risulta
// inviato; se la si annulla non cambia niente.
condividi(pulsante) {
const riga = pulsante.closest('[data-link]');
if (!riga || !navigator.share) return;
navigator.share({ title: riga.dataset.titolo, text: riga.dataset.messaggio })
.then(() => this.$wire.segnaCondiviso(Number(riga.dataset.id)))
.catch(() => {});
},
corrisponde(testo) {
const parole = normalizza(this.q).split(/\s+/).filter(Boolean);
return parole.every((p) => (testo ?? '').includes(p));
},
get nessuno() {
if (normalizza(this.q) === '') return false;
return ![...this.$root.querySelectorAll('[data-cerca]')].some((riga) => this.corrisponde(riga.dataset.cerca));
},
// Copia il link della riga (il <code> accanto al pulsante); il pulsante
// mostra la spunta per due secondi (classe tm-copiato). Un metodo solo
// invece dello stesso codice ripetuto in ogni riga.
copia(pulsante) {
const testo = pulsante.closest('[data-link]')?.querySelector('code')?.textContent.trim() ?? '';
navigator.clipboard?.writeText(testo).then(() => {
pulsante.classList.add('tm-copiato');
setTimeout(() => pulsante.classList.remove('tm-copiato'), 2000);
}).catch(() => {});
},
}));
// vuoto: il valore di "nessuna scelta" per la proprietà Livewire collegata
// ('' per le stringhe, null per i ?int: '' su un ?int sarebbe un errore).
window.Alpine.data('sceltaRicerca', (opzioni, vuoto = '') => ({
opzioni,
vuoto,
valore: null,
testo: '',
aperto: false,
evidenziato: 0,
init() {
this.testo = this.etichettaDi(this.valore);
// Valore cambiato da Livewire (reset del form, scelta da codice).
this.$watch('valore', (v) => { if (!this.aperto) this.testo = this.etichettaDi(v); });
},
scelta() {
return this.opzioni.find((o) => String(o.valore) === String(this.valore ?? '')) ?? null;
},
etichettaDi(v) {
if (v === null || v === undefined || v === '') {
const vuota = this.opzioni.find((o) => o.speciale && o.valore === '');
return vuota ? vuota.etichetta : '';
}
const o = this.opzioni.find((x) => String(x.valore) === String(v));
return o ? o.etichetta : '';
},
// Finché nel campo c'è l'etichetta della scelta attuale, si vede la
// tendina completa: si cerca solo da quando si comincia a scrivere.
get ricerca() {
return this.testo === this.etichettaDi(this.valore) ? '' : this.testo;
},
get risultati() {
return proponi(this.opzioni, this.ricerca);
},
apri() {
if (this.aperto) return;
this.aperto = true;
const i = this.risultati.findIndex((o) => String(o.valore) === String(this.valore ?? ''));
this.evidenziato = Math.max(0, i);
this.$nextTick(() => this.mostraEvidenziato());
},
chiudi() {
this.aperto = false;
this.testo = this.etichettaDi(this.valore);
},
alterna() {
if (this.aperto) { this.chiudi(); return; }
this.$refs.campo.focus();
this.apri();
},
scrivendo() {
this.aperto = true;
this.evidenziato = 0;
},
sposta(passo) {
if (!this.aperto) { this.apri(); return; }
const n = this.risultati.length;
if (n === 0) return;
this.evidenziato = (this.evidenziato + passo + n) % n;
this.$nextTick(() => this.mostraEvidenziato());
},
mostraEvidenziato() {
this.$refs.elenco?.querySelector('[data-evidenziato="1"]')?.scrollIntoView({ block: 'nearest' });
},
// daTocco: scelta col dito o col mouse. Si toglie il focus, così sul
// telefono si chiude la tastiera; con frecce e Invio il focus resta.
scegli(opzione, daTocco = false) {
if (!opzione || opzione.motivo) return;
this.valore = opzione.valore;
this.aperto = false;
this.testo = opzione.etichetta;
// Per chi vuole reagire alla scelta senza passare dal server
// (l'anteprima della miniatura in Assegna).
this.$dispatch('scelta', { valore: opzione.valore, opzione });
if (daTocco) this.$refs.campo.blur();
},
scegliEvidenziato() {
if (this.aperto) this.scegli(this.risultati[this.evidenziato]);
},
svuota() {
this.valore = this.vuoto;
this.testo = '';
this.$dispatch('scelta', { valore: this.vuoto, opzione: null });
this.$refs.campo.focus();
this.aperto = true;
this.evidenziato = 0;
},
}));
});
// ─── Ritorno alla pagina di partenza ────────────────────────────────────────
//
// I link con data-ritorno (schede e operazioni) portano con sé ?da=<percorso>:
// la pagina di partenza con i suoi filtri, oppure il `da` che la pagina
// corrente aveva già ricevuto, così si risale sempre all'origine del percorso
// (elenco → scheda → Rientra → torna all'elenco). Il server lo valida e lo usa
// dopo conferma o annulla: App\Support\Ritorno, trait RicordaRitorno.
//
// Si calcola al momento dell'uso e non al caricamento: filtri e pagina degli
// elenchi cambiano l'indirizzo (#[Url] di Livewire) senza ricaricare.
function ritornoCorrente() {
const qui = new URL(window.location.href);
return qui.searchParams.get('da') || (qui.pathname + qui.search);
}
function aggiornaLink(link) {
const destinazione = new URL(link.getAttribute('href'), window.location.origin);
if (destinazione.origin !== window.location.origin) return;
destinazione.searchParams.set('da', ritornoCorrente());
link.setAttribute('href', destinazione.pathname + destinazione.search + destinazione.hash);
}
// Fase di cattura: si arriva prima di wire:navigate, che legge l'href al
// pointerdown (precaricamento) e al click. contextmenu e focusin coprono
// "apri in una nuova scheda" e la tastiera.
['pointerdown', 'click', 'contextmenu', 'focusin'].forEach((evento) => {
document.addEventListener(evento, (e) => {
const link = e.target instanceof Element ? e.target.closest('a[data-ritorno][href]') : null;
if (link) aggiornaLink(link);
}, true);
});