diff --git a/HANDOFF.md b/HANDOFF.md index 4a07ea7..5e6fd03 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -127,6 +127,38 @@ 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. +### Pagina Assegna più leggera; "Vedi tutti" in testata nella Home (19 ottobre 2026) + +Segnalazione dell'utente: la pagina Assegnazioni era lenta. Misurato su dev +con i dati di prod (79 assegnazioni aperte): il server la genera in ~80 ms +con 10 query, ma l'HTML pesava **387 KB** (299 l'elenco dei territori +assegnati, di cui 58 di icone SVG ripetute; 75 le opzioni dei campi di scelta) +e Livewire lo rimandava **per intero a ogni interazione**: scegliere un +territorio (anteprima con `wire:model.live`), scrivere nella ricerca +dell'elenco (`linkSearch`), aprire l'invio del link. Rimedi: + +- L'elenco è un componente a sé, `Assegnazioni\ElencoAssegnati`, **lazy**: la + pagina mostra subito il form e le sue azioni non lo ridisegnano. "Invia + link" nell'elenco chiede alla pagina di aprire l'invio con l'evento + `apri-invio` (`Assegna::apriInvio` ha `#[On('apri-invio')]`); + `toggleLinkSent` sta nell'elenco. +- La ricerca nell'elenco filtra nel browser (`data-cerca` senza accenti, + Alpine `elencoCercabile`): nessuna richiesta mentre si scrive. +- Il territorio va a Livewire con `wire:model` (non più `.live`): l'anteprima + della miniatura segue la scelta nel browser (evento `scelta` di + ``, URL della miniatura nelle opzioni). +- Le icone delle righe sono simboli SVG definiti una volta e richiamati con + ``. Il ramo "link scaduto" dell'elenco (solo per non autenticati, cioè + mai su questa pagina) è stato tolto. +- `AssegnaPrestazioniTest` tiene fermi questi punti, compreso un tetto al + peso per riga dell'elenco. +- Corretto anche `Assegnazione::giorni`: con Carbon 3 `diffInDays()` è un + float e PHP segnalava una conversione implicita a ogni riga. + +Home: "Vedi tutti" di *Da assegnare* e *Da rientrare* è ora un pulsante nella +testata del riquadro, accanto al PDF, invece che in fondo all'elenco. Compare +alle stesse condizioni di prima (elenco troncato al limite della Home). + ### Scelta di territori e proclamatori con ricerca (19 ottobre 2026) Al posto delle tendine di territorio e proclamatore (Assegna, finestra del diff --git a/README.md b/README.md index 19b31bf..3282ff3 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,16 @@ Cosa serve sapere: `CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va provato sullo stack vero, non qui. +La logica JavaScript senza DOM (la ricerca nei campi di scelta, +`resources/js/scelta.js`) ha i suoi test in `tests/js/`, con il test runner di +Node: + +```bash +npm run test:js +# oppure, senza Node installato: +docker run --rm -v "$PWD":/app -w /app node:20-alpine node --test tests/js/ +``` + --- ## Variabili d'ambiente @@ -678,11 +688,15 @@ docker compose --profile dev up -d --build - Media mensile di percorrenza e durata media delle assegnazioni - Campagna attiva con barra di progresso - Tre liste rapide con azione diretta: **da assegnare**, **prioritari**, - **da rientrare** + **da rientrare**; quando l'elenco è troncato, **"Vedi tutti"** è un pulsante + in testata che apre l'elenco territori già filtrato ### Territori - CRUD completo con upload PDF e miniatura generata automaticamente -- Filtri per zona, tipologia e stato +- **"Nuovo territorio"** apre il designer delle piantine già in disegno: il + territorio si crea dal confine disegnato (per chi non è amministratore resta + il form) +- Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina - Badge di stato colorati e indicatore di priorità (manuale o automatica) - Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico - Link PDF pubblico condivisibile, a scadenza configurabile @@ -696,12 +710,28 @@ docker compose --profile dev up -d --build - Soft delete con cestino ### Assegnazione e rientro -- Selezione territorio disponibile e proclamatore attivo +- **Scelta con ricerca** di territorio e proclamatore (anche nel Registro e nel + designer delle piantine): si scrive e compaiono le proposte più vicine — + uguale, inizia con, contiene, errore di battitura ("forse cercavi") — con + le parole in qualsiasi ordine, senza badare a maiuscole e accenti. Campo + vuoto: l'elenco completo. Pensata anche per il telefono +- I territori già assegnati si trovano cercando ("già assegnato a …") ma non + si possono scegliere; l'anteprima della miniatura segue la scelta - Anno teocratico rilevato automaticamente dalla data - Validazione: territorio non già assegnato, entrambi attivi +- **Invio del link dopo l'assegnazione**: una finestra chiede il canale — + *Copia link* sempre, *WhatsApp* se il proclamatore ha un numero, *Email* se + ha un indirizzo e la posta è configurata (la manda l'app) — e se il link è + stato inviato. Solo dopo la risposta si va avanti; altrimenti l'assegnazione + resta segnalata **"Link ancora da inviare"**, con "Invia link" per riprendere +- L'elenco dei territori assegnati si carica subito dopo la pagina e si filtra + nel browser, senza attese - Rientro con calcolo automatico dei giorni -- **Prompt campagna**: se il rientro ricade nel periodo di una campagna, viene - chiesto se conteggiarlo +- **Prompt campagna**: se il territorio era fuori durante una campagna viene + chiesto se è stato percorso per la campagna; oltre la soglia "da rientrare" + compare un avviso di tempo di percorrenza molto alto +- **Dopo una conferma o un annulla si torna alla pagina di partenza**, con i + suoi filtri, ricerca, ordinamento e pagina (aperta dal menu: come prima) ### Campagne - CRUD con date di inizio e fine @@ -799,11 +829,11 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina. | `settings` | Configurazione singleton | | `zone` | Zone territoriali | | `tipologie` | Tipologie di territorio | -| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete | +| `proclamatori` | Assegnatari, con nome/cognome e i facoltativi email/telefono cifrati, soft delete | | `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete | | `anni_teocratici` | Periodi settembre → agosto | | `campagne` | Periodi speciali con date e descrizione | -| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna | +| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`) | | `activity_log` | Audit trail | **Vincoli e indici principali** diff --git a/app/Livewire/Assegnazioni/Assegna.php b/app/Livewire/Assegnazioni/Assegna.php index ddd8d9c..c60447a 100644 --- a/app/Livewire/Assegnazioni/Assegna.php +++ b/app/Livewire/Assegnazioni/Assegna.php @@ -4,8 +4,8 @@ namespace App\Livewire\Assegnazioni; use App\Livewire\Concerns\RicordaRitorno; use Livewire\Component; -use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; +use Livewire\Attributes\On; use App\Models\Territorio; use App\Models\Proclamatore; use App\Models\Assegnazione; @@ -40,7 +40,6 @@ class Assegna extends Component public ?int $territorio_id = null; public ?int $proclamatore_id = null; public string $assigned_at = ''; - public string $linkSearch = ''; // Optional pre-selection from parent context public ?int $preselectedTerritorioId = null; @@ -143,7 +142,12 @@ class Assegna extends Component $this->apriInvio($assegnazione->id); } - /** Apre l'invio del link: subito dopo l'assegnazione, o da un'assegnazione rimasta in sospeso. */ + /** + * Apre l'invio del link: subito dopo l'assegnazione, o da un'assegnazione + * rimasta in sospeso ("Invia link" nell'elenco, che è un componente a sé + * e lo chiede con l'evento apri-invio). + */ + #[On('apri-invio')] public function apriInvio(int $assegnazioneId): void { $this->authorize('territori.assign'); @@ -250,65 +254,27 @@ class Assegna extends Component return Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId); } - public function toggleLinkSent(int $assegnazioneId): void - { - $this->authorize('territori.assign'); - $assegnazione = Assegnazione::findOrFail($assegnazioneId); - $assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale); - } - - #[Computed] - public function selectedThumbnailUrl(): ?string - { - if (!$this->territorio_id) { - return null; - } - $t = Territorio::find($this->territorio_id); - return $t?->miniaturaUrl(); - } - public function render() { // 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() + // uno si legge a chi è, invece di un "nessun risultato". La miniatura + // serve all'anteprima, che si aggiorna nel browser. + $territori = Territorio::attivi() ->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']) ->get() - ->sortBy('numero', SORT_NATURAL) + ->sortBy('numero', SORT_NATURAL); + + $opzioniTerritori = $territori ->map(fn (Territorio $t) => $t->opzioneScelta( $t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null - )) + ) + array_filter(['miniatura' => $t->miniaturaUrl()])) ->values() ->all(); $opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get()); - // All currently assigned territories with links - $assegnazioniAperte = Assegnazione::aperte() - ->with(['territorio.zona', 'proclamatore']) - ->get() - ->sortBy(fn($a) => (int) $a->territorio?->numero); - - // Ricerca rapida nella lista dei link: numero territorio, zona, - // proclamatore in un solo campo. In memoria perché nome/cognome del - // proclamatore sono cifrati e non filtrabili via SQL. - $assegnazioniAperteVisibili = $assegnazioniAperte; - if (trim($this->linkSearch) !== '') { - $term = mb_strtolower(trim($this->linkSearch)); - $assegnazioniAperteVisibili = $assegnazioniAperte->filter(function ($a) use ($term) { - $haystack = mb_strtolower( - ($a->territorio?->numero ?? '') . ' ' . - ($a->territorio?->zona?->nome ?? '') . ' ' . - ($a->proclamatore?->nome_completo ?? '') - ); - return str_contains($haystack, $term); - })->values(); - } - - // Il link scade alla soglia rientro (giorni_per_smarrito). - $sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120)); - + // L'elenco dei territori assegnati sta in ElencoAssegnati. // L'invio in corso; se nel frattempo l'assegnazione è rientrata o // sparita non c'è più niente da mandare. $invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null; @@ -321,9 +287,7 @@ class Assegna extends Component 'postaAttiva' => $invio ? app(ConfigurazionePosta::class)->funzionante() : false, 'opzioniTerritori' => $opzioniTerritori, 'opzioniProclamatori' => $opzioniProclamatori, - 'assegnazioniAperte' => $assegnazioniAperte, - 'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili, - 'sogliaRientro' => $sogliaRientro, + 'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null, ]); } } diff --git a/app/Livewire/Assegnazioni/ElencoAssegnati.php b/app/Livewire/Assegnazioni/ElencoAssegnati.php new file mode 100644 index 0000000..b9f57fb --- /dev/null +++ b/app/Livewire/Assegnazioni/ElencoAssegnati.php @@ -0,0 +1,60 @@ +. + */ +#[Lazy] +class ElencoAssegnati extends Component +{ + public function toggleLinkSent(int $assegnazioneId): void + { + $this->authorize('territori.assign'); + $assegnazione = Assegnazione::findOrFail($assegnazioneId); + $assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale); + } + + public function placeholder() + { + return view('livewire.assegnazioni.elenco-assegnati-attesa'); + } + + public function render() + { + $assegnazioni = Assegnazione::aperte() + ->with(['territorio.zona', 'proclamatore']) + ->get() + ->sortBy(fn ($a) => (string) $a->territorio?->numero, SORT_NATURAL) + ->values(); + + // Il testo su cui filtra la ricerca nel browser: numero, zona e nome + // (decifrato qui: in SQL non si potrebbe cercare), senza accenti. + $cerca = $assegnazioni->mapWithKeys(fn ($a) => [$a->id => Str::lower(Str::ascii(implode(' ', array_filter([ + $a->territorio?->numero, + $a->territorio?->zona?->nome, + $a->proclamatore?->nome_completo, + ]))))]); + + return view('livewire.assegnazioni.elenco-assegnati', [ + 'assegnazioni' => $assegnazioni, + 'cerca' => $cerca, + // Il link scade alla soglia rientro (giorni_per_smarrito). + 'sogliaRientro' => max(1, (int) Setting::getValue('giorni_per_smarrito', 120)), + ]); + } +} diff --git a/app/Models/Assegnazione.php b/app/Models/Assegnazione.php index 680de45..c78054d 100644 --- a/app/Models/Assegnazione.php +++ b/app/Models/Assegnazione.php @@ -136,7 +136,8 @@ class Assegnazione extends Model public function getGiorniAttribute(): int { $end = $this->returned_at ?? now(); - return Carbon::parse($this->assigned_at)->diffInDays($end); + // Carbon 3 restituisce un float: i giorni interi trascorsi. + return (int) Carbon::parse($this->assigned_at)->diffInDays($end); } public function getIsApertaAttribute(): bool diff --git a/resources/js/app.js b/resources/js/app.js index fd967be..f5e17f2 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,5 @@ import './bootstrap'; -import { proponi } from './scelta'; +import { proponi, normalizza } from './scelta'; // ─── Campo di scelta con ricerca () ─────────────────────────────── // @@ -8,6 +8,21 @@ import { proponi } from './scelta'; // Il valore scelto arriva a Livewire con x-modelable + wire:model. document.addEventListener('alpine:init', () => { + // Elenco filtrato nel browser: ogni riga ha data-cerca (testo già senza + // accenti, minuscolo); valgono tutte le parole scritte, in qualsiasi + // ordine. Nessuna richiesta al server mentre si scrive. + window.Alpine.data('elencoCercabile', () => ({ + q: '', + corrisponde(testo) { + const parole = normalizza(this.q).split(/\s+/).filter(Boolean); + return parole.every((p) => (testo ?? '').includes(p)); + }, + get nessuno() { + if (normalizza(this.q) === '') return false; + return ![...this.$root.querySelectorAll('[data-cerca]')].some((riga) => this.corrisponde(riga.dataset.cerca)); + }, + })); + // 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 = '') => ({ @@ -90,6 +105,9 @@ document.addEventListener('alpine:init', () => { this.valore = opzione.valore; this.aperto = false; this.testo = opzione.etichetta; + // Per chi vuole reagire alla scelta senza passare dal server + // (l'anteprima della miniatura in Assegna). + this.$dispatch('scelta', { valore: opzione.valore, opzione }); if (daTocco) this.$refs.campo.blur(); }, @@ -100,6 +118,7 @@ document.addEventListener('alpine:init', () => { svuota() { this.valore = this.vuoto; this.testo = ''; + this.$dispatch('scelta', { valore: this.vuoto, opzione: null }); this.$refs.campo.focus(); this.aperto = true; this.evidenziato = 0; diff --git a/resources/views/livewire/assegnazioni/assegna.blade.php b/resources/views/livewire/assegnazioni/assegna.blade.php index 57a7dd1..193bca9 100644 --- a/resources/views/livewire/assegnazioni/assegna.blade.php +++ b/resources/views/livewire/assegnazioni/assegna.blade.php @@ -126,30 +126,28 @@ {{-- Form --}}
-
+ {{-- L'anteprima segue la scelta nel browser (evento "scelta" di + , miniatura nelle opzioni): scegliere un + territorio non fa più una richiesta al server. --}} +
- @error('territorio_id')

{{ $message }}

@enderror - @if($territorio_id) -
+

Anteprima territorio

- @if($this->selectedThumbnailUrl) -
- Thumbnail territorio selezionato + Miniatura del territorio scelto
- @else -
- Nessuna thumbnail disponibile per questo territorio. +
+ Nessuna miniatura disponibile per questo territorio.
- @endif
- @endif
@@ -179,115 +177,8 @@
- {{-- Elenco territori attualmente assegnati --}} - @if($assegnazioniAperte->count()) -
-
-
- -
-
-

Territori Assegnati ({{ $assegnazioniAperte->count() }})

-

Link PDF · stato invio · il link scade alla soglia rientro ({{ $sogliaRientro }} giorni)

-
-
- - {{-- Ricerca rapida: territorio, numero, zona, proclamatore in un solo campo --}} -
-
- - -
-
- -
- @forelse($assegnazioniAperteVisibili as $a) - @php - $pdfUrl = $a->shortPdfUrl(); - $linkScaduto = ! auth()->check() && $a->data_consegna_presunta && $a->data_consegna_presunta->isPast(); - @endphp -
- {{-- Testata riga --}} -
-
- N° {{ $a->territorio?->numero }} - @if($a->territorio?->zona?->nome) - {{ $a->territorio?->zona?->nome }} - @endif -
-
- {{ $a->proclamatore?->nome_completo ?? 'N/A' }} - · - {{ $a->assigned_at->format('d/m/Y') }} - · - - {{ $a->giorni }}g - -
-
- - {{-- Procedura di invio interrotta, o risposto "non ancora" --}} - @if($a->link_in_sospeso) -
- - Link ancora da inviare - Invia link -
- @endif - - {{-- Avviso link scaduto (solo per non loggati) --}} - @if($linkScaduto) -
- - Link scaduto — rigenerare dal dettaglio territorio -
- - {{-- Link attivo --}} - @elseif($pdfUrl) -
- {{ $pdfUrl }} - - - - - - @if($a->proclamatore) - - @endif - - {{-- Flag Link Inviato --}} - {{-- Interruttore di stato, non un comando: lo stato "inviato" - si legge dalla variante (primary quando è acceso) e - aria-pressed lo dice anche a chi non lo vede. --}} - - @if($a->link_sent) - - Link inviato - @else - - Non inviato - @endif - - - Copiato! -
- @else -

Nessun PDF disponibile

- @endif -
- @empty -
Nessun risultato per "{{ $linkSearch }}".
- @endforelse -
-
- @endif + {{-- Elenco dei territori assegnati: componente a sé, caricato subito + dopo la pagina (ElencoAssegnati, #[Lazy]). Le azioni del form non + lo ridisegnano. --}} +
diff --git a/resources/views/livewire/assegnazioni/elenco-assegnati-attesa.blade.php b/resources/views/livewire/assegnazioni/elenco-assegnati-attesa.blade.php new file mode 100644 index 0000000..7faa1e7 --- /dev/null +++ b/resources/views/livewire/assegnazioni/elenco-assegnati-attesa.blade.php @@ -0,0 +1,3 @@ +
+ Caricamento dei territori assegnati… +
diff --git a/resources/views/livewire/assegnazioni/elenco-assegnati.blade.php b/resources/views/livewire/assegnazioni/elenco-assegnati.blade.php new file mode 100644 index 0000000..ee58443 --- /dev/null +++ b/resources/views/livewire/assegnazioni/elenco-assegnati.blade.php @@ -0,0 +1,110 @@ +
+@if($assegnazioni->count()) +
+ {{-- Le icone delle righe, una volta sola: ogni riga le richiama con + . Ripetute per esteso pesavano ~60 KB su 80 righe. --}} + + +
+
+ +
+
+

Territori Assegnati ({{ $assegnazioni->count() }})

+

Link PDF · stato invio · il link scade alla soglia rientro ({{ $sogliaRientro }} giorni)

+
+
+ + {{-- Ricerca rapida: filtra nel browser, nessuna richiesta al server --}} +
+
+ + +
+
+ +
+ @foreach($assegnazioni as $a) + @php + $pdfUrl = $a->shortPdfUrl(); + $whatsapp = $pdfUrl && $a->proclamatore + ? $a->proclamatore->whatsappUrl('Ciao ' . $a->proclamatore->nome . ', ti è stato assegnato il territorio N° ' . $a->territorio?->numero . '. Ecco la piantina: ' . $pdfUrl) + : null; + $nome = $a->proclamatore?->nome_completo ?? 'N/A'; + @endphp +
+
+
+ N° {{ $a->territorio?->numero }} + @if($a->territorio?->zona?->nome) + {{ $a->territorio->zona->nome }} + @endif +
+
+ {{ $nome }} + · + {{ $a->assigned_at->format('d/m/Y') }} + · + {{ $a->giorni }}g +
+
+ + {{-- Procedura di invio interrotta, o risposto "non ancora" --}} + @if($a->link_in_sospeso) +
+ + Link ancora da inviare + {{-- L'invio si apre nel componente della pagina (Assegna::apriInvio). --}} + Invia link +
+ @endif + + @if($pdfUrl) +
+ {{ $pdfUrl }} + + + + + + @if($whatsapp) + + + + @endif + + {{-- Interruttore di stato, non un comando: lo stato "inviato" + si legge dalla variante (primary quando è acceso) e + aria-pressed lo dice anche a chi non lo vede. --}} + + + {{ $a->link_sent ? 'Link inviato' : 'Non inviato' }} + + + Copiato! +
+ @else +

Nessun PDF disponibile

+ @endif +
+ @endforeach +
Nessun risultato per «».
+
+
+@endif +
diff --git a/resources/views/livewire/home.blade.php b/resources/views/livewire/home.blade.php index 3e7fa2f..b76fa9e 100644 --- a/resources/views/livewire/home.blade.php +++ b/resources/views/livewire/home.blade.php @@ -82,7 +82,7 @@
{{-- Da assegnare --}}
-
+
@@ -90,13 +90,19 @@

Da Assegnare

Prima i prioritari, poi i territori con più tempo in reparto

- @hasanyrole('amministratore|assistente') - - - - @endhasanyrole + {{-- "Vedi tutti" in testata, non in fondo all'elenco. --}} +
+ @if($territoriDaAssegnare->count() >= $homeLimit) + Vedi tutti + @endif + @hasanyrole('amministratore|assistente') + + + + @endhasanyrole +
    @forelse($territoriDaAssegnare as $t) @@ -126,16 +132,11 @@ @endforelse
- @if($territoriDaAssegnare->count() >= $homeLimit) - - @endif
{{-- Da rientrare --}}
-
+
@@ -143,15 +144,20 @@

Da Rientrare

Territori assegnati da più tempo

- @hasanyrole('amministratore|assistente') - {{-- Scaricare un elenco non è distruttivo: era rosso solo perché - lo è la scheda che lo contiene. --}} - - - - @endhasanyrole +
+ @if($daRientrare->count() >= $homeLimit) + Vedi tutti + @endif + @hasanyrole('amministratore|assistente') + {{-- Scaricare un elenco non è distruttivo: era rosso solo perché + lo è la scheda che lo contiene. --}} + + + + @endhasanyrole +
    @forelse($daRientrare as $t) @@ -177,11 +183,6 @@ @endforelse
- @if($daRientrare->count() >= $homeLimit) - - @endif
diff --git a/tests/Feature/AssegnaPrestazioniTest.php b/tests/Feature/AssegnaPrestazioniTest.php new file mode 100644 index 0000000..474c5b4 --- /dev/null +++ b/tests/Feature/AssegnaPrestazioniTest.php @@ -0,0 +1,109 @@ +withoutVite(); + Storage::fake('public'); + $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'); + } + + /** $n assegnazioni aperte, ognuna con PDF e un proclamatore con telefono. */ + private function assegnazioni(int $n): void + { + $zona = Zona::create(['nome' => 'Montepellegrino', 'attivo' => true]); + for ($i = 1; $i <= $n; $i++) { + Storage::disk('public')->put("territori-pdf/t{$i}.pdf", '%PDF-1.4'); + $t = Territorio::create(['numero' => (string) $i, 'attivo' => true, 'zona_id' => $zona->id, 'pdf_path' => "territori-pdf/t{$i}.pdf"]); + $p = Proclamatore::create(['nome' => "Nicolò{$i}", 'cognome' => 'Rossi', 'attivo' => true, 'telefono' => '3331234567']); + Assegnazione::create([ + 'territorio_id' => $t->id, 'proclamatore_id' => $p->id, 'anno_teocratico_id' => AnnoTeocratico::sole()->id, + 'assigned_at' => now()->subDays($i), 'created_by' => $this->admin->id, + ]); + } + } + + public function test_la_pagina_non_contiene_piu_l_elenco_che_arriva_dopo(): void + { + $this->assegnazioni(5); + + $this->actingAs($this->admin) + ->get(route('assegnazioni.assegna')) + ->assertOk() + ->assertSee('Caricamento dei territori assegnati') + ->assertDontSee('Territori Assegnati (5)'); + } + + public function test_scegliere_un_territorio_non_chiede_niente_al_server(): void + { + $html = $this->actingAs($this->admin)->get(route('assegnazioni.assegna'))->getContent(); + + // wire:model senza .live: il valore parte solo con "Assegna". + $this->assertStringContainsString('wire:model="territorio_id"', $html); + $this->assertStringNotContainsString('wire:model.live="territorio_id"', $html); + } + + public function test_le_opzioni_portano_la_miniatura_per_l_anteprima(): void + { + $t = Territorio::create(['numero' => '7', 'attivo' => true, 'thumbnail_path' => 'territori-thumbnails/t7.png']); + + $opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniTerritori'); + + $this->assertSame($t->miniaturaUrl(), $opzioni[0]['miniatura']); + } + + public function test_l_elenco_si_cerca_nel_browser_senza_accenti(): void + { + $this->assegnazioni(1); + + Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class) + ->assertSee('Territori Assegnati (1)') + ->assertSeeHtml('data-cerca="1 montepellegrino rossi nicolo1"') + ->assertDontSeeHtml('wire:model.live.debounce.300ms="linkSearch"'); + } + + public function test_le_righe_dell_elenco_restano_leggere(): void + { + $this->assegnazioni(30); + + $html = Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class)->html(); + + // Prima ~3,8 KB a riga. Le icone sono simboli richiamati con . + $this->assertLessThan(2600, strlen($html) / 30, 'Ogni riga dell\'elenco deve restare sotto i 2,6 KB'); + $this->assertSame(1, substr_count($html, 'assertGreaterThanOrEqual(30, substr_count($html, 'href="#tm-i-copia"')); + } +} diff --git a/tests/Feature/ContattiProclamatoriTest.php b/tests/Feature/ContattiProclamatoriTest.php index 21fe888..49faa0a 100644 --- a/tests/Feature/ContattiProclamatoriTest.php +++ b/tests/Feature/ContattiProclamatoriTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Livewire\Assegnazioni\Assegna; +use App\Livewire\Assegnazioni\ElencoAssegnati; use App\Livewire\Proclamatori\ProclamatoreCreate; use App\Livewire\Settings\XmlExchange; use App\Models\AnnoTeocratico; @@ -245,11 +246,15 @@ class ContattiProclamatoriTest extends TestCase $this->assegna($mario, $this->territorioConPdf()); $assegnazione = Assegnazione::sole(); - // La pagina viene chiusa senza rispondere: l'invio è perso, l'avviso resta. + // La pagina viene chiusa senza rispondere: l'invio è perso, l'avviso resta + // nell'elenco, e "Invia link" chiede alla pagina di riaprirlo. + Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class) + ->assertSee('Link ancora da inviare') + ->assertSeeHtml("\$dispatch('apri-invio', { assegnazioneId: {$assegnazione->id} })"); + Livewire::actingAs($this->admin)->test(Assegna::class) ->assertSet('invioId', null) - ->assertSee('Link ancora da inviare') - ->call('apriInvio', $assegnazione->id) + ->dispatch('apri-invio', assegnazioneId: $assegnazione->id) ->assertSee('Hai inviato il link?') ->call('concludiInvio', false) ->assertRedirect(route('assegnazioni.assegna')); @@ -260,7 +265,7 @@ class ContattiProclamatoriTest extends TestCase ->assertSee('Il link della piantina è ancora da inviare'); // Segnato come inviato dall'elenco: l'avviso sparisce. - Livewire::actingAs($this->admin)->test(Assegna::class) + Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class) ->call('toggleLinkSent', $assegnazione->id) ->assertDontSee('Link ancora da inviare'); $this->assertFalse($assegnazione->fresh()->link_in_sospeso);