++ gruppi: chi fa parte di un gruppo di servizio e' segnalato

Badge "Gruppo ..." / "Sorvegliante del gruppo ..." nell'elenco dei
proclamatori (con il filtro "In un gruppo di servizio") e in testa alla
scheda; nella scelta del proclamatore (Assegna, Registro) la nota
"gruppo ..." accanto al nome, che resta sceglibile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-20 11:09:07 +02:00
parent 7a54c15852
commit a93539242a
8 changed files with 82 additions and 4 deletions

View File

@@ -181,6 +181,13 @@ Decisioni successive dell'utente (20 ottobre 2026):
accesso dal registro), oppure il gruppo di cui è membro, e i gruppi sciolti accesso dal registro), oppure il gruppo di cui è membro, e i gruppi sciolti
che ha guidato. Il *Profilo* del sorvegliante mostra il suo gruppo con gli che ha guidato. Il *Profilo* del sorvegliante mostra il suo gruppo con gli
stessi numeri e "Torna al mio gruppo". stessi numeri e "Torna al mio gruppo".
- **Anche i membri si segnalano**: `Proclamatore::gruppo_etichetta`
("Gruppo Zisa" / "Sorvegliante del gruppo Zisa") come badge nell'elenco
(più il filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa
alla scheda; nelle opzioni di `<x-scelta>` come `dettaglio` (Assegna,
Registro), non come `motivo`: assegnare direttamente a un membro resta
possibile. In "Il mio gruppo" la nota non c'è (`opzioniScelta(...,
conGruppo: false)`).
- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro - L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro
delle attività `User::tapActivity()` li sostituisce con "proclamatore #id", delle attività `User::tapActivity()` li sostituisce con "proclamatore #id",
come vale per tutti i proclamatori. Restano invece in chiaro nella tabella come vale per tutti i proclamatori. Restano invece in chiaro nella tabella

View File

@@ -766,6 +766,11 @@ docker compose --profile dev up -d --build
gruppo. Un membro non attivo resta nel gruppo ma non riceve territori; nel gruppo. Un membro non attivo resta nel gruppo ma non riceve territori; nel
cestino esce dal gruppo. Anonimizzando un ex sorvegliante si anonimizza cestino esce dal gruppo. Anonimizzando un ex sorvegliante si anonimizza
anche la sua utenza anche la sua utenza
- **Chi fa parte di un gruppo è segnalato**: badge "Gruppo …" o
"Sorvegliante del gruppo …" nell'elenco dei proclamatori (con il filtro
*In un gruppo di servizio*) e in testa alla scheda; nella scelta del
proclamatore (Assegna, Registro) la nota "gruppo …" accanto al nome, che
resta comunque sceglibile
- **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante - **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante
(gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal (gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal
gruppo, stato dell'utenza e ultimo accesso), membro, o sorvegliante di gruppo, stato dell'utenza e ultimo accesso), membro, o sorvegliante di

View File

@@ -184,7 +184,7 @@ class MioGruppo extends Component
'gruppo' => $gruppo, 'gruppo' => $gruppo,
'territori' => $territori, 'territori' => $territori,
'membri' => $membri, 'membri' => $membri,
'opzioniMembri' => Proclamatore::opzioniScelta($membri), 'opzioniMembri' => Proclamatore::opzioniScelta($membri, conGruppo: false),
'invio' => $this->invioCorrente(), 'invio' => $this->invioCorrente(),
]); ]);
} }

View File

@@ -106,7 +106,7 @@ class ProclamatoreIndex extends Component
// withCount rather than letting the view call // withCount rather than letting the view call
// ->assegnazioni()->aperte()->count() per row, which issued one // ->assegnazioni()->aperte()->count() per row, which issued one
// COUNT(*) per displayed proclamatore on every render. // COUNT(*) per displayed proclamatore on every render.
$query = Proclamatore::query()->withCount([ $query = Proclamatore::query()->with('gruppo')->withCount([
'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'), 'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'),
]); ]);
@@ -124,6 +124,8 @@ class ProclamatoreIndex extends Component
$filtered = $filtered->where('attivo', true); $filtered = $filtered->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') { } elseif ($this->filtroStato === 'inattivo') {
$filtered = $filtered->where('attivo', false); $filtered = $filtered->where('attivo', false);
} elseif ($this->filtroStato === 'gruppo') {
$filtered = $filtered->whereNotNull('gruppo_servizio_id');
} }
// Sort in-memory // Sort in-memory
@@ -148,6 +150,8 @@ class ProclamatoreIndex extends Component
$query->where('attivo', true); $query->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') { } elseif ($this->filtroStato === 'inattivo') {
$query->where('attivo', false); $query->where('attivo', false);
} elseif ($this->filtroStato === 'gruppo') {
$query->whereNotNull('gruppo_servizio_id');
} }
// cognome/nome encrypted, so sort in-memory // cognome/nome encrypted, so sort in-memory

View File

@@ -103,9 +103,13 @@ class Proclamatore extends Model
*/ */
public function opzioneScelta(?string $motivo = null): array public function opzioneScelta(?string $motivo = null): array
{ {
// Il gruppo di servizio come nota, solo se già caricato (opzioniScelta).
$gruppo = $this->relationLoaded('gruppo') ? $this->gruppo_etichetta : null;
return array_filter([ return array_filter([
'valore' => (string) $this->id, 'valore' => (string) $this->id,
'etichetta' => $this->nome_completo, 'etichetta' => $this->nome_completo,
'dettaglio' => $gruppo !== null ? lcfirst($gruppo) : null,
'chiavi' => [(string) $this->cognome, (string) $this->nome], 'chiavi' => [(string) $this->cognome, (string) $this->nome],
'motivo' => $motivo, 'motivo' => $motivo,
], fn ($v) => $v !== null); ], fn ($v) => $v !== null);
@@ -113,10 +117,16 @@ class Proclamatore extends Model
/** /**
* Le opzioni di un elenco di proclamatori, in ordine di cognome e nome. * Le opzioni di un elenco di proclamatori, in ordine di cognome e nome.
* L'ordine si fa qui: in SQL ordinerebbe i testi cifrati. * L'ordine si fa qui: in SQL ordinerebbe i testi cifrati. Con $conGruppo
* chi fa parte di un gruppo di servizio lo dice accanto al nome (non serve
* dove sono tutti dello stesso gruppo, come in "Il mio gruppo").
*/ */
public static function opzioniScelta(iterable $proclamatori): array public static function opzioniScelta(iterable $proclamatori, bool $conGruppo = true): array
{ {
if ($conGruppo && $proclamatori instanceof \Illuminate\Database\Eloquent\Collection) {
$proclamatori->loadMissing('gruppo');
}
return collect($proclamatori) return collect($proclamatori)
->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome)) ->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome))
->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo')) ->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo'))
@@ -124,6 +134,22 @@ class Proclamatore extends Model
->all(); ->all();
} }
/**
* "Sorvegliante del gruppo Zisa" o "Gruppo Zisa"; null fuori dai gruppi.
* Senza gruppo_servizio_id non fa query.
*/
public function getGruppoEtichettaAttribute(): ?string
{
$gruppo = $this->gruppo;
if (! $gruppo) {
return null;
}
return $gruppo->sorvegliante_id === $this->id
? "Sorvegliante del gruppo {$gruppo->nome}"
: "Gruppo {$gruppo->nome}";
}
public function assegnazioni() public function assegnazioni()
{ {
return $this->hasMany(Assegnazione::class, 'proclamatore_id'); return $this->hasMany(Assegnazione::class, 'proclamatore_id');

View File

@@ -26,6 +26,7 @@
<option value="">Tutti</option> <option value="">Tutti</option>
<option value="attivo">Attivi</option> <option value="attivo">Attivi</option>
<option value="inattivo">Inattivi</option> <option value="inattivo">Inattivi</option>
<option value="gruppo">In un gruppo di servizio</option>
</select> </select>
</div> </div>
</div> </div>
@@ -53,11 +54,16 @@
<td data-label="Cognome" class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td> <td data-label="Cognome" class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td>
<td data-label="Nome" class="px-4 py-3 text-sm text-gray-700">{{ $proclamatore->nome }}</td> <td data-label="Nome" class="px-4 py-3 text-sm text-gray-700">{{ $proclamatore->nome }}</td>
<td data-label="Stato" class="px-4 py-3 text-sm"> <td data-label="Stato" class="px-4 py-3 text-sm">
<div class="flex flex-wrap gap-1">
@if($proclamatore->attivo) @if($proclamatore->attivo)
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attivo</span> <span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attivo</span>
@else @else
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span> <span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
@endif @endif
@if($proclamatore->gruppo_etichetta)
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-800">{{ $proclamatore->gruppo_etichetta }}</span>
@endif
</div>
</td> </td>
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td> <td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
<td class="px-4 py-3 text-sm text-right cell-actions"> <td class="px-4 py-3 text-sm text-right cell-actions">

View File

@@ -6,6 +6,9 @@
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }} {{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
@if($proclamatore->gruppo_etichetta)
<span class="mt-1 inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">{{ $proclamatore->gruppo_etichetta }}</span>
@endif
</div> </div>
@can('settings.manage') @can('settings.manage')
<x-btn data-ritorno variante="secondary" :href="route('proclamatori.edit', $proclamatore)"> <x-btn data-ritorno variante="secondary" :href="route('proclamatori.edit', $proclamatore)">

View File

@@ -472,6 +472,33 @@ class GruppiServizioTest extends TestCase
->assertDontSee('Assegnazioni dal gruppo'); ->assertDontSee('Assegnazioni dal gruppo');
} }
public function test_chi_fa_parte_di_un_gruppo_e_segnalato_in_elenco_scheda_e_scelta(): void
{
$g = $this->gruppo();
$this->proclamatore('Neri');
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
->assertSee('Sorvegliante del gruppo Zisa')
->assertSee('Gruppo Zisa')
->set('filtroStato', 'gruppo')
->assertSee('Verdi')->assertSee('Bianchi')->assertSee('Rossi')
->assertDontSee('Neri');
$this->get(route('proclamatori.show', $g['verdi']))->assertOk()->assertSee('Gruppo Zisa');
// Nella tendina di Assegna la nota accanto al nome...
$opzioni = collect(Livewire::test(Assegna::class)->viewData('opzioniProclamatori'))->keyBy('etichetta');
$this->assertSame('gruppo Zisa', $opzioni['Verdi Anna']['dettaglio']);
$this->assertSame('sorvegliante del gruppo Zisa', $opzioni['Rossi Mario']['dettaglio']);
$this->assertArrayNotHasKey('dettaglio', $opzioni['Neri Test']);
$this->assertArrayNotHasKey('motivo', $opzioni['Verdi Anna'], 'Si può comunque scegliere');
// ...che in "Il mio gruppo", dove sono tutti dello stesso gruppo, non serve.
$this->flushSession();
$membri = Livewire::actingAs($g['sorvegliante'])->test(MioGruppo::class)->viewData('opzioniMembri');
$this->assertSame([], collect($membri)->pluck('dettaglio')->filter()->all());
}
public function test_il_profilo_del_sorvegliante_mostra_il_suo_gruppo(): void public function test_il_profilo_del_sorvegliante_mostra_il_suo_gruppo(): void
{ {
$g = $this->gruppo(); $g = $this->gruppo();