diff --git a/HANDOFF.md b/HANDOFF.md index 2a9fb17..4a07ea7 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -127,6 +127,33 @@ email di prova e di conferma arrivate, link aperto senza login, account sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione e dati di prova rimossi dopo. +### Scelta di territori e proclamatori con ricerca (19 ottobre 2026) + +Al posto delle tendine di territorio e proclamatore (Assegna, finestra del +Registro, editor delle piantine) c'è ``: si scrive e compaiono le +proposte più vicine; campo vuoto o appena aperto = la tendina completa. + +- Ordine: uguale → inizia con → contiene → a una lettera di distanza ("forse + cercavi", due lettere dalle 7 in su; mai sui numeri sotto le 4 cifre, così + "35" non trova "36"). Le parole scritte valgono in qualsiasi ordine ("mario + ros"); maiuscole e accenti non contano. Logica pura in + `resources/js/scelta.js`, provata da `tests/js/scelta.test.mjs` + (`npm run test:js`, oppure `node --test tests/js/`). +- Le opzioni le prepara il server (`Territorio::opzioneScelta()`, + `Proclamatore::opzioneScelta()/opzioniScelta()`). Con `motivo` un'opzione + compare cercando ma non si sceglie: in Assegna i territori già assegnati + ("già assegnato a …"), nel Registro territori e proclamatori disattivati. +- Nomi e cognomi sono cifrati: si ordinano e si cercano decifrati, nella + pagina. Prima il Registro li ordinava in SQL, cioè a caso. +- Telefono: testo a 16px sotto i 640px (iOS non ingrandisce), voci alte 44px, + elenco in mezzo schermo, scelta al tocco senza perdere il focus; dopo la + scelta col dito il campo perde il focus e la tastiera si chiude. +- Con Livewire: `wire:model` sul componente (x-modelable). `vuoto` è il valore + di "nessuna scelta": `null` per le proprietà `?int` (Assegna), `''` per le + stringhe. Il componente si ridisegna quando cambiano le opzioni (wire:key + con l'impronta), perché x-data le legge una volta sola. +- Il filtro "Filtra territori" di Assegna non serve più ed è stato tolto. + ### Si torna da dove si è partiti (19 ottobre 2026) Regola dell'utente: dopo un'operazione — confermata **o annullata** — si torna diff --git a/app/Livewire/Assegnazioni/Assegna.php b/app/Livewire/Assegnazioni/Assegna.php index bb76aa8..ddd8d9c 100644 --- a/app/Livewire/Assegnazioni/Assegna.php +++ b/app/Livewire/Assegnazioni/Assegna.php @@ -40,7 +40,6 @@ class Assegna extends Component public ?int $territorio_id = null; public ?int $proclamatore_id = null; public string $assigned_at = ''; - public string $territorioSearch = ''; public string $linkSearch = ''; // Optional pre-selection from parent context @@ -270,26 +269,20 @@ class Assegna extends Component public function render() { - $territoriQuery = Territorio::where('attivo', true) - ->whereDoesntHave('assegnazioni', fn($q) => $q->aperte()) - ->with(['zona', 'tipologia']); - - if (trim($this->territorioSearch) !== '') { - $term = trim($this->territorioSearch); - $territoriQuery->where(function ($q) use ($term) { - $q->where('numero', 'like', "%{$term}%") - ->orWhereHas('zona', fn($z) => $z->where('nome', 'like', "%{$term}%")) - ->orWhereHas('tipologia', fn($t) => $t->where('nome', 'like', "%{$term}%")); - }); - } - - $territoriDisponibili = $territoriQuery - ->orderBy('numero') - ->get(); - - $proclamatoriAttivi = Proclamatore::attivi() + // Le opzioni dei campi di scelta con ricerca (). Ci sono anche + // i territori già assegnati, che non si possono scegliere: cercandone + // uno si legge a chi è, invece di un "nessun risultato". + $opzioniTerritori = Territorio::attivi() + ->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']) ->get() - ->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome)); + ->sortBy('numero', SORT_NATURAL) + ->map(fn (Territorio $t) => $t->opzioneScelta( + $t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null + )) + ->values() + ->all(); + + $opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get()); // All currently assigned territories with links $assegnazioniAperte = Assegnazione::aperte() @@ -326,8 +319,8 @@ class Assegna extends Component return view('livewire.assegnazioni.assegna', [ 'invio' => $invio, 'postaAttiva' => $invio ? app(ConfigurazionePosta::class)->funzionante() : false, - 'territoriDisponibili' => $territoriDisponibili, - 'proclamatoriAttivi' => $proclamatoriAttivi, + 'opzioniTerritori' => $opzioniTerritori, + 'opzioniProclamatori' => $opzioniProclamatori, 'assegnazioniAperte' => $assegnazioniAperte, 'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili, 'sogliaRientro' => $sogliaRientro, diff --git a/app/Livewire/Piantine/PiantineEditor.php b/app/Livewire/Piantine/PiantineEditor.php index 35122b2..8c22130 100644 --- a/app/Livewire/Piantine/PiantineEditor.php +++ b/app/Livewire/Piantine/PiantineEditor.php @@ -577,18 +577,33 @@ class PiantineEditor extends Component ->pluck('territorio_id'); $territori = Territorio::attivi() + ->with(['zona:id,nome', 'tipologia:id,nome']) ->whereNotIn('id', $occupati) ->orderByRaw('LENGTH(numero), numero') - ->get(['id', 'numero', 'attivo', 'deleted_at']); + ->get(['id', 'numero', 'zona_id', 'tipologia_id', 'attivo', 'deleted_at']); if ($mappa?->territorio && ! $territori->contains('id', $mappa->territorio_id)) { $territori->prepend($mappa->territorio); } + // Il campo di scelta con ricerca (): due voci fisse in cima, + // poi i territori, che qui restano sceglibili anche se cestinati o + // disattivati (è il collegamento che già c'era). + $opzioniTerritori = [ + ['valore' => '', 'etichetta' => '— nessun territorio —', 'speciale' => true], + ['valore' => 'nuovo', 'etichetta' => '+ Crea un territorio nuovo', 'speciale' => true], + ...$territori->map(function (Territorio $t) { + $opzione = $t->opzioneScelta(nota: $t->trashed() ? 'nel cestino' : (! $t->attivo ? 'disattivato' : null)); + $opzione['etichetta'] = str_replace('N° ', 'Territorio ', $opzione['etichetta']); + + return $opzione; + })->all(), + ]; + return view('livewire.piantine.piantine-editor', [ 'mappa' => $mappa, 'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null, - 'territori' => $territori, + 'opzioniTerritori' => $opzioniTerritori, 'zone' => Zona::orderBy('nome')->get(['id', 'nome']), 'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']), 'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine', diff --git a/app/Livewire/Registro.php b/app/Livewire/Registro.php index da5d4db..0f4117d 100644 --- a/app/Livewire/Registro.php +++ b/app/Livewire/Registro.php @@ -348,8 +348,14 @@ class Registro extends Component 'anni' => AnnoTeocratico::orderByDesc('start_date')->get(), 'zone' => Zona::attive()->orderBy('nome')->get(), 'tipologie' => Tipologia::orderBy('nome')->get(), - 'territori' => Territorio::attivi()->orderBy('numero')->get(), - 'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(), + // Campi di scelta con ricerca della finestra di modifica. Ci sono + // anche i disattivati, non sceglibili: modificando una vecchia + // assegnazione il campo mostra comunque il nome giusto. + 'opzioniTerritori' => Territorio::with(['zona', 'tipologia'])->get() + ->sortBy('numero', SORT_NATURAL) + ->map(fn (Territorio $t) => $t->opzioneScelta($t->attivo ? null : 'territorio disattivato')) + ->values()->all(), + 'opzioniProclamatori' => Proclamatore::opzioniScelta(Proclamatore::all()), 'campagne' => Campagna::orderByDesc('created_at')->get(), ]); } diff --git a/app/Models/Proclamatore.php b/app/Models/Proclamatore.php index e8fc7d1..1d86328 100644 --- a/app/Models/Proclamatore.php +++ b/app/Models/Proclamatore.php @@ -83,6 +83,34 @@ class Proclamatore extends Model return 'https://wa.me/' . $numero . ($messaggio !== null && $messaggio !== '' ? '?text=' . rawurlencode($messaggio) : ''); } + /** + * L'opzione per il campo di scelta con ricerca (): si cerca per + * cognome e nome. Nome e cognome sono cifrati nel database: la ricerca + * avviene sui valori già decifrati, nella pagina, mai in SQL. + */ + public function opzioneScelta(?string $motivo = null): array + { + return array_filter([ + 'valore' => (string) $this->id, + 'etichetta' => $this->nome_completo, + 'chiavi' => [(string) $this->cognome, (string) $this->nome], + 'motivo' => $motivo, + ], fn ($v) => $v !== null); + } + + /** + * Le opzioni di un elenco di proclamatori, in ordine di cognome e nome. + * L'ordine si fa qui: in SQL ordinerebbe i testi cifrati. + */ + public static function opzioniScelta(iterable $proclamatori): array + { + return collect($proclamatori) + ->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome)) + ->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo')) + ->values() + ->all(); + } + public function assegnazioni() { return $this->hasMany(Assegnazione::class, 'proclamatore_id'); diff --git a/app/Models/Territorio.php b/app/Models/Territorio.php index f7a4804..a35a0e4 100644 --- a/app/Models/Territorio.php +++ b/app/Models/Territorio.php @@ -145,6 +145,24 @@ class Territorio extends Model return false; } + /** + * L'opzione per il campo di scelta con ricerca (): si cerca per + * numero, zona e tipologia. Con $motivo compare cercando ma non si può + * scegliere ("già assegnato a …"). + */ + public function opzioneScelta(?string $motivo = null, ?string $nota = null): array + { + $dettaglio = trim(($this->zona?->nome ?? '') . ($this->tipologia?->nome ? " ({$this->tipologia->nome})" : '')); + + return array_filter([ + 'valore' => (string) $this->id, + 'etichetta' => "N° {$this->numero}" . ($nota ? " ({$nota})" : ''), + 'dettaglio' => $dettaglio !== '' ? $dettaglio : null, + 'chiavi' => [(string) $this->numero, (string) $this->zona?->nome, (string) $this->tipologia?->nome], + 'motivo' => $motivo, + ], fn ($v) => $v !== null); + } + // ─── Scopes ───────────────────────────────────────────────── public function scopeAttivi($query) diff --git a/package.json b/package.json index 37df51e..2dfb29d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build" + "build": "vite build", + "test:js": "node --test tests/js/" }, "devDependencies": { "@tailwindcss/forms": "^0.5", diff --git a/resources/css/app.css b/resources/css/app.css index 0ff81fa..230beef 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1686,3 +1686,125 @@ html { scroll-behavior: smooth; } text-align: center; } } + +/* ─── Campo di scelta con ricerca () ────────────────────────────── + 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; +} diff --git a/resources/js/app.js b/resources/js/app.js index 4519fb5..fd967be 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,111 @@ import './bootstrap'; +import { proponi } from './scelta'; + +// ─── Campo di scelta con ricerca () ─────────────────────────────── +// +// 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 ──────────────────────────────────────── // diff --git a/resources/js/scelta.js b/resources/js/scelta.js new file mode 100644 index 0000000..a8c5efe --- /dev/null +++ b/resources/js/scelta.js @@ -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 }))]; +} diff --git a/resources/views/components/scelta.blade.php b/resources/views/components/scelta.blade.php new file mode 100644 index 0000000..5728872 --- /dev/null +++ b/resources/views/components/scelta.blade.php @@ -0,0 +1,96 @@ +{{-- + Campo di scelta con ricerca, al posto di : 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)) + +
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() }"> +
+ + + @unless($disabilitato) + + + @endunless +
+ +
    + + +
+
diff --git a/resources/views/livewire/assegnazioni/assegna.blade.php b/resources/views/livewire/assegnazioni/assegna.blade.php index 31e9b7e..57a7dd1 100644 --- a/resources/views/livewire/assegnazioni/assegna.blade.php +++ b/resources/views/livewire/assegnazioni/assegna.blade.php @@ -127,25 +127,11 @@
- @if(!$preselectedTerritorioId) - - - @endif - - - - @if($preselectedTerritorioId) - - @endif + + @error('territorio_id')

{{ $message }}

@enderror @if($territorio_id) @@ -167,13 +153,10 @@
- - + + @error('proclamatore_id')

{{ $message }}

@enderror
diff --git a/resources/views/livewire/piantine/piantine-editor.blade.php b/resources/views/livewire/piantine/piantine-editor.blade.php index 75f48e1..0aa72d6 100644 --- a/resources/views/livewire/piantine/piantine-editor.blade.php +++ b/resources/views/livewire/piantine/piantine-editor.blade.php @@ -148,15 +148,8 @@
- + @error('territorioScelto')

{{ $message }}

@enderror @if($mappa->territorioNelCestino())

diff --git a/resources/views/livewire/registro.blade.php b/resources/views/livewire/registro.blade.php index fc6adf7..1eae727 100644 --- a/resources/views/livewire/registro.blade.php +++ b/resources/views/livewire/registro.blade.php @@ -188,25 +188,17 @@

{{-- Territorio --}}
- - + + @error('form_territorio_id') {{ $message }} @enderror
{{-- Proclamatore --}}
- - + + @error('form_proclamatore_id') {{ $message }} @enderror
diff --git a/tests/Feature/SceltaRicercaTest.php b/tests/Feature/SceltaRicercaTest.php new file mode 100644 index 0000000..8bacc0b --- /dev/null +++ b/tests/Feature/SceltaRicercaTest.php @@ -0,0 +1,158 @@ +) per territori e proclamatori: + * le opzioni che il server prepara. Il modo in cui si filtrano e ordinano + * mentre si scrive è provato in tests/js/scelta.test.mjs. + */ +class SceltaRicercaTest extends TestCase +{ + use RefreshDatabase; + + private User $admin; + + protected function setUp(): void + { + parent::setUp(); + $this->withoutVite(); + Storage::fake('public'); + Storage::fake('local'); + $this->seed(RolesAndPermissionsSeeder::class); + Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']); + AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']); + + $this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']); + $this->admin->assignRole('amministratore'); + } + + private function etichette(array $opzioni): array + { + return array_column($opzioni, 'etichetta'); + } + + public function test_assegna_usa_i_campi_con_ricerca(): void + { + $this->actingAs($this->admin) + ->get(route('assegnazioni.assegna')) + ->assertOk() + ->assertSee('role="combobox"', false) + ->assertSee('id="territorio_id"', false) + ->assertSee('id="proclamatore_id"', false) + ->assertDontSee('Filtra territori'); + } + + public function test_un_territorio_gia_assegnato_si_trova_ma_non_si_sceglie(): void + { + $zona = Zona::create(['nome' => 'Zisa', 'attivo' => true]); + $libero = Territorio::create(['numero' => '12', 'attivo' => true, 'zona_id' => $zona->id]); + $occupato = Territorio::create(['numero' => '35', 'attivo' => true]); + $mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]); + Assegnazione::create([ + 'territorio_id' => $occupato->id, 'proclamatore_id' => $mario->id, + 'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id, + ]); + + $opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniTerritori'); + $perValore = collect($opzioni)->keyBy('valore'); + + $this->assertSame(['valore' => (string) $libero->id, 'etichetta' => 'N° 12', 'dettaglio' => 'Zisa', 'chiavi' => ['12', 'Zisa', '']], $perValore[(string) $libero->id]); + $this->assertSame('già assegnato a Rossi Mario', $perValore[(string) $occupato->id]['motivo']); + $this->assertArrayNotHasKey('motivo', $perValore[(string) $libero->id]); + } + + public function test_i_territori_sono_in_ordine_numerico(): void + { + foreach (['100', '9', '35', '12a', '12'] as $numero) { + Territorio::create(['numero' => $numero, 'attivo' => true]); + } + + $opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniTerritori'); + + $this->assertSame(['N° 9', 'N° 12', 'N° 12a', 'N° 35', 'N° 100'], $this->etichette($opzioni)); + } + + public function test_i_proclamatori_sono_in_ordine_di_cognome_decifrato(): void + { + // Nel database sono cifrati: un ORDER BY li metterebbe a caso. + Proclamatore::create(['nome' => 'Paolo', 'cognome' => 'Verdi', 'attivo' => true]); + Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Rosselli', 'attivo' => true]); + Proclamatore::create(['nome' => 'Luca', 'cognome' => 'Bianchi', 'attivo' => true]); + + $opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniProclamatori'); + + $this->assertSame(['Bianchi Luca', 'Rosselli Anna', 'Verdi Paolo'], $this->etichette($opzioni)); + $this->assertSame(['Bianchi', 'Luca'], $opzioni[0]['chiavi']); + } + + public function test_assegna_propone_solo_proclamatori_attivi(): void + { + Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]); + Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => false]); + + $opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniProclamatori'); + + $this->assertSame(['Rossi Mario'], $this->etichette($opzioni)); + } + + public function test_si_assegna_con_il_valore_scelto_nel_campo(): void + { + // Il campo manda a Livewire il valore come stringa, come faceva