++ pagina Assegna più leggera; "Vedi tutti" in testata nella Home

Assegna pesava 387 KB e Livewire li rimandava a ogni interazione.
- Elenco dei territori assegnati in un componente lazy a sé
  (ElencoAssegnati): il form compare subito e non lo ridisegna.
- Ricerca nell'elenco e anteprima della miniatura nel browser: nessuna
  richiesta al server mentre si scrive o si sceglie il territorio.
- Icone SVG come simboli richiamati con <use>.
- Assegnazione::giorni: cast a int (Carbon 3 restituisce un float).

Home: "Vedi tutti" diventa un pulsante nella testata dei riquadri.
README e HANDOFF aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-19 15:49:08 +02:00
parent fda428f242
commit 81a8f2ba33
12 changed files with 442 additions and 217 deletions

View File

@@ -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 sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
e dati di prova rimossi dopo. 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
`<x-scelta>`, URL della miniatura nelle opzioni).
- Le icone delle righe sono simboli SVG definiti una volta e richiamati con
`<use>`. 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) ### Scelta di territori e proclamatori con ricerca (19 ottobre 2026)
Al posto delle tendine di territorio e proclamatore (Assegna, finestra del Al posto delle tendine di territorio e proclamatore (Assegna, finestra del

View File

@@ -338,6 +338,16 @@ Cosa serve sapere:
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va `CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
provato sullo stack vero, non qui. 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 ## Variabili d'ambiente
@@ -678,11 +688,15 @@ docker compose --profile dev up -d --build
- Media mensile di percorrenza e durata media delle assegnazioni - Media mensile di percorrenza e durata media delle assegnazioni
- Campagna attiva con barra di progresso - Campagna attiva con barra di progresso
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**, - 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 ### Territori
- CRUD completo con upload PDF e miniatura generata automaticamente - 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) - Badge di stato colorati e indicatore di priorità (manuale o automatica)
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico - Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico
- Link PDF pubblico condivisibile, a scadenza configurabile - Link PDF pubblico condivisibile, a scadenza configurabile
@@ -696,12 +710,28 @@ docker compose --profile dev up -d --build
- Soft delete con cestino - Soft delete con cestino
### Assegnazione e rientro ### 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 - Anno teocratico rilevato automaticamente dalla data
- Validazione: territorio non già assegnato, entrambi attivi - 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 - Rientro con calcolo automatico dei giorni
- **Prompt campagna**: se il rientro ricade nel periodo di una campagna, viene - **Prompt campagna**: se il territorio era fuori durante una campagna viene
chiesto se conteggiarlo 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 ### Campagne
- CRUD con date di inizio e fine - 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 | | `settings` | Configurazione singleton |
| `zone` | Zone territoriali | | `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio | | `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 | | `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
| `anni_teocratici` | Periodi settembre → agosto | | `anni_teocratici` | Periodi settembre → agosto |
| `campagne` | Periodi speciali con date e descrizione | | `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 | | `activity_log` | Audit trail |
**Vincoli e indici principali** **Vincoli e indici principali**

View File

@@ -4,8 +4,8 @@ namespace App\Livewire\Assegnazioni;
use App\Livewire\Concerns\RicordaRitorno; use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
@@ -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 $linkSearch = '';
// Optional pre-selection from parent context // Optional pre-selection from parent context
public ?int $preselectedTerritorioId = null; public ?int $preselectedTerritorioId = null;
@@ -143,7 +142,12 @@ class Assegna extends Component
$this->apriInvio($assegnazione->id); $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 public function apriInvio(int $assegnazioneId): void
{ {
$this->authorize('territori.assign'); $this->authorize('territori.assign');
@@ -250,65 +254,27 @@ class Assegna extends Component
return Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId); 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() public function render()
{ {
// Le opzioni dei campi di scelta con ricerca (<x-scelta>). Ci sono anche // Le opzioni dei campi di scelta con ricerca (<x-scelta>). Ci sono anche
// i territori già assegnati, che non si possono scegliere: cercandone // i territori già assegnati, che non si possono scegliere: cercandone
// uno si legge a chi è, invece di un "nessun risultato". // uno si legge a chi è, invece di un "nessun risultato". La miniatura
$opzioniTerritori = Territorio::attivi() // serve all'anteprima, che si aggiorna nel browser.
$territori = Territorio::attivi()
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']) ->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore'])
->get() ->get()
->sortBy('numero', SORT_NATURAL) ->sortBy('numero', SORT_NATURAL);
$opzioniTerritori = $territori
->map(fn (Territorio $t) => $t->opzioneScelta( ->map(fn (Territorio $t) => $t->opzioneScelta(
$t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null $t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null
)) ) + array_filter(['miniatura' => $t->miniaturaUrl()]))
->values() ->values()
->all(); ->all();
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get()); $opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
// All currently assigned territories with links // L'elenco dei territori assegnati sta in ElencoAssegnati.
$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'invio in corso; se nel frattempo l'assegnazione è rientrata o // L'invio in corso; se nel frattempo l'assegnazione è rientrata o
// sparita non c'è più niente da mandare. // sparita non c'è più niente da mandare.
$invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null; $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, 'postaAttiva' => $invio ? app(ConfigurazionePosta::class)->funzionante() : false,
'opzioniTerritori' => $opzioniTerritori, 'opzioniTerritori' => $opzioniTerritori,
'opzioniProclamatori' => $opzioniProclamatori, 'opzioniProclamatori' => $opzioniProclamatori,
'assegnazioniAperte' => $assegnazioniAperte, 'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili,
'sogliaRientro' => $sogliaRientro,
]); ]);
} }
} }

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Assegnazioni;
use App\Models\Assegnazione;
use App\Models\Setting;
use Illuminate\Support\Str;
use Livewire\Attributes\Lazy;
use Livewire\Component;
/**
* L'elenco dei territori assegnati della pagina Assegna, separato dal form.
*
* Prima stava nello stesso componente: con ~80 righe la pagina pesava quasi
* 400 KB e Livewire li rimandava tutti a ogni interazione (scegliere un
* territorio, scrivere nella ricerca, aprire l'invio del link). Ora:
* - è un componente a sé, caricato subito dopo la pagina (#[Lazy]): il form
* compare subito e le sue azioni non ridisegnano l'elenco;
* - la ricerca filtra le righe nel browser (data-cerca), senza richieste;
* - le icone sono simboli SVG definiti una volta e richiamati con <use>.
*/
#[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)),
]);
}
}

View File

@@ -136,7 +136,8 @@ class Assegnazione extends Model
public function getGiorniAttribute(): int public function getGiorniAttribute(): int
{ {
$end = $this->returned_at ?? now(); $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 public function getIsApertaAttribute(): bool

View File

@@ -1,5 +1,5 @@
import './bootstrap'; import './bootstrap';
import { proponi } from './scelta'; import { proponi, normalizza } from './scelta';
// ─── Campo di scelta con ricerca (<x-scelta>) ─────────────────────────────── // ─── Campo di scelta con ricerca (<x-scelta>) ───────────────────────────────
// //
@@ -8,6 +8,21 @@ import { proponi } from './scelta';
// Il valore scelto arriva a Livewire con x-modelable + wire:model. // Il valore scelto arriva a Livewire con x-modelable + wire:model.
document.addEventListener('alpine:init', () => { 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 // vuoto: il valore di "nessuna scelta" per la proprietà Livewire collegata
// ('' per le stringhe, null per i ?int: '' su un ?int sarebbe un errore). // ('' per le stringhe, null per i ?int: '' su un ?int sarebbe un errore).
window.Alpine.data('sceltaRicerca', (opzioni, vuoto = '') => ({ window.Alpine.data('sceltaRicerca', (opzioni, vuoto = '') => ({
@@ -90,6 +105,9 @@ document.addEventListener('alpine:init', () => {
this.valore = opzione.valore; this.valore = opzione.valore;
this.aperto = false; this.aperto = false;
this.testo = opzione.etichetta; 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(); if (daTocco) this.$refs.campo.blur();
}, },
@@ -100,6 +118,7 @@ document.addEventListener('alpine:init', () => {
svuota() { svuota() {
this.valore = this.vuoto; this.valore = this.vuoto;
this.testo = ''; this.testo = '';
this.$dispatch('scelta', { valore: this.vuoto, opzione: null });
this.$refs.campo.focus(); this.$refs.campo.focus();
this.aperto = true; this.aperto = true;
this.evidenziato = 0; this.evidenziato = 0;

View File

@@ -126,30 +126,28 @@
{{-- Form --}} {{-- Form --}}
<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> {{-- L'anteprima segue la scelta nel browser (evento "scelta" di
<x-scelta>, miniatura nelle opzioni): scegliere un
territorio non fa più una richiesta al server. --}}
<div x-data="{ scelto: @js((bool) $territorio_id), anteprima: @js($anteprimaIniziale) }"
x-on:scelta="scelto = !! $event.detail.valore; anteprima = $event.detail.opzione?.miniatura ?? null">
<label for="territorio_id" class="block text-sm font-medium text-gray-700 mb-1">Territorio *</label> <label for="territorio_id" class="block text-sm font-medium text-gray-700 mb-1">Territorio *</label>
<x-scelta id="territorio_id" wire:model.live="territorio_id" <x-scelta id="territorio_id" wire:model="territorio_id"
:opzioni="$opzioniTerritori" :opzioni="$opzioniTerritori"
segnaposto="Numero, zona o tipologia" :vuoto="null" segnaposto="Numero, zona o tipologia" :vuoto="null"
:disabilitato="(bool) $preselectedTerritorioId" /> :disabilitato="(bool) $preselectedTerritorioId" />
@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) <div class="mt-3" x-show="scelto" x-cloak>
<div class="mt-3">
<p class="text-xs text-gray-500 mb-2">Anteprima territorio</p> <p class="text-xs text-gray-500 mb-2">Anteprima territorio</p>
@if($this->selectedThumbnailUrl) <div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm" x-show="anteprima">
<div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm"> <img x-bind:src="anteprima" alt="Miniatura del territorio scelto" loading="lazy"
<img src="{{ $this->selectedThumbnailUrl }}"
alt="Thumbnail territorio selezionato"
class="block w-full h-auto max-h-[70vh] object-contain bg-white"> class="block w-full h-auto max-h-[70vh] object-contain bg-white">
</div> </div>
@else <div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500" x-show="! anteprima">
<div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500"> Nessuna miniatura disponibile per questo territorio.
Nessuna thumbnail disponibile per questo territorio.
</div> </div>
@endif
</div> </div>
@endif
</div> </div>
<div> <div>
@@ -179,115 +177,8 @@
</div> </div>
{{-- Elenco territori attualmente assegnati --}} {{-- Elenco dei territori assegnati: componente a sé, caricato subito
@if($assegnazioniAperte->count()) dopo la pagina (ElencoAssegnati, #[Lazy]). Le azioni del form non
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-2"> lo ridisegnano. --}}
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe"> <livewire:assegnazioni.elenco-assegnati />
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#6366f1">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</div>
<div>
<h3 class="text-sm font-semibold" style="color:#3730a3">Territori Assegnati ({{ $assegnazioniAperte->count() }})</h3>
<p class="text-xs" style="color:#4f46e5">Link PDF · stato invio · il link scade alla soglia rientro ({{ $sogliaRientro }} giorni)</p>
</div>
</div>
{{-- Ricerca rapida: territorio, numero, zona, proclamatore in un solo campo --}}
<div class="px-5 py-3 border-b border-gray-100" style="background:#fafafa">
<div class="relative">
<svg class="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input wire:model.live.debounce.300ms="linkSearch" type="text"
placeholder="Cerca per territorio, numero, zona o proclamatore..."
class="block w-full rounded-lg border-gray-300 text-sm pl-9 focus:ring-indigo-500 focus:border-indigo-500">
</div>
</div>
<div class="divide-y divide-gray-100">
@forelse($assegnazioniAperteVisibili as $a)
@php
$pdfUrl = $a->shortPdfUrl();
$linkScaduto = ! auth()->check() && $a->data_consegna_presunta && $a->data_consegna_presunta->isPast();
@endphp
<div class="px-5 py-4 hover:bg-indigo-50/30 transition-colors">
{{-- Testata riga --}}
<div class="flex flex-wrap items-start justify-between gap-x-6 gap-y-2">
<div class="flex items-center gap-2">
<a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-bold text-sm">N° {{ $a->territorio?->numero }}</a>
@if($a->territorio?->zona?->nome)
<span class="text-xs text-gray-400 bg-gray-100 rounded px-1.5 py-0.5">{{ $a->territorio?->zona?->nome }}</span>
@endif
</div>
<div class="flex items-center gap-3 text-sm flex-wrap">
<span class="font-medium text-gray-800">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</span>
<span class="text-gray-300">·</span>
<span class="text-gray-500 text-xs">{{ $a->assigned_at->format('d/m/Y') }}</span>
<span class="text-gray-300">·</span>
<span class="text-xs font-semibold px-2 py-0.5 rounded-full
{{ $a->giorni > 120 ? 'bg-red-50 text-red-600' : ($a->giorni > 90 ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-500') }}">
{{ $a->giorni }}g
</span>
</div>
</div>
{{-- Procedura di invio interrotta, o risposto "non ancora" --}}
@if($a->link_in_sospeso)
<div class="mt-3 flex flex-wrap items-center gap-2 rounded-md px-3 py-1.5 text-xs font-semibold bg-amber-50 border border-amber-200 text-amber-800" role="status">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
Link ancora da inviare
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="apriInvio({{ $a->id }})">Invia link</x-btn>
</div>
@endif
{{-- Avviso link scaduto (solo per non loggati) --}}
@if($linkScaduto)
<div class="mt-3 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold bg-red-50 border border-red-200 text-red-700">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Link scaduto — rigenerare dal dettaglio territorio
</div>
{{-- Link attivo --}}
@elseif($pdfUrl)
<div x-data="{ copied: false }" class="mt-3 flex flex-wrap items-center gap-2">
<code class="flex-1 min-w-0 text-xs bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-gray-600 break-all select-all cursor-text" x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="flex-none"
x-on:click="navigator.clipboard.writeText($el.closest('div').querySelector('code').textContent).then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
title="Copia link" aria-label="Copia il link del PDF">
<svg x-show="!copied" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
<svg x-show="copied" x-cloak class="text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</x-btn>
@if($a->proclamatore)
<x-whatsapp :proclamatore="$a->proclamatore" compatto class="flex-none"
:messaggio="'Ciao ' . $a->proclamatore->nome . ', ti è stato assegnato il territorio N° ' . $a->territorio?->numero . '. Ecco la piantina: ' . $pdfUrl" />
@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. --}}
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" class="flex-none"
wire:click="toggleLinkSent({{ $a->id }})"
aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}"
title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}">
@if($a->link_sent)
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Link inviato
@else
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
Non inviato
@endif
</x-btn>
<span x-show="copied" x-cloak class="flex-none text-xs text-green-600 font-medium">Copiato!</span>
</div>
@else
<p class="mt-2 text-xs text-gray-300">Nessun PDF disponibile</p>
@endif
</div>
@empty
<div class="px-5 py-8 text-center text-sm text-gray-400">Nessun risultato per "{{ $linkSearch }}".</div>
@endforelse
</div>
</div>
@endif
</div> </div>

View File

@@ -0,0 +1,3 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mt-2 px-5 py-6 text-sm text-gray-500" role="status">
Caricamento dei territori assegnati…
</div>

View File

@@ -0,0 +1,110 @@
<div>
@if($assegnazioni->count())
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-2" x-data="elencoCercabile">
{{-- Le icone delle righe, una volta sola: ogni riga le richiama con
<use>. Ripetute per esteso pesavano ~60 KB su 80 righe. --}}
<svg aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
<symbol id="tm-i-copia" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></symbol>
<symbol id="tm-i-spunta" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></symbol>
<symbol id="tm-i-invio" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></symbol>
<symbol id="tm-i-avviso" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></symbol>
<symbol id="tm-i-whatsapp" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></symbol>
</svg>
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#6366f1">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</div>
<div>
<h3 class="text-sm font-semibold" style="color:#3730a3">Territori Assegnati ({{ $assegnazioni->count() }})</h3>
<p class="text-xs" style="color:#4f46e5">Link PDF · stato invio · il link scade alla soglia rientro ({{ $sogliaRientro }} giorni)</p>
</div>
</div>
{{-- Ricerca rapida: filtra nel browser, nessuna richiesta al server --}}
<div class="px-5 py-3 border-b border-gray-100" style="background:#fafafa">
<div class="relative">
<svg class="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input x-model="q" type="search" inputmode="search" autocomplete="off"
aria-label="Cerca tra i territori assegnati"
placeholder="Cerca per territorio, numero, zona o proclamatore..."
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm pl-9 focus:ring-indigo-500 focus:border-indigo-500">
</div>
</div>
<div class="divide-y divide-gray-100">
@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
<div wire:key="assegnato-{{ $a->id }}" data-cerca="{{ $cerca[$a->id] }}" x-show="corrisponde($el.dataset.cerca)" class="px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-x-6 gap-y-2">
<div class="flex items-center gap-2">
<a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-bold text-sm">N° {{ $a->territorio?->numero }}</a>
@if($a->territorio?->zona?->nome)
<span class="text-xs text-gray-400 bg-gray-100 rounded px-1.5 py-0.5">{{ $a->territorio->zona->nome }}</span>
@endif
</div>
<div class="flex items-center gap-3 text-sm flex-wrap">
<span class="font-medium text-gray-800">{{ $nome }}</span>
<span class="text-gray-300">·</span>
<span class="text-gray-500 text-xs">{{ $a->assigned_at->format('d/m/Y') }}</span>
<span class="text-gray-300">·</span>
<span class="text-xs font-semibold px-2 py-0.5 rounded-full {{ $a->giorni > 120 ? 'bg-red-50 text-red-600' : ($a->giorni > 90 ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-500') }}">{{ $a->giorni }}g</span>
</div>
</div>
{{-- Procedura di invio interrotta, o risposto "non ancora" --}}
@if($a->link_in_sospeso)
<div class="mt-3 flex flex-wrap items-center gap-2 rounded-md px-3 py-1.5 text-xs font-semibold bg-amber-50 border border-amber-200 text-amber-800" role="status">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" aria-hidden="true"><use href="#tm-i-avviso"/></svg>
Link ancora da inviare
{{-- L'invio si apre nel componente della pagina (Assegna::apriInvio). --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="$dispatch('apri-invio', { assegnazioneId: {{ $a->id }} })">Invia link</x-btn>
</div>
@endif
@if($pdfUrl)
<div x-data="{ copied: false }" class="mt-3 flex flex-wrap items-center gap-2">
<code class="flex-1 min-w-0 text-xs bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-gray-600 break-all select-all cursor-text" x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="flex-none"
x-on:click="navigator.clipboard.writeText($el.closest('div').querySelector('code').textContent).then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
title="Copia link" aria-label="Copia il link del PDF">
<svg x-show="!copied" fill="none" stroke="currentColor" aria-hidden="true"><use href="#tm-i-copia"/></svg>
<svg x-show="copied" x-cloak class="text-green-600" fill="none" stroke="currentColor" aria-hidden="true"><use href="#tm-i-spunta"/></svg>
</x-btn>
@if($whatsapp)
<a href="{{ $whatsapp }}" target="_blank" rel="noopener" class="tm-whatsapp tm-whatsapp-compatto flex-none"
title="Scrivi su WhatsApp a {{ $nome }}" aria-label="Scrivi su WhatsApp a {{ $nome }}">
<svg fill="none" stroke="currentColor" aria-hidden="true"><use href="#tm-i-whatsapp"/></svg>
</a>
@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. --}}
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" class="flex-none"
wire:click="toggleLinkSent({{ $a->id }})"
aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}"
title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}">
<svg fill="none" stroke="currentColor" aria-hidden="true"><use href="{{ $a->link_sent ? '#tm-i-spunta' : '#tm-i-invio' }}"/></svg>
{{ $a->link_sent ? 'Link inviato' : 'Non inviato' }}
</x-btn>
<span x-show="copied" x-cloak class="flex-none text-xs text-green-600 font-medium">Copiato!</span>
</div>
@else
<p class="mt-2 text-xs text-gray-300">Nessun PDF disponibile</p>
@endif
</div>
@endforeach
<div x-show="nessuno" x-cloak class="px-5 py-8 text-center text-sm text-gray-400">Nessun risultato per «<span x-text="q"></span>».</div>
</div>
</div>
@endif
</div>

View File

@@ -82,7 +82,7 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Da assegnare --}} {{-- Da assegnare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border-color:#bbf7d0"> <div class="px-5 py-4 border-b flex flex-wrap items-center gap-3" style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border-color:#bbf7d0">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#22c55e"> <div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#22c55e">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
</div> </div>
@@ -90,13 +90,19 @@
<h3 class="text-sm font-semibold" style="color:#166534">Da Assegnare</h3> <h3 class="text-sm font-semibold" style="color:#166534">Da Assegnare</h3>
<p class="text-xs" style="color:#15803d">Prima i prioritari, poi i territori con più tempo in reparto</p> <p class="text-xs" style="color:#15803d">Prima i prioritari, poi i territori con più tempo in reparto</p>
</div> </div>
@hasanyrole('amministratore|assistente') {{-- "Vedi tutti" in testata, non in fondo all'elenco. --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto" <div class="ml-auto flex flex-none items-center gap-2">
wire:click="downloadPdfDaAssegnare" @if($territoriDaAssegnare->count() >= $homeLimit)
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da assegnare"> <x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('territori.index', ['filterStato' => 'in_reparto'])">Vedi tutti</x-btn>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> @endif
</x-btn> @hasanyrole('amministratore|assistente')
@endhasanyrole <x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="downloadPdfDaAssegnare"
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da assegnare">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</x-btn>
@endhasanyrole
</div>
</div> </div>
<ul class="divide-y divide-gray-100"> <ul class="divide-y divide-gray-100">
@forelse($territoriDaAssegnare as $t) @forelse($territoriDaAssegnare as $t)
@@ -126,16 +132,11 @@
</li> </li>
@endforelse @endforelse
</ul> </ul>
@if($territoriDaAssegnare->count() >= $homeLimit)
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
<a href="{{ route('territori.index') }}?filterStato=in_reparto" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti →</a>
</div>
@endif
</div> </div>
{{-- Da rientrare --}} {{-- Da rientrare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#fef2f2,#fee2e2);border-color:#fecaca"> <div class="px-5 py-4 border-b flex flex-wrap items-center gap-3" style="background:linear-gradient(135deg,#fef2f2,#fee2e2);border-color:#fecaca">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#ef4444"> <div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#ef4444">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div> </div>
@@ -143,15 +144,20 @@
<h3 class="text-sm font-semibold" style="color:#991b1b">Da Rientrare</h3> <h3 class="text-sm font-semibold" style="color:#991b1b">Da Rientrare</h3>
<p class="text-xs" style="color:#b91c1c">Territori assegnati da più tempo</p> <p class="text-xs" style="color:#b91c1c">Territori assegnati da più tempo</p>
</div> </div>
@hasanyrole('amministratore|assistente') <div class="ml-auto flex flex-none items-center gap-2">
{{-- Scaricare un elenco non è distruttivo: era rosso solo perché @if($daRientrare->count() >= $homeLimit)
lo è la scheda che lo contiene. --}} <x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('territori.index', ['filterStato' => 'da_rientrare'])">Vedi tutti</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto" @endif
wire:click="downloadPdfDaRientrare" @hasanyrole('amministratore|assistente')
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da rientrare"> {{-- Scaricare un elenco non è distruttivo: era rosso solo perché
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> lo è la scheda che lo contiene. --}}
</x-btn> <x-btn variante="secondary" tono="tenue" dimensione="sm" icona
@endhasanyrole wire:click="downloadPdfDaRientrare"
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da rientrare">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</x-btn>
@endhasanyrole
</div>
</div> </div>
<ul class="divide-y divide-gray-100"> <ul class="divide-y divide-gray-100">
@forelse($daRientrare as $t) @forelse($daRientrare as $t)
@@ -177,11 +183,6 @@
</li> </li>
@endforelse @endforelse
</ul> </ul>
@if($daRientrare->count() >= $homeLimit)
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
<a href="{{ route('territori.index') }}?filterStato=da_rientrare" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti →</a>
</div>
@endif
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\ElencoAssegnati;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
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;
/**
* La pagina Assegna era lenta: ~400 KB rimandati per intero a ogni
* interazione. Questi test tengono fermi i rimedi: elenco in un componente
* lazy, ricerca e anteprima nel browser, righe leggere.
*/
class AssegnaPrestazioniTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->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 <use>.
$this->assertLessThan(2600, strlen($html) / 30, 'Ogni riga dell\'elenco deve restare sotto i 2,6 KB');
$this->assertSame(1, substr_count($html, '<symbol id="tm-i-copia"'));
$this->assertGreaterThanOrEqual(30, substr_count($html, 'href="#tm-i-copia"'));
}
}

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna; use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\ElencoAssegnati;
use App\Livewire\Proclamatori\ProclamatoreCreate; use App\Livewire\Proclamatori\ProclamatoreCreate;
use App\Livewire\Settings\XmlExchange; use App\Livewire\Settings\XmlExchange;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
@@ -245,11 +246,15 @@ class ContattiProclamatoriTest extends TestCase
$this->assegna($mario, $this->territorioConPdf()); $this->assegna($mario, $this->territorioConPdf());
$assegnazione = Assegnazione::sole(); $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) Livewire::actingAs($this->admin)->test(Assegna::class)
->assertSet('invioId', null) ->assertSet('invioId', null)
->assertSee('Link ancora da inviare') ->dispatch('apri-invio', assegnazioneId: $assegnazione->id)
->call('apriInvio', $assegnazione->id)
->assertSee('Hai inviato il link?') ->assertSee('Hai inviato il link?')
->call('concludiInvio', false) ->call('concludiInvio', false)
->assertRedirect(route('assegnazioni.assegna')); ->assertRedirect(route('assegnazioni.assegna'));
@@ -260,7 +265,7 @@ class ContattiProclamatoriTest extends TestCase
->assertSee('Il link della piantina è ancora da inviare'); ->assertSee('Il link della piantina è ancora da inviare');
// Segnato come inviato dall'elenco: l'avviso sparisce. // 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) ->call('toggleLinkSent', $assegnazione->id)
->assertDontSee('Link ancora da inviare'); ->assertDontSee('Link ancora da inviare');
$this->assertFalse($assegnazione->fresh()->link_in_sospeso); $this->assertFalse($assegnazione->fresh()->link_in_sospeso);