++ gruppi: le azioni del sorvegliante non finiscono piu' in 403; priorita' e filtri nel pacchetto; nome del sorvegliante

- LimitaSorvegliante: la rotta delle azioni di Livewire 3.7 si chiama
  "default.livewire.update"; con 'livewire.*' ogni clic era un 403.
- Territorio::ordinaPerPriorita(): la regola di "Da assegnare" della Home in
  un metodo solo, usato dalla Home, dall'aggiunta al pacchetto e da "Il mio
  gruppo".
- Aggiunta al pacchetto: ricerca, zona, tipologia, solo prioritari,
  "Seleziona tutti quelli mostrati", badge e giorni di fermo.
- Il mio gruppo: da dare in ordine di priorita' poi i gia' dati; filtri
  stato, zona, tipologia, solo prioritari; sotto il titolo nome e cognome
  del sorvegliante.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-20 13:58:53 +02:00
parent d2d6b1bdce
commit ce98165667
12 changed files with 371 additions and 110 deletions

View File

@@ -219,6 +219,21 @@ Decisioni successive dell'utente (20 ottobre 2026):
temporanea". Si spegne a password cambiata (pagina obbligatoria o temporanea". Si spegne a password cambiata (pagina obbligatoria o
profilo); il ripristino e l'import XML la spengono per tutti, perché lì il profilo); il ripristino e l'import XML la spengono per tutti, perché lì il
motivo è il ripristino. motivo è il ripristino.
- **Bug: ogni clic del sorvegliante finiva in 403.** In Livewire 3.7 la
rotta delle azioni si chiama `default.livewire.update`, e
`LimitaSorvegliante` lasciava passare solo `livewire.*`: ora `*livewire.*`.
I test non lo vedevano perché `Livewire::test()` non passa dai middleware
delle rotte; ora un test chiama la rotta vera.
- **Priorità e filtri** (richiesta dell'utente): la regola della Home ("Da
assegnare": prioritari — spunta o giacenza oltre `giorni_giacenza_prioritari`
— poi giacenza, poi numero) è ora `Territorio::ordinaPerPriorita()`, usata
dalla Home, dall'aggiunta al pacchetto (`GruppoShow`: ricerca, zona,
tipologia, solo prioritari, "Seleziona tutti quelli mostrati") e da "Il mio
gruppo" (prima i da dare in ordine di priorità, poi i già dati dal più
vecchio; filtri stato, zona, tipologia, solo prioritari, nell'indirizzo).
Gli attributi calcolati si chiamano `giacenza_giorni` e `in_priorita`
(erano `home_giorni_giacenza` e `home_is_prioritario`, anche nel PDF).
- "Il mio gruppo" mostra sotto il titolo nome e cognome del sorvegliante.
- **Anche i membri si segnalano**: `Proclamatore::gruppo_etichetta` - **Anche i membri si segnalano**: `Proclamatore::gruppo_etichetta`
("Gruppo Zisa" / "Sorvegliante del gruppo Zisa") come badge nell'elenco ("Gruppo Zisa" / "Sorvegliante del gruppo Zisa") come badge nell'elenco
(più il filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa (più il filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa

View File

@@ -750,14 +750,21 @@ docker compose --profile dev up -d --build
chiede, non riceve posta e non va confermata. Due gruppi non possono avere chiede, non riceve posta e non va confermata. Due gruppi non possono avere
lo stesso nome. Se perde la password, l'amministratore ne genera un'altra lo stesso nome. Se perde la password, l'amministratore ne genera un'altra
dalla pagina del gruppo dalla pagina del gruppo
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto. - I territori del pacchetto si aggiungono in blocco tra quelli in reparto,
**in ordine di priorità** come "Da assegnare" nella Home (prima i
prioritari, poi quelli fermi da più tempo), con ricerca, filtri per zona,
tipologia e "solo prioritari", e "Seleziona tutti quelli mostrati".
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
cambiano), escono da "Da assegnare" e dalla pagina *Assegna* cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo - Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo
(ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne (ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne
nasce un'**assegnazione normale**, che conta come le altre ed è segnata nasce un'**assegnazione normale**, che conta come le altre ed è segnata
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri, "via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
apre la piantina e rimanda in reparto quelli non ancora dati apre la piantina e rimanda in reparto quelli non ancora dati. Il suo
pacchetto è in ordine di priorità (prima i da dare, prioritari in testa,
poi quelli già dati) e si filtra per stato, zona, tipologia e "solo
prioritari"; sotto il nome del gruppo compaiono nome e cognome del
sorvegliante
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto - Al **rientro** il territorio torna in reparto ed esce dal pacchetto
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il - **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
gruppo**, che non resta nello storico: i territori solo nel pacchetto gruppo**, che non resta nello storico: i territori solo nel pacchetto

View File

@@ -28,7 +28,10 @@ class LimitaSorvegliante
'password.change.store', 'password.change.store',
'logout', 'logout',
'verification.*', 'verification.*',
'livewire.*', // Le azioni Livewire: la rotta si chiama "default.livewire.update"
// (Livewire 3.7), non "livewire.update". Con 'livewire.*' ogni clic
// del sorvegliante finiva in 403.
'*livewire.*',
]; ];
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response

View File

@@ -32,6 +32,16 @@ class GruppoShow extends Component
/** @var int[] territori da aggiungere al pacchetto */ /** @var int[] territori da aggiungere al pacchetto */
public array $nuoviTerritori = []; public array $nuoviTerritori = [];
// Filtri dei territori in reparto da aggiungere al pacchetto. L'ordine è
// quello di priorità (Territorio::ordinaPerPriorita), come nella Home.
public string $cercaTerritorio = '';
public string $filtroZona = '';
public string $filtroTipologia = '';
public bool $soloPrioritari = false;
#[Locked] #[Locked]
public ?string $passwordTemporanea = null; public ?string $passwordTemporanea = null;
@@ -91,6 +101,45 @@ class GruppoShow extends Component
$this->nuoviTerritori = []; $this->nuoviTerritori = [];
} }
/** Tutti i territori che i filtri mostrano finiscono tra quelli da aggiungere. */
public function selezionaMostrati(): void
{
$this->nuoviTerritori = collect($this->nuoviTerritori)
->map(fn ($id) => (string) $id)
->merge($this->candidatiFiltrati()->pluck('id')->map(fn ($id) => (string) $id))
->unique()
->values()
->all();
}
public function deselezionaTutti(): void
{
$this->nuoviTerritori = [];
}
/** I territori in reparto, in ordine di priorità. */
private function candidati(): \Illuminate\Support\Collection
{
return Territorio::ordinaPerPriorita(
Territorio::inReparto()->with(['zona', 'tipologia', 'ultimaAssegnazione'])->get()
);
}
private function candidatiFiltrati(?\Illuminate\Support\Collection $candidati = null): \Illuminate\Support\Collection
{
$cerca = Str::lower(Str::ascii(trim($this->cercaTerritorio)));
return ($candidati ?? $this->candidati())
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
->when($cerca !== '', fn ($c) => $c->filter(fn (Territorio $t) => str_contains(
Str::lower(Str::ascii("{$t->numero} {$t->zona?->nome} {$t->tipologia?->nome}")),
$cerca
)))
->values();
}
public function rimanda(int $territorioId): void public function rimanda(int $territorioId): void
{ {
$territorio = Territorio::findOrFail($territorioId); $territorio = Territorio::findOrFail($territorioId);
@@ -135,13 +184,21 @@ class GruppoShow extends Component
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(); $membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values();
$territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values(); $territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
// I territori in reparto (attivi, non assegnati, fuori da ogni
// pacchetto), in ordine di priorità; zone e tipologie dei filtri sono
// quelle che ci sono tra loro.
$candidati = $this->candidati();
return view('livewire.gruppi.gruppo-show', [ return view('livewire.gruppi.gruppo-show', [
'membri' => $membri, 'membri' => $membri,
'territori' => $territori, 'territori' => $territori,
// Si possono aggiungere i proclamatori attivi senza gruppo... // Si possono aggiungere i proclamatori attivi senza gruppo...
'candidatiMembri' => Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(), 'candidatiMembri' => Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(),
// ...e i territori in reparto (attivi, non assegnati, fuori da ogni pacchetto). // ...e i territori in reparto.
'candidatiTerritori' => Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values(), 'totaleCandidati' => $candidati->count(),
'candidatiTerritori' => $this->candidatiFiltrati($candidati),
'zoneCandidati' => $candidati->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
'tipologieCandidati' => $candidati->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
]); ]);
} }
} }

View File

@@ -12,6 +12,7 @@ use App\Services\GruppiServizio;
use Carbon\Carbon; use Carbon\Carbon;
use InvalidArgumentException; use InvalidArgumentException;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
/** /**
@@ -35,6 +36,20 @@ class MioGruppo extends Component
public string $dataAssegnazione = ''; public string $dataAssegnazione = '';
// Filtri del pacchetto, nell'indirizzo: tornando dal rientro si ritrovano.
#[Url(except: '')]
public string $filtroZona = '';
#[Url(except: '')]
public string $filtroTipologia = '';
/** '' tutti, 'da_dare', 'dati' */
#[Url(except: '')]
public string $filtroStato = '';
#[Url(except: false)]
public bool $soloPrioritari = false;
public function mount(): void public function mount(): void
{ {
$this->dataAssegnazione = now()->format('Y-m-d'); $this->dataAssegnazione = now()->format('Y-m-d');
@@ -170,11 +185,25 @@ class MioGruppo extends Component
public function render() public function render()
{ {
$gruppo = $this->gruppo(); $gruppo = $this->gruppo()?->load('sorvegliante');
$territori = $gruppo // Prima quelli da dare, in ordine di priorità come "Da assegnare"
? $gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values() // nella Home (Territorio::ordinaPerPriorita); poi quelli già dati, dal
// più vecchio.
$pacchetto = $gruppo
? $gruppo->territori()->with(['zona', 'tipologia', 'ultimaAssegnazione', 'assegnazioneCorrente.proclamatore'])->get()
: collect(); : collect();
[$dati, $daDare] = $pacchetto->partition(fn (Territorio $t) => $t->assegnazioneCorrente !== null);
$pacchetto = Territorio::ordinaPerPriorita($daDare)
->concat($dati->sortBy(fn (Territorio $t) => $t->assegnazioneCorrente->assigned_at)->values());
$territori = $pacchetto
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
->when($this->filtroStato === 'da_dare', fn ($c) => $c->whereNull('assegnazioneCorrente'))
->when($this->filtroStato === 'dati', fn ($c) => $c->whereNotNull('assegnazioneCorrente'))
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
->values();
$membri = $gruppo $membri = $gruppo
? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values() ? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
@@ -182,7 +211,10 @@ class MioGruppo extends Component
return view('livewire.gruppi.mio-gruppo', [ return view('livewire.gruppi.mio-gruppo', [
'gruppo' => $gruppo, 'gruppo' => $gruppo,
'pacchetto' => $pacchetto,
'territori' => $territori, 'territori' => $territori,
'zone' => $pacchetto->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
'tipologie' => $pacchetto->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
'membri' => $membri, 'membri' => $membri,
'opzioniMembri' => Proclamatore::opzioniScelta($membri, conGruppo: false), 'opzioniMembri' => Proclamatore::opzioniScelta($membri, conGruppo: false),
'invio' => $this->invioCorrente(), 'invio' => $this->invioCorrente(),

View File

@@ -18,35 +18,10 @@ class Home extends Component
$this->authorize('territori.assign'); $this->authorize('territori.assign');
$settings = Setting::instance(); $settings = Setting::instance();
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$territori = Territorio::inReparto() $territori = Territorio::ordinaPerPriorita(
->with('zona', 'tipologia', 'ultimaAssegnazione') Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
->get()
->map(function (Territorio $territorio) use ($priorityThreshold) {
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorniGiacenza = 0;
}
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
$territorio->setAttribute(
'home_is_prioritario',
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
); );
return $territorio;
})
->sort(function (Territorio $left, Territorio $right) {
$p = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
if ($p !== 0) return $p;
$g = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
if ($g !== 0) return $g;
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})
->values();
$pdf = Pdf::loadView('pdf.territori-lista', [ $pdf = Pdf::loadView('pdf.territori-lista', [
'titolo' => 'Territori da Assegnare', 'titolo' => 'Territori da Assegnare',
@@ -90,7 +65,6 @@ class Home extends Component
{ {
$settings = Setting::instance(); $settings = Setting::instance();
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10)); $homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$annoCorrente = AnnoTeocratico::corrente(); $annoCorrente = AnnoTeocratico::corrente();
$campagnaAttiva = Campagna::attiva(); $campagnaAttiva = Campagna::attiva();
@@ -149,46 +123,11 @@ class Home extends Component
]; ];
} }
// Quick lists // Quick lists: la logica di priorità sta in Territorio::ordinaPerPriorita
$territoriDaAssegnare = Territorio::inReparto() // (la usano anche il pacchetto dei gruppi di servizio e "Il mio gruppo").
->with('zona', 'tipologia', 'ultimaAssegnazione') $territoriDaAssegnare = Territorio::ordinaPerPriorita(
->get() Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
->map(function (Territorio $territorio) use ($priorityThreshold) { )->take($homeLimit)->values();
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorniGiacenza = 0;
}
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
$territorio->setAttribute(
'home_is_prioritario',
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
);
return $territorio;
})
->sort(function (Territorio $left, Territorio $right) {
$priorityComparison = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
if ($priorityComparison !== 0) {
return $priorityComparison;
}
$giacenzaComparison = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
if ($giacenzaComparison !== 0) {
return $giacenzaComparison;
}
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})
->take($homeLimit)
->values();
$daRientrare = Territorio::daRientrare() $daRientrare = Territorio::daRientrare()
->with(['zona', 'assegnazioneCorrente.proclamatore']) ->with(['zona', 'assegnazioneCorrente.proclamatore'])

View File

@@ -169,6 +169,41 @@ class Territorio extends Model
return false; return false;
} }
/**
* La logica di priorità dei territori da distribuire, la stessa ovunque
* (Home "Da assegnare", pacchetto di un gruppo, "Il mio gruppo"): prima i
* prioritari — spunta manuale o fermi da più giorni della soglia
* "giorni_giacenza_prioritari" — poi chi è fermo da più tempo, poi il
* numero. Su ogni territorio imposta giacenza_giorni (dall'ultimo rientro,
* o dalla creazione se mai assegnato; 0 se è fuori) e in_priorita, da
* mostrare. Vuole 'ultimaAssegnazione' già caricata, per non fare una
* query a territorio.
*/
public static function ordinaPerPriorita(iterable $territori): \Illuminate\Support\Collection
{
$soglia = (int) (Setting::instance()->giorni_giacenza_prioritari ?? 180);
return collect($territori)
->each(function (self $territorio) use ($soglia) {
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorni = (int) $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorni = (int) $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorni = 0;
}
$territorio->setAttribute('giacenza_giorni', $giorni);
$territorio->setAttribute('in_priorita', (bool) $territorio->prioritario || $giorni > $soglia);
})
->sort(function (self $a, self $b) {
return [(int) $b->in_priorita, $b->giacenza_giorni] <=> [(int) $a->in_priorita, $a->giacenza_giorni]
?: strnatcasecmp((string) $a->numero, (string) $b->numero);
})
->values();
}
/** /**
* L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per * 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ò * numero, zona e tipologia. Con $motivo compare cercando ma non si può

View File

@@ -49,27 +49,65 @@
@endforelse @endforelse
</ul> </ul>
{{-- Assegnazione in blocco: si spuntano i territori in reparto --}} {{-- Assegnazione in blocco: si spuntano i territori in reparto, in
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile"> ordine di priorità (come "Da assegnare" nella Home), filtrabili. --}}
<div class="border-t border-gray-100 bg-gray-50 p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi territori al pacchetto</h3> <h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi territori al pacchetto</h3>
@if($candidatiTerritori->isEmpty()) @if($totaleCandidati === 0)
<p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p> <p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p>
@else @else
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i territori in reparto" <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 mb-2">
placeholder="Filtra per numero o zona…" <input wire:model.live.debounce.300ms="cercaTerritorio" type="search" inputmode="search" autocomplete="off" aria-label="Cerca tra i territori in reparto"
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500"> placeholder="Numero, zona o tipologia…"
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100"> class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
@foreach($candidatiTerritori as $c) <select wire:model.live="filtroZona" aria-label="Zona"
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40" class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->numero . ' ' . $c->zona?->nome)) }}" <option value="">Tutte le zone</option>
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-t-{{ $c->id }}"> @foreach($zoneCandidati as $id => $nome)
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviTerritori" class="h-5 w-5 rounded border-gray-300 text-indigo-600"> <option value="{{ $id }}">{{ $nome }}</option>
<span><strong>N° {{ $c->numero }}</strong> <span class="text-gray-500">{{ $c->zona?->nome }}</span></span>
</label>
@endforeach @endforeach
</select>
<select wire:model.live="filtroTipologia" aria-label="Tipologia"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le tipologie</option>
@foreach($tipologieCandidati as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<label class="flex items-center gap-2 text-sm text-gray-700 px-1">
<input type="checkbox" wire:model.live="soloPrioritari" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
Solo prioritari
</label>
</div> </div>
<div class="mt-3"> <div class="mb-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>{{ $candidatiTerritori->count() }} di {{ $totaleCandidati }} in reparto, prima i prioritari e quelli fermi da più tempo</span>
@if($candidatiTerritori->isNotEmpty())
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="selezionaMostrati">Seleziona tutti quelli mostrati</x-btn>
@endif
<x-btn variante="ghost" dimensione="sm" wire:click="deselezionaTutti">Deseleziona</x-btn>
</div>
<div class="max-h-96 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
@forelse($candidatiTerritori as $c)
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40" wire:key="candidato-t-{{ $c->id }}">
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviTerritori" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
<span class="min-w-0">
<strong>N° {{ $c->numero }}</strong>
@if($c->in_priorita)
<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
@endif
<span class="block text-xs text-gray-500">
{{ collect([$c->zona?->nome, $c->tipologia?->nome])->filter()->join(' — ') }}
@if($c->giacenza_giorni > 0) · fermo da {{ $c->giacenza_giorni }} gg @endif
</span>
</span>
</label>
@empty
<p class="px-3 py-6 text-center text-sm text-gray-500">Nessun territorio con questi filtri.</p>
@endforelse
</div>
<div class="mt-3 flex flex-wrap items-center gap-3">
<x-btn variante="primary" wire:click="aggiungiTerritori">Aggiungi i territori selezionati</x-btn> <x-btn variante="primary" wire:click="aggiungiTerritori">Aggiungi i territori selezionati</x-btn>
<span class="text-xs text-gray-500" x-data>Selezionati: <strong x-text="$wire.nuoviTerritori.length">{{ count($nuoviTerritori) }}</strong></span>
</div> </div>
@endif @endif
</div> </div>

View File

@@ -10,11 +10,14 @@
@else @else
<div class="mb-5"> <div class="mb-5">
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1> <h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
@if($gruppo->sorvegliante)
<p class="text-sm text-gray-700 mt-0.5">Sorvegliante: <strong>{{ trim($gruppo->sorvegliante->nome . ' ' . $gruppo->sorvegliante->cognome) }}</strong></p>
@endif
{{-- In questa vista solo blocchi php…endphp: la forma in linea, se {{-- In questa vista solo blocchi php…endphp: la forma in linea, se
viene prima di un blocco, confonde il compilatore di Blade. --}} viene prima di un blocco, confonde il compilatore di Blade. --}}
@php $daDare = $territori->filter(fn ($t) => ! $t->assegnazioneCorrente)->count(); @endphp @php $daDare = $pacchetto->filter(fn ($t) => ! $t->assegnazioneCorrente)->count(); @endphp
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
{{ $territori->count() }} {{ $territori->count() === 1 ? 'territorio' : 'territori' }} nel pacchetto · {{ $pacchetto->count() }} {{ $pacchetto->count() === 1 ? 'territorio' : 'territori' }} nel pacchetto ·
{{ $daDare }} da dare · {{ $membri->count() }} {{ $membri->count() === 1 ? 'membro' : 'membri' }} {{ $daDare }} da dare · {{ $membri->count() }} {{ $membri->count() === 1 ? 'membro' : 'membri' }}
</p> </p>
</div> </div>
@@ -29,14 +32,45 @@
<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-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>
</svg> </svg>
<div class="px-4 py-3 border-b border-gray-100 flex flex-wrap items-center justify-between gap-2"> <div class="px-4 py-3 border-b border-gray-100 space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 id="titolo-pacchetto" class="text-sm font-semibold text-gray-800">Territori del pacchetto</h2> <h2 id="titolo-pacchetto" class="text-sm font-semibold text-gray-800">Territori del pacchetto</h2>
@if($territori->count() > 5) @if($pacchetto->count() > 5)
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto" <input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto"
placeholder="Cerca numero, zona o nome…" placeholder="Cerca numero, zona o nome…"
class="w-full sm:w-64 rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500"> class="w-full sm:w-64 rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
@endif @endif
</div> </div>
{{-- Filtri; l'ordine è di priorità: prima i prioritari e quelli fermi da più tempo --}}
@if($pacchetto->isNotEmpty())
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<select wire:model.live="filtroStato" aria-label="Stato"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutti</option>
<option value="da_dare">Da dare</option>
<option value="dati">Già dati</option>
</select>
<select wire:model.live="filtroZona" aria-label="Zona"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le zone</option>
@foreach($zone as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<select wire:model.live="filtroTipologia" aria-label="Tipologia"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le tipologie</option>
@foreach($tipologie as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<label class="flex items-center gap-2 text-sm text-gray-700 px-1">
<input type="checkbox" wire:model.live="soloPrioritari" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
Solo prioritari
</label>
</div>
@endif
</div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
@forelse($territori as $t) @forelse($territori as $t)
@@ -47,11 +81,12 @@
@endphp @endphp
<div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato"> <div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
<div class="tm-assegnato-testa"> <div class="tm-assegnato-testa">
<span><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif</span> <span><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif
@if($t->in_priorita)<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>@endif</span>
@if($a) @if($a)
<span class="tm-assegnato-info"><b>{{ $a->proclamatore?->nome_completo }}</b> · dal {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span> <span class="tm-assegnato-info"><b>{{ $a->proclamatore?->nome_completo }}</b> · dal {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
@else @else
<span class="tm-assegnato-info"><b>Da dare</b></span> <span class="tm-assegnato-info"><b>Da dare</b>@if($t->tipologia?->nome) · {{ $t->tipologia->nome }}@endif @if($t->giacenza_giorni > 0) · fermo da {{ $t->giacenza_giorni }} gg @endif</span>
@endif @endif
</div> </div>
@@ -101,7 +136,9 @@
@endif @endif
</div> </div>
@empty @empty
<p class="px-4 py-8 text-center text-sm text-gray-500">Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo.</p> <p class="px-4 py-8 text-center text-sm text-gray-500">
{{ $pacchetto->isEmpty() ? "Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo." : 'Nessun territorio con questi filtri.' }}
</p>
@endforelse @endforelse
<p x-show="nessuno" x-cloak class="px-4 py-8 text-center text-sm text-gray-400">Nessun risultato per «<span x-text="q"></span>».</p> <p x-show="nessuno" x-cloak class="px-4 py-8 text-center text-sm text-gray-400">Nessun risultato per «<span x-text="q"></span>».</p>
</div> </div>
@@ -111,7 +148,7 @@
<h2 id="titolo-membri" class="text-sm font-semibold text-gray-800 mb-2">Membri del gruppo</h2> <h2 id="titolo-membri" class="text-sm font-semibold text-gray-800 mb-2">Membri del gruppo</h2>
<ul class="text-sm text-gray-700 space-y-1"> <ul class="text-sm text-gray-700 space-y-1">
@foreach($membri as $m) @foreach($membri as $m)
@php $quanti = $territori->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp @php $quanti = $pacchetto->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp
<li class="flex justify-between gap-3"> <li class="flex justify-between gap-3">
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span> <span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
<span class="text-gray-500">{{ $quanti }} {{ $quanti === 1 ? 'territorio' : 'territori' }}</span> <span class="text-gray-500">{{ $quanti }} {{ $quanti === 1 ? 'territorio' : 'territori' }}</span>

View File

@@ -110,14 +110,14 @@
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a data-ritorno href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a> <a data-ritorno href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
@if($t->home_is_prioritario) @if($t->in_priorita)
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
@endif @endif
</div> </div>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }} {{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}
@if($t->home_giorni_giacenza > 0) @if($t->giacenza_giorni > 0)
— <span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto — <span class="font-medium">{{ $t->giacenza_giorni }} gg</span> in reparto
@endif @endif
</p> </p>
</div> </div>

View File

@@ -44,9 +44,9 @@
<td style="font-weight:bold">{{ $t->numero }}</td> <td style="font-weight:bold">{{ $t->numero }}</td>
<td>{{ $t->zona?->nome ?? '-' }}</td> <td>{{ $t->zona?->nome ?? '-' }}</td>
<td>{{ $t->tipologia?->nome ?? '-' }}</td> <td>{{ $t->tipologia?->nome ?? '-' }}</td>
<td>{{ $t->home_giorni_giacenza }} gg</td> <td>{{ $t->giacenza_giorni }} gg</td>
<td> <td>
@if($t->home_is_prioritario) @if($t->in_priorita)
<span class="prioritario">★ Prioritario</span> <span class="prioritario">★ Prioritario</span>
@else @else
- -

View File

@@ -609,6 +609,104 @@ class GruppiServizioTest extends TestCase
} }
} }
// ─── Le azioni del sorvegliante passano dal middleware ──────
public function test_le_azioni_livewire_del_sorvegliante_non_finiscono_in_403(): void
{
// Livewire::test() non passa dai middleware delle rotte: qui si chiama
// la rotta vera delle azioni (in Livewire 3.7 "default.livewire.update",
// che il filtro 'livewire.*' non riconosceva: ogni clic era un 403).
['sorvegliante' => $s] = $this->gruppo();
$this->flushSession();
$rotta = collect(app('router')->getRoutes()->getRoutes())
->first(fn ($r) => str_ends_with($r->uri(), 'livewire/update'));
$this->assertNotNull($rotta);
$this->withoutExceptionHandling();
try {
$this->actingAs($s)->post('/' . ltrim($rotta->uri(), '/'), ['components' => []], ['X-Livewire' => 'true']);
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
$this->assertNotSame(403, $e->getStatusCode(), $e->getMessage());
} catch (\Throwable) {
// Livewire rifiuta la richiesta vuota: non è il 403 del sorvegliante.
}
$this->addToAssertionCount(1);
}
// ─── Priorità e filtri ──────────────────────────────────────
/** Territori in reparto con giacenze diverse: 40 (200 gg), 41 (50 gg), 42 (spunta prioritario), 43 (nuovo). */
private function territoriConPriorita(): array
{
$zona = \App\Models\Zona::create(['nome' => 'Noce', 'attivo' => true]);
$altra = \App\Models\Zona::create(['nome' => 'Zisa', 'attivo' => true]);
$t = [];
foreach (['40' => 200, '41' => 50, '42' => 0, '43' => 0] as $numero => $giorni) {
$t[$numero] = $this->territorio($numero);
$t[$numero]->forceFill([
'created_at' => now()->subDays($giorni),
'prioritario' => $numero === '42',
'zona_id' => $numero === '43' ? $altra->id : $zona->id,
])->save();
}
return $t;
}
public function test_aggiungendo_al_pacchetto_i_territori_sono_in_ordine_di_priorita_e_filtrabili(): void
{
['gruppo' => $gruppo] = $this->gruppo();
$t = $this->territoriConPriorita();
$pagina = Livewire::test(GruppoShow::class, ['gruppo' => $gruppo]);
// Prima i prioritari (per giacenza), poi gli altri: la regola della Home.
$this->assertSame(['40', '42', '41', '43'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
$pagina->assertSee('★ Prioritario')->assertSee('fermo da 200 gg');
$pagina->set('soloPrioritari', true);
$this->assertSame(['40', '42'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
$pagina->set('soloPrioritari', false)->set('filtroZona', (string) $t['43']->zona_id);
$this->assertSame(['43'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
$pagina->set('filtroZona', '')->set('cercaTerritorio', '41');
$this->assertSame(['41'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
// "Seleziona tutti quelli mostrati" e aggiunta in blocco.
$pagina->set('cercaTerritorio', '')->set('soloPrioritari', true)
->call('selezionaMostrati')
->call('aggiungiTerritori');
$this->assertSame($gruppo->id, $t['40']->fresh()->gruppo_servizio_id);
$this->assertSame($gruppo->id, $t['42']->fresh()->gruppo_servizio_id);
$this->assertNull($t['41']->fresh()->gruppo_servizio_id);
}
public function test_il_sorvegliante_vede_il_pacchetto_in_ordine_di_priorita_e_lo_filtra(): void
{
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $vecchi] = $this->gruppo();
$t = $this->territoriConPriorita();
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, collect($t)->pluck('id')->all());
// Il pacchetto di gruppo() (12, 14, 35) va via, tranne il 12 che si dà.
app(GruppiServizio::class)->assegna($gruppo, $vecchi[0], $verdi, now());
app(GruppiServizio::class)->rimandaInReparto($gruppo, $vecchi[1]->fresh());
app(GruppiServizio::class)->rimandaInReparto($gruppo, $vecchi[2]->fresh());
$this->flushSession();
$pagina = Livewire::actingAs($s)->test(MioGruppo::class)
->assertSee('Sorvegliante: <strong>Mario Rossi</strong>', false);
// Da dare in ordine di priorità, poi quelli già dati.
$this->assertSame(['40', '42', '41', '43', '12'], $pagina->viewData('territori')->pluck('numero')->all());
$pagina->set('filtroStato', 'da_dare');
$this->assertSame(['40', '42', '41', '43'], $pagina->viewData('territori')->pluck('numero')->all());
$pagina->set('filtroStato', 'dati');
$this->assertSame(['12'], $pagina->viewData('territori')->pluck('numero')->all());
$pagina->set('filtroStato', '')->set('soloPrioritari', true);
$this->assertSame(['40', '42'], $pagina->viewData('territori')->pluck('numero')->all());
$pagina->set('soloPrioritari', false)->set('filtroZona', (string) $t['43']->zona_id);
$this->assertSame(['43'], $pagina->viewData('territori')->pluck('numero')->all());
}
// ─── Backup e azzeramento ─────────────────────────────────── // ─── Backup e azzeramento ───────────────────────────────────
public function test_l_azzeramento_toglie_anche_gruppi_e_utenza_del_sorvegliante(): void public function test_l_azzeramento_toglie_anche_gruppi_e_utenza_del_sorvegliante(): void