++ 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:
27
HANDOFF.md
27
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
|
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
|
||||||
e dati di prova rimossi dopo.
|
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'è `<x-scelta>`: 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)
|
### Si torna da dove si è partiti (19 ottobre 2026)
|
||||||
|
|
||||||
Regola dell'utente: dopo un'operazione — confermata **o annullata** — si torna
|
Regola dell'utente: dopo un'operazione — confermata **o annullata** — si torna
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ class Assegna extends Component
|
|||||||
public ?int $territorio_id = null;
|
public ?int $territorio_id = null;
|
||||||
public ?int $proclamatore_id = null;
|
public ?int $proclamatore_id = null;
|
||||||
public string $assigned_at = '';
|
public string $assigned_at = '';
|
||||||
public string $territorioSearch = '';
|
|
||||||
public string $linkSearch = '';
|
public string $linkSearch = '';
|
||||||
|
|
||||||
// Optional pre-selection from parent context
|
// Optional pre-selection from parent context
|
||||||
@@ -270,26 +269,20 @@ class Assegna extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$territoriQuery = Territorio::where('attivo', true)
|
// Le opzioni dei campi di scelta con ricerca (<x-scelta>). Ci sono anche
|
||||||
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte())
|
// i territori già assegnati, che non si possono scegliere: cercandone
|
||||||
->with(['zona', 'tipologia']);
|
// uno si legge a chi è, invece di un "nessun risultato".
|
||||||
|
$opzioniTerritori = Territorio::attivi()
|
||||||
if (trim($this->territorioSearch) !== '') {
|
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore'])
|
||||||
$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()
|
|
||||||
->get()
|
->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
|
// All currently assigned territories with links
|
||||||
$assegnazioniAperte = Assegnazione::aperte()
|
$assegnazioniAperte = Assegnazione::aperte()
|
||||||
@@ -326,8 +319,8 @@ class Assegna extends Component
|
|||||||
return view('livewire.assegnazioni.assegna', [
|
return view('livewire.assegnazioni.assegna', [
|
||||||
'invio' => $invio,
|
'invio' => $invio,
|
||||||
'postaAttiva' => $invio ? app(ConfigurazionePosta::class)->funzionante() : false,
|
'postaAttiva' => $invio ? app(ConfigurazionePosta::class)->funzionante() : false,
|
||||||
'territoriDisponibili' => $territoriDisponibili,
|
'opzioniTerritori' => $opzioniTerritori,
|
||||||
'proclamatoriAttivi' => $proclamatoriAttivi,
|
'opzioniProclamatori' => $opzioniProclamatori,
|
||||||
'assegnazioniAperte' => $assegnazioniAperte,
|
'assegnazioniAperte' => $assegnazioniAperte,
|
||||||
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili,
|
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili,
|
||||||
'sogliaRientro' => $sogliaRientro,
|
'sogliaRientro' => $sogliaRientro,
|
||||||
|
|||||||
@@ -577,18 +577,33 @@ class PiantineEditor extends Component
|
|||||||
->pluck('territorio_id');
|
->pluck('territorio_id');
|
||||||
|
|
||||||
$territori = Territorio::attivi()
|
$territori = Territorio::attivi()
|
||||||
|
->with(['zona:id,nome', 'tipologia:id,nome'])
|
||||||
->whereNotIn('id', $occupati)
|
->whereNotIn('id', $occupati)
|
||||||
->orderByRaw('LENGTH(numero), numero')
|
->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)) {
|
if ($mappa?->territorio && ! $territori->contains('id', $mappa->territorio_id)) {
|
||||||
$territori->prepend($mappa->territorio);
|
$territori->prepend($mappa->territorio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Il campo di scelta con ricerca (<x-scelta>): 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', [
|
return view('livewire.piantine.piantine-editor', [
|
||||||
'mappa' => $mappa,
|
'mappa' => $mappa,
|
||||||
'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
|
'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
|
||||||
'territori' => $territori,
|
'opzioniTerritori' => $opzioniTerritori,
|
||||||
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
||||||
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
|
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
|
||||||
'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine',
|
'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine',
|
||||||
|
|||||||
@@ -348,8 +348,14 @@ class Registro extends Component
|
|||||||
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
|
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
|
||||||
'zone' => Zona::attive()->orderBy('nome')->get(),
|
'zone' => Zona::attive()->orderBy('nome')->get(),
|
||||||
'tipologie' => Tipologia::orderBy('nome')->get(),
|
'tipologie' => Tipologia::orderBy('nome')->get(),
|
||||||
'territori' => Territorio::attivi()->orderBy('numero')->get(),
|
// Campi di scelta con ricerca della finestra di modifica. Ci sono
|
||||||
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(),
|
// 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(),
|
'campagne' => Campagna::orderByDesc('created_at')->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,34 @@ class Proclamatore extends Model
|
|||||||
return 'https://wa.me/' . $numero . ($messaggio !== null && $messaggio !== '' ? '?text=' . rawurlencode($messaggio) : '');
|
return 'https://wa.me/' . $numero . ($messaggio !== null && $messaggio !== '' ? '?text=' . rawurlencode($messaggio) : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'opzione per il campo di scelta con ricerca (<x-scelta>): 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()
|
public function assegnazioni()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
|
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
|
||||||
|
|||||||
@@ -145,6 +145,24 @@ class Territorio extends Model
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'opzione per il campo di scelta con ricerca (<x-scelta>): 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 ─────────────────────────────────────────────────
|
// ─── Scopes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
public function scopeAttivi($query)
|
public function scopeAttivi($query)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build"
|
"build": "vite build",
|
||||||
|
"test:js": "node --test tests/js/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5",
|
"@tailwindcss/forms": "^0.5",
|
||||||
|
|||||||
@@ -1686,3 +1686,125 @@ html { scroll-behavior: smooth; }
|
|||||||
text-align: center;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,111 @@
|
|||||||
import './bootstrap';
|
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 ────────────────────────────────────────
|
// ─── Ritorno alla pagina di partenza ────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|||||||
101
resources/js/scelta.js
Normal file
101
resources/js/scelta.js
Normal 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 }))];
|
||||||
|
}
|
||||||
96
resources/views/components/scelta.blade.php
Normal file
96
resources/views/components/scelta.blade.php
Normal 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>
|
||||||
@@ -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">
|
<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">
|
<form wire:submit="save" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@if(!$preselectedTerritorioId)
|
<label for="territorio_id" class="block text-sm font-medium text-gray-700 mb-1">Territorio *</label>
|
||||||
<label for="territorio_search" class="block text-sm font-medium text-gray-700">Filtra territori</label>
|
<x-scelta id="territorio_id" wire:model.live="territorio_id"
|
||||||
<input wire:model.live.debounce.300ms="territorioSearch"
|
:opzioni="$opzioniTerritori"
|
||||||
type="text"
|
segnaposto="Numero, zona o tipologia" :vuoto="null"
|
||||||
id="territorio_search"
|
:disabilitato="(bool) $preselectedTerritorioId" />
|
||||||
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
|
|
||||||
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
|
|
||||||
@if($territorio_id)
|
@if($territorio_id)
|
||||||
@@ -167,13 +153,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700">Proclamatore *</label>
|
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700 mb-1">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">
|
<x-scelta id="proclamatore_id" wire:model="proclamatore_id"
|
||||||
<option value="">Seleziona un proclamatore</option>
|
:opzioni="$opzioniProclamatori"
|
||||||
@foreach($proclamatoriAttivi as $p)
|
segnaposto="Cognome o nome" :vuoto="null" />
|
||||||
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
@error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
@error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -148,15 +148,8 @@
|
|||||||
|
|
||||||
<div class="tm-campo">
|
<div class="tm-campo">
|
||||||
<label for="territorioScelto">Collegata a</label>
|
<label for="territorioScelto">Collegata a</label>
|
||||||
<select id="territorioScelto" wire:model.live="territorioScelto">
|
<x-scelta id="territorioScelto" wire:model.live="territorioScelto"
|
||||||
<option value="">— nessun territorio —</option>
|
:opzioni="$opzioniTerritori" segnaposto="Numero, zona o tipologia" />
|
||||||
<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
|
@error('territorioScelto') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||||
@if($mappa->territorioNelCestino())
|
@if($mappa->territorioNelCestino())
|
||||||
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">
|
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">
|
||||||
|
|||||||
@@ -188,25 +188,17 @@
|
|||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;">
|
||||||
{{-- Territorio --}}
|
{{-- Territorio --}}
|
||||||
<div style="grid-column:1/-1;">
|
<div style="grid-column:1/-1;">
|
||||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Territorio *</label>
|
<label for="form_territorio_id" 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;">
|
<x-scelta id="form_territorio_id" wire:model="form_territorio_id"
|
||||||
<option value="">-- seleziona --</option>
|
:opzioni="$opzioniTerritori" segnaposto="Numero, zona o tipologia" />
|
||||||
@foreach($territori as $t)
|
|
||||||
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
@error('form_territorio_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
@error('form_territorio_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Proclamatore --}}
|
{{-- Proclamatore --}}
|
||||||
<div style="grid-column:1/-1;">
|
<div style="grid-column:1/-1;">
|
||||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Proclamatore *</label>
|
<label for="form_proclamatore_id" 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;">
|
<x-scelta id="form_proclamatore_id" wire:model="form_proclamatore_id"
|
||||||
<option value="">-- seleziona --</option>
|
:opzioni="$opzioniProclamatori" segnaposto="Cognome o nome" />
|
||||||
@foreach($proclamatori as $p)
|
|
||||||
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
158
tests/Feature/SceltaRicercaTest.php
Normal file
158
tests/Feature/SceltaRicercaTest.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Assegnazioni\Assegna;
|
||||||
|
use App\Livewire\Piantine\PiantineEditor;
|
||||||
|
use App\Livewire\Registro;
|
||||||
|
use App\Models\AnnoTeocratico;
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use App\Models\TerritorioMappa;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Zona;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I campi di scelta con ricerca (<x-scelta>) 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 <select>.
|
||||||
|
Storage::disk('public')->put('territori-pdf/t7.pdf', '%PDF-1.4');
|
||||||
|
$territorio = Territorio::create(['numero' => '7', 'attivo' => true, 'pdf_path' => 'territori-pdf/t7.pdf']);
|
||||||
|
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
|
||||||
|
|
||||||
|
Livewire::actingAs($this->admin)->test(Assegna::class)
|
||||||
|
->set('territorio_id', (string) $territorio->id)
|
||||||
|
->set('proclamatore_id', (string) $mario->id)
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSame($mario->id, Assegnazione::sole()->proclamatore_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nel_registro_i_disattivati_ci_sono_ma_non_si_scelgono(): void
|
||||||
|
{
|
||||||
|
Territorio::create(['numero' => '5', 'attivo' => false]);
|
||||||
|
Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => false]);
|
||||||
|
|
||||||
|
$pagina = Livewire::actingAs($this->admin)->test(Registro::class);
|
||||||
|
|
||||||
|
$this->assertSame('territorio disattivato', $pagina->viewData('opzioniTerritori')[0]['motivo']);
|
||||||
|
$this->assertSame('non attivo', $pagina->viewData('opzioniProclamatori')[0]['motivo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nell_editor_le_voci_fisse_restano_in_cima(): void
|
||||||
|
{
|
||||||
|
Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||||
|
$mappa = TerritorioMappa::create(['geojson' => json_encode([
|
||||||
|
'type' => 'Polygon', 'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
|
||||||
|
])]);
|
||||||
|
|
||||||
|
$opzioni = Livewire::actingAs($this->admin)
|
||||||
|
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||||
|
->viewData('opzioniTerritori');
|
||||||
|
|
||||||
|
$this->assertSame(['— nessun territorio —', '+ Crea un territorio nuovo', 'Territorio 12'], $this->etichette($opzioni));
|
||||||
|
$this->assertTrue($opzioni[0]['speciale']);
|
||||||
|
$this->assertSame('', $opzioni[0]['valore']);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
tests/js/scelta.test.mjs
Normal file
98
tests/js/scelta.test.mjs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// Prove della ricerca nei campi di scelta (resources/js/scelta.js).
|
||||||
|
// Si eseguono con: node --test tests/js/
|
||||||
|
// Gli esempi sono quelli concordati con l'utente il 19 ottobre 2026.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { proponi, normalizza, distanza } from '../../resources/js/scelta.js';
|
||||||
|
|
||||||
|
const territorio = (numero, zona = 'Montepellegrino', tipologia = 'Residenziale', motivo) => ({
|
||||||
|
valore: numero, etichetta: `N° ${numero}`, dettaglio: `${zona} (${tipologia})`,
|
||||||
|
chiavi: [numero, zona, tipologia], ...(motivo ? { motivo } : {}),
|
||||||
|
});
|
||||||
|
const proclamatore = (cognome, nome, motivo) => ({
|
||||||
|
valore: `${cognome}-${nome}`, etichetta: `${cognome} ${nome}`, chiavi: [cognome, nome], ...(motivo ? { motivo } : {}),
|
||||||
|
});
|
||||||
|
const etichette = (risultati) => risultati.map((o) => o.etichetta);
|
||||||
|
|
||||||
|
const territori = [
|
||||||
|
territorio('12', 'Zisa'), territorio('35'), territorio('135', 'Noce'), territorio('235', 'Cuba'),
|
||||||
|
territorio('350'), territorio('351'), territorio('36'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const proclamatori = [
|
||||||
|
proclamatore('Bianchi', 'Rosa'), proclamatore('Grosso', 'Luca'), proclamatore('Neri', 'Nicolò'),
|
||||||
|
proclamatore('Rosselli', 'Anna'), proclamatore('Rossi', 'Mario'), proclamatore('Verdi', 'Paolo'),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('esempio 1: "35" mette prima l\'uguale, poi chi inizia, poi chi contiene', () => {
|
||||||
|
assert.deepEqual(etichette(proponi(territori, '35')), ['N° 35', 'N° 350', 'N° 351', 'N° 135', 'N° 235']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('i numeri brevi non hanno tolleranza: "35" non trova il 36', () => {
|
||||||
|
assert.ok(!etichette(proponi(territori, '35')).includes('N° 36'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('esempio 1: "monte" trova i territori della zona Montepellegrino', () => {
|
||||||
|
assert.deepEqual(etichette(proponi(territori, 'monte')), ['N° 35', 'N° 350', 'N° 351', 'N° 36']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('esempio 2: "ros" mette prima il cognome, poi il nome, poi chi contiene', () => {
|
||||||
|
assert.deepEqual(etichette(proponi(proclamatori, 'ros')), ['Rosselli Anna', 'Rossi Mario', 'Bianchi Rosa', 'Grosso Luca']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maiuscole e accenti non contano', () => {
|
||||||
|
assert.deepEqual(etichette(proponi(proclamatori, 'NICOLO')), ['Neri Nicolò']);
|
||||||
|
assert.equal(normalizza(' Nicolò '), 'nicolo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nome e cognome in qualsiasi ordine', () => {
|
||||||
|
assert.deepEqual(etichette(proponi(proclamatori, 'mario ros')), ['Rossi Mario']);
|
||||||
|
assert.deepEqual(etichette(proponi(proclamatori, 'rossi mario')), ['Rossi Mario']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('esempio 3: "rosi" propone Rossi come "forse cercavi"', () => {
|
||||||
|
const risultati = proponi(proclamatori, 'rosi');
|
||||||
|
const rossi = risultati.find((o) => o.etichetta === 'Rossi Mario');
|
||||||
|
assert.ok(rossi, 'Rossi Mario deve essere proposto');
|
||||||
|
assert.equal(rossi.forse, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una corrispondenza vera viene prima di un errore di battitura', () => {
|
||||||
|
const elenco = [proclamatore('Rossi', 'Mario'), proclamatore('Rosi', 'Carla')];
|
||||||
|
const risultati = proponi(elenco, 'rosi');
|
||||||
|
assert.equal(risultati[0].etichetta, 'Rosi Carla');
|
||||||
|
assert.equal(risultati[0].forse, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('campo vuoto: la tendina completa, senza le voci non sceglibili', () => {
|
||||||
|
const elenco = [territorio('7'), territorio('8', 'Zisa', 'Residenziale', 'già assegnato a Rossi Mario')];
|
||||||
|
assert.deepEqual(etichette(proponi(elenco, '')), ['N° 7']);
|
||||||
|
assert.deepEqual(etichette(proponi(elenco, ' ')), ['N° 7']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cercando, le voci non sceglibili compaiono con il motivo', () => {
|
||||||
|
const elenco = [territorio('7'), territorio('8', 'Zisa', 'Residenziale', 'già assegnato a Rossi Mario')];
|
||||||
|
const [occupato] = proponi(elenco, '8');
|
||||||
|
assert.equal(occupato.etichetta, 'N° 8');
|
||||||
|
assert.equal(occupato.motivo, 'già assegnato a Rossi Mario');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le opzioni speciali restano sempre in cima', () => {
|
||||||
|
const elenco = [
|
||||||
|
{ valore: '', etichetta: '— nessun territorio —', speciale: true },
|
||||||
|
{ valore: 'nuovo', etichetta: '+ Crea un territorio nuovo', speciale: true },
|
||||||
|
territorio('35'), territorio('12', 'Zisa'),
|
||||||
|
];
|
||||||
|
assert.deepEqual(etichette(proponi(elenco, 'zisa')), ['— nessun territorio —', '+ Crea un territorio nuovo', 'N° 12']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nessun risultato: elenco vuoto', () => {
|
||||||
|
assert.deepEqual(proponi(proclamatori, 'zzzz'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distanza di modifica', () => {
|
||||||
|
assert.equal(distanza('rosi', 'rossi'), 1);
|
||||||
|
assert.equal(distanza('kitten', 'sitting', 3), 3);
|
||||||
|
assert.equal(distanza('abc', 'abcdef', 1), 2); // oltre il limite: limite + 1
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user