++ scelta di territori e proclamatori con ricerca

Al posto delle tendine (Assegna, Registro, editor delle piantine) un
campo in cui si scrive e compaiono le proposte più vicine: uguale, inizia
con, contiene, errore di battitura ("forse cercavi"); parole in qualsiasi
ordine, senza badare a maiuscole e accenti. Campo vuoto: la tendina
completa. Fatto anche per il telefono (16px, voci da 44px, scelta al tocco).

- Logica pura in resources/js/scelta.js con test node (tests/js).
- Opzioni dal server: Territorio/Proclamatore::opzioneScelta(); i
  territori già assegnati si trovano ("già assegnato a …") ma non si
  scelgono. Nomi cifrati ordinati e cercati decifrati, mai in SQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-19 15:35:56 +02:00
parent 2873424c3c
commit fda428f242
16 changed files with 814 additions and 76 deletions

View File

@@ -1686,3 +1686,125 @@ html { scroll-behavior: smooth; }
text-align: center;
}
}
/* ─── Campo di scelta con ricerca (<x-scelta>) ──────────────────────────────
Al posto delle tendine di territori e proclamatori. Pensato anche per il
telefono: testo a 16px (sotto, iOS ingrandisce la pagina al tocco), voci
alte almeno 44px, elenco che scorre dentro mezzo schermo. I selettori
battono `.tm-campo input[type="text"]` dell'editor delle piantine. */
.tm-scelta {
position: relative;
width: 100%;
}
.tm-scelta-campo {
position: relative;
display: flex;
align-items: center;
}
.tm-scelta input.tm-scelta-input {
width: 100%;
min-height: 44px;
padding: 8px 76px 8px 12px;
border: 1px solid var(--tm-bordo-forte);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
color: var(--tm-testo);
font-family: inherit;
font-size: 16px;
line-height: 1.3;
transition: border-color .15s ease, box-shadow .15s ease;
}
@media (min-width: 640px) {
.tm-scelta input.tm-scelta-input { min-height: 40px; font-size: 14px; }
}
.tm-scelta input.tm-scelta-input:focus {
outline: none;
border-color: var(--tm-accento);
box-shadow: 0 0 0 3px var(--tm-accento-tenue);
}
.tm-scelta input.tm-scelta-input:disabled {
padding-right: 12px;
background: var(--tm-superficie-alt);
color: var(--tm-testo-medio);
}
.tm-scelta input.tm-scelta-input::placeholder { color: var(--tm-testo-tenue); }
.tm-scelta-pulisci,
.tm-scelta-apri {
position: absolute;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-top: -18px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--tm-testo-tenue);
cursor: pointer;
}
.tm-scelta-pulisci { right: 38px; }
.tm-scelta-apri { right: 2px; }
.tm-scelta-pulisci:hover,
.tm-scelta-apri:hover { color: var(--tm-testo); background: var(--tm-superficie-alt); }
.tm-scelta-pulisci svg,
.tm-scelta-apri svg { width: 18px; height: 18px; transition: transform .15s ease; }
.tm-scelta-apri svg.tm-scelta-giu { transform: rotate(180deg); }
.tm-scelta-elenco {
position: absolute;
z-index: 40;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: min(50vh, 20rem);
margin: 0;
padding: 4px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
list-style: none;
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
}
.tm-scelta-voce {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 8px;
row-gap: 2px;
min-height: 44px;
padding: 10px 12px;
border-radius: 8px;
color: var(--tm-testo);
font-size: 15px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
@media (min-width: 640px) {
.tm-scelta-voce { min-height: 36px; padding: 7px 10px; font-size: 14px; }
}
.tm-scelta-voce-attiva { background: var(--tm-accento-tenue); }
.tm-scelta-voce-scelta .tm-scelta-etichetta { font-weight: 700; color: var(--tm-accento-scuro); }
.tm-scelta-voce-speciale { color: var(--tm-testo-medio); }
.tm-scelta-voce-spenta { cursor: not-allowed; color: var(--tm-testo-tenue); }
.tm-scelta-voce-spenta.tm-scelta-voce-attiva { background: var(--tm-superficie-alt); }
.tm-scelta-dettaglio { font-size: 13px; color: var(--tm-testo-tenue); }
.tm-scelta-motivo { flex-basis: 100%; font-size: 12px; color: var(--tm-attesa); }
.tm-scelta-forse {
padding: 1px 6px;
border-radius: 999px;
background: var(--tm-attesa-tenue);
color: var(--tm-attesa);
font-size: 11px;
font-weight: 600;
}
.tm-scelta-vuoto {
padding: 12px;
color: var(--tm-testo-tenue);
font-size: 14px;
}

View File

@@ -1,4 +1,111 @@
import './bootstrap';
import { proponi } 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', () => {
// 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;
if (daTocco) this.$refs.campo.blur();
},
scegliEvidenziato() {
if (this.aperto) this.scegli(this.risultati[this.evidenziato]);
},
svuota() {
this.valore = this.vuoto;
this.testo = '';
this.$refs.campo.focus();
this.aperto = true;
this.evidenziato = 0;
},
}));
});
// ─── Ritorno alla pagina di partenza ────────────────────────────────────────
//

101
resources/js/scelta.js Normal file
View File

@@ -0,0 +1,101 @@
// ─── Scelta con ricerca: la logica, senza DOM ───────────────────────────────
//
// Le proposte per quello che si scrive in un campo di scelta (territorio,
// proclamatore). Funzioni pure, provate da tests/js/scelta.test.mjs.
//
// Un'opzione è { valore, etichetta, dettaglio?, chiavi: [stringhe], motivo?,
// speciale? }:
// chiavi i campi in cui cercare, in ordine di importanza (cognome prima
// del nome, numero prima della zona)
// motivo l'opzione c'è ma non si può scegliere ("già assegnato a …"): si
// mostra solo cercando, mai nella tendina completa
// speciale sempre in cima e mai filtrata ("— nessun territorio —")
//
// Punteggio di ogni parola scritta, più basso = più vicino:
// 0 uguale · 1 inizia con · 2 contiene · 3 a una lettera di distanza
// ("forse cercavi"), più 0.1 per ogni posizione della chiave.
// Devono trovare posto tutte le parole scritte, in qualsiasi ordine
// ("mario ros" trova Rossi Mario).
export function normalizza(testo) {
return (testo ?? '')
.toString()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim();
}
/** Distanza di modifica tra due parole, fermandosi appena supera `limite`. */
export function distanza(a, b, limite = 2) {
if (Math.abs(a.length - b.length) > limite) return limite + 1;
let precedente = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
const riga = [i];
let minimo = i;
for (let j = 1; j <= b.length; j++) {
const costo = a[i - 1] === b[j - 1] ? 0 : 1;
riga[j] = Math.min(precedente[j] + 1, riga[j - 1] + 1, precedente[j - 1] + costo);
minimo = Math.min(minimo, riga[j]);
}
if (minimo > limite) return limite + 1;
precedente = riga;
}
return precedente[b.length];
}
const PERFETTO = 0, INIZIA = 1, CONTIENE = 2, VICINO = 3;
function punteggioParola(parola, chiavi) {
let migliore = Infinity;
chiavi.forEach((chiave, posizione) => {
if (chiave === '') return;
let p = Infinity;
if (chiave === parola) p = PERFETTO;
else if (chiave.startsWith(parola) || chiave.split(/\s+/).some((w) => w.startsWith(parola))) p = INIZIA;
else if (chiave.includes(parola)) p = CONTIENE;
else if (parola.length >= 4) {
// Errori di battitura: una lettera (due dalle 7 lettere in su), su
// parole intere. Mai sui numeri brevi: "35" non deve trovare "36".
const tolleranza = parola.length >= 7 ? 2 : 1;
if (chiave.split(/\s+/).some((w) => distanza(w, parola, tolleranza) <= tolleranza)) p = VICINO;
}
if (p !== Infinity) migliore = Math.min(migliore, p + posizione * 0.1);
});
return migliore;
}
/**
* Le opzioni da proporre per `testo`.
* Campo vuoto: la tendina completa (speciali e sceglibili, nell'ordine dato).
* Altrimenti: le opzioni in cui trovano posto tutte le parole, dalla più
* vicina; `forse` segna quelle trovate solo per un errore di battitura.
*/
export function proponi(opzioni, testo, massimo = 50) {
const speciali = opzioni.filter((o) => o.speciale);
const normali = opzioni.filter((o) => !o.speciale);
const parole = normalizza(testo).split(/\s+/).filter(Boolean);
if (parole.length === 0) {
return [...speciali, ...normali.filter((o) => !o.motivo)].map((o) => ({ ...o, forse: false }));
}
const trovate = [];
normali.forEach((opzione, ordine) => {
const chiavi = (opzione.chiavi ?? [opzione.etichetta]).map(normalizza);
let totale = 0;
let forse = false;
for (const parola of parole) {
const p = punteggioParola(parola, chiavi);
if (p === Infinity) return;
if (p >= VICINO) forse = true;
totale += p;
}
trovate.push({ opzione, totale, ordine, forse });
});
trovate.sort((a, b) => a.totale - b.totale || a.ordine - b.ordine);
return [...speciali.map((o) => ({ ...o, forse: false })),
...trovate.slice(0, massimo).map((t) => ({ ...t.opzione, forse: t.forse }))];
}

View File

@@ -0,0 +1,96 @@
{{--
Campo di scelta con ricerca, al posto di <select> per territori e
proclamatori. Si scrive e sotto compaiono le proposte più vicine (uguale,
inizia con, contiene, errore di battitura); con il campo vuoto o appena
aperto c'è la tendina completa. Logica in resources/js/scelta.js, provata
da tests/js/scelta.test.mjs; comportamento in resources/js/app.js.
id: id del campo (per la <label for>)
opzioni: array di opzioni, vedi Territorio::opzioneScelta() e
Proclamatore::opzioneScelta(); per le voci fisse in cima
['valore' => '', 'etichetta' => '…', 'speciale' => true]
segnaposto: testo nel campo vuoto
disabilitato: true per bloccare il campo (territorio già preselezionato)
vuoto: il valore di "nessuna scelta": '' (default) per le proprietà
stringa, null per quelle ?int
Il valore va a Livewire come con un <select>: wire:model sul componente.
Si ridisegna da capo quando cambiano le opzioni (wire:key con l'impronta),
perché x-data le legge una volta sola.
--}}
@props(['id', 'opzioni' => [], 'segnaposto' => 'Scrivi per cercare…', 'disabilitato' => false, 'vuoto' => ''])
@php($impronta = substr(md5(json_encode($opzioni)), 0, 12))
<div x-data="sceltaRicerca(@js(array_values($opzioni)), @js($vuoto))"
x-modelable="valore"
wire:key="scelta-{{ $id }}-{{ $impronta }}"
{{ $attributes->whereStartsWith('wire:model') }}
{{ $attributes->whereDoesntStartWith('wire:model')->class('tm-scelta') }}
x-on:click.outside="aperto && chiudi()"
x-on:keydown.escape="if (aperto) { $event.stopPropagation(); chiudi() }">
<div class="tm-scelta-campo">
<input x-ref="campo"
id="{{ $id }}"
type="text"
role="combobox"
aria-autocomplete="list"
aria-controls="{{ $id }}-elenco"
x-bind:aria-expanded="aperto ? 'true' : 'false'"
x-bind:aria-activedescendant="aperto && risultati.length ? '{{ $id }}-voce-' + evidenziato : null"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
inputmode="search" enterkeyhint="done"
placeholder="{{ $segnaposto }}"
@disabled($disabilitato)
x-model="testo"
x-on:focus="$el.select(); apri()"
x-on:click="apri()"
x-on:input="scrivendo()"
x-on:keydown.arrow-down.prevent="sposta(1)"
x-on:keydown.arrow-up.prevent="sposta(-1)"
x-on:keydown.enter.prevent="scegliEvidenziato()"
x-on:keydown.tab="aperto && chiudi()"
class="tm-scelta-input">
@unless($disabilitato)
<button type="button" class="tm-scelta-pulisci" x-show="testo !== ''" x-cloak
x-on:pointerdown.prevent x-on:click="svuota()"
aria-label="Svuota il campo" tabindex="-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
<button type="button" class="tm-scelta-apri"
x-on:pointerdown.prevent x-on:click="alterna()"
x-bind:aria-label="aperto ? 'Chiudi l\'elenco' : 'Apri l\'elenco'" tabindex="-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true" x-bind:class="aperto && 'tm-scelta-giu'"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
@endunless
</div>
<ul x-ref="elenco" id="{{ $id }}-elenco" role="listbox" class="tm-scelta-elenco" x-show="aperto" x-cloak>
<template x-for="(opzione, i) in risultati" :key="String(opzione.valore) + '|' + i">
<li x-bind:id="'{{ $id }}-voce-' + i"
role="option"
x-bind:aria-selected="String(opzione.valore) === String(valore ?? '') ? 'true' : 'false'"
x-bind:aria-disabled="opzione.motivo ? 'true' : null"
x-bind:data-evidenziato="i === evidenziato ? '1' : null"
class="tm-scelta-voce"
x-bind:class="{
'tm-scelta-voce-attiva': i === evidenziato,
'tm-scelta-voce-scelta': String(opzione.valore) === String(valore ?? ''),
'tm-scelta-voce-spenta': !! opzione.motivo,
'tm-scelta-voce-speciale': !! opzione.speciale,
}"
x-on:pointerdown.prevent
x-on:click="scegli(opzione, true)"
x-on:pointermove="evidenziato = i">
<span class="tm-scelta-etichetta" x-text="opzione.etichetta"></span>
<span class="tm-scelta-dettaglio" x-show="opzione.dettaglio" x-text="opzione.dettaglio"></span>
<span class="tm-scelta-motivo" x-show="opzione.motivo" x-text="opzione.motivo"></span>
<span class="tm-scelta-forse" x-show="opzione.forse">forse cercavi</span>
</li>
</template>
<li class="tm-scelta-vuoto" x-show="risultati.length === 0" role="presentation">
Nessun risultato per «<span x-text="testo"></span>»
</li>
</ul>
</div>

View File

@@ -127,25 +127,11 @@
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
<form wire:submit="save" class="space-y-4">
<div>
@if(!$preselectedTerritorioId)
<label for="territorio_search" class="block text-sm font-medium text-gray-700">Filtra territori</label>
<input wire:model.live.debounce.300ms="territorioSearch"
type="text"
id="territorio_search"
placeholder="Cerca per numero, zona o tipologia"
class="mt-1 mb-2 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@endif
<label for="territorio_id" class="block text-sm font-medium text-gray-700">Territorio *</label>
<select wire:model.live="territorio_id" id="territorio_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" @if($preselectedTerritorioId) disabled @endif>
<option value="">Seleziona un territorio</option>
@foreach($territoriDisponibili as $t)
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }} ({{ $t->tipologia?->nome }})</option>
@endforeach
</select>
@if($preselectedTerritorioId)
<input type="hidden" wire:model="territorio_id" value="{{ $preselectedTerritorioId }}">
@endif
<label for="territorio_id" class="block text-sm font-medium text-gray-700 mb-1">Territorio *</label>
<x-scelta id="territorio_id" wire:model.live="territorio_id"
:opzioni="$opzioniTerritori"
segnaposto="Numero, zona o tipologia" :vuoto="null"
:disabilitato="(bool) $preselectedTerritorioId" />
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@if($territorio_id)
@@ -167,13 +153,10 @@
</div>
<div>
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700">Proclamatore *</label>
<select wire:model="proclamatore_id" id="proclamatore_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<option value="">Seleziona un proclamatore</option>
@foreach($proclamatoriAttivi as $p)
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
@endforeach
</select>
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700 mb-1">Proclamatore *</label>
<x-scelta id="proclamatore_id" wire:model="proclamatore_id"
:opzioni="$opzioniProclamatori"
segnaposto="Cognome o nome" :vuoto="null" />
@error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>

View File

@@ -148,15 +148,8 @@
<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>
<x-scelta id="territorioScelto" wire:model.live="territorioScelto"
:opzioni="$opzioniTerritori" segnaposto="Numero, zona o tipologia" />
@error('territorioScelto') <p class="tm-campo-errore">{{ $message }}</p> @enderror
@if($mappa->territorioNelCestino())
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">

View File

@@ -188,25 +188,17 @@
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;">
{{-- Territorio --}}
<div style="grid-column:1/-1;">
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Territorio *</label>
<select wire:model="form_territorio_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
<option value="">-- seleziona --</option>
@foreach($territori as $t)
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }}</option>
@endforeach
</select>
<label for="form_territorio_id" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Territorio *</label>
<x-scelta id="form_territorio_id" wire:model="form_territorio_id"
:opzioni="$opzioniTerritori" segnaposto="Numero, zona o tipologia" />
@error('form_territorio_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div>
{{-- Proclamatore --}}
<div style="grid-column:1/-1;">
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Proclamatore *</label>
<select wire:model="form_proclamatore_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;">
<option value="">-- seleziona --</option>
@foreach($proclamatori as $p)
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
@endforeach
</select>
<label for="form_proclamatore_id" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Proclamatore *</label>
<x-scelta id="form_proclamatore_id" wire:model="form_proclamatore_id"
:opzioni="$opzioniProclamatori" segnaposto="Cognome o nome" />
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div>