++ gruppi: il gruppo sciolto si elimina; il sorvegliante entra con un nome utente dal nome del gruppo

- Sciolto, il gruppo non resta nello storico: si elimina (via sciolto_at;
  la migrazione elimina anche quelli gia' sciolti). I territori solo nel
  pacchetto tornano in reparto; quelli gia' dati restano ai membri, e
  l'assegnazione conserva il nome del gruppo (assegnazioni.gruppo_nome).
- Creando il gruppo non si chiede l'email: users.username dal nome del
  gruppo ("Gruppo Zisa" -> gruppo.zisa), users.email facoltativa. Login con
  "Email o nome utente". Nome del gruppo unico.
- Sciolto il gruppo il nome utente si libera (l'utenza disattivata prende
  ex.sorvegliante.<id>). XML: nome utente e gruppo_nome.
- Profilo, testata, cambio password e Utenti mostrano il nome utente dove
  manca l'email. Informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-20 11:45:00 +02:00
parent a93539242a
commit aaffeb7de7
29 changed files with 408 additions and 199 deletions

View File

@@ -148,19 +148,21 @@ e non vede nient'altro. Scelte dell'utente:
- Il sorvegliante: assegna, manda il link (copia/condividi), registra i
rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e
pacchetti li gestisce **solo l'amministratore**.
- Utenza: email e password temporanea da cambiare al primo accesso, **senza
- Utenza: password temporanea da cambiare al primo accesso, **senza
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il
sorvegliante; anche cambiandola dal profilo).
sorvegliante). *Dal 20 ottobre: niente email, nome utente dal nome del
gruppo — vedi sotto.*
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
non dati in reparto, membri liberi, utenza senza ruolo e disattivata
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I
territori già dati restano ai membri. Si riparte con un gruppo nuovo.
`LimitaSorvegliante`). I territori già dati restano ai membri. Si riparte
con un gruppo nuovo. *Dal 20 ottobre il gruppo sciolto si elimina — vedi
sotto.*
Casi aggiunti di mio, da far confermare all'utente:
- Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la
sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id`
è unico), con email e password temporanea nuove.
è unico), con nome utente e password temporanea nuovi.
- `Proclamatore::anonimizza()` anonimizza anche l'utenza dell'ex sorvegliante
(nome, email, password, ruoli).
@@ -177,10 +179,33 @@ Decisioni successive dell'utente (20 ottobre 2026):
territori); nel cestino esce dal gruppo.
- **Dettagli nel profilo**: la scheda del proclamatore (`ProclamatoreShow`)
mostra il gruppo che guida con i numeri (`GruppoServizio::riepilogo()`),
l'utenza (email solo all'amministratore, password temporanea o no, ultimo
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
stessi numeri e "Torna al mio gruppo".
l'utenza (nome utente solo all'amministratore, password temporanea o no,
ultimo accesso dal registro), oppure il gruppo di cui è membro. Il
*Profilo* del sorvegliante mostra il suo gruppo con gli stessi numeri e
"Torna al mio gruppo".
- **Il gruppo sciolto non resta nello storico**: `sciogli()` lo elimina
(colonna `sciolto_at` tolta dalla migrazione `2026_10_20_000020`, che
elimina anche i gruppi già sciolti). I territori solo nel pacchetto tornano
in reparto (conferma esplicita dell'utente); quelli già dati restano ai
membri come assegnazioni qualsiasi. Sulla traccia delle assegnazioni
l'utente non si è espresso: ho tenuto la scelta che non perde dati, il
nome del gruppo copiato sull'assegnazione (`assegnazioni.gruppo_nome`,
"via gruppo …" in registro e scheda territorio). Toglierlo è un attimo, se
lo chiede. Dopo lo scioglimento si torna all'elenco dei gruppi; nella
scheda del proclamatore non c'è più "In passato sorvegliante di".
- **Nome utente al posto dell'email**: creando il gruppo non si chiede
l'email; `GruppiServizio::nomeUtente()` ricava `users.username` dal nome
del gruppo (`Str::slug` con il punto: «Gruppo Zisa» → `gruppo.zisa`,
numero in coda se già preso; mai una chiocciola). `users.email` è diventata
facoltativa. Il login ha un solo campo, "Email o nome utente"
(`LoginController`: con la chiocciola cerca l'email, senza il nome utente,
in minuscolo). Dove si mostrava l'email dell'utente ora c'è
`User::identificativo` (email, o nome utente). Il profilo del sorvegliante
mostra il nome utente e non chiede l'email. Il nome del gruppo è unico.
Sciolto il gruppo, il nome utente si libera e l'utenza disattivata prende
`ex.sorvegliante.<id>` (l'anonimizzata `anonimo.<id>`): resta riconoscibile
per l'import XML, che individua gli utenti per email o, senza, per nome
utente. Un gruppo con `<sciolto_at>` in un XML vecchio non si importa.
- **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
@@ -214,7 +239,8 @@ Dove sta:
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
l'amministratore passa ogni `@can` (`Gate::before`).
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
assegnazioni e il legame utenza ↔ proclamatore.
assegnazioni (id e nome del gruppo), nome utente e legame utenza ↔
proclamatore.
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
del proclamatore).
- L'azzeramento dell'applicazione (*Zona pericolosa*) cancella anche i gruppi.

View File

@@ -741,10 +741,13 @@ docker compose --profile dev up -d --build
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
anche membro
- Creando il gruppo il sorvegliante riceve un'**utenza**: l'email indicata e
una password temporanea, mostrata una volta sola e da cambiare al primo
accesso. **Nessuna conferma dell'email**. Se la perde, l'amministratore ne
genera un'altra dalla pagina del gruppo
- Creando il gruppo il sorvegliante riceve un'**utenza senza email**: un
**nome utente ricavato dal nome del gruppo** («Gruppo Zisa» →
`gruppo.zisa`; se è già preso, un numero in coda) e una password
temporanea, mostrati una volta sola, da cambiare al primo accesso. Al login
il campo accetta l'email o il nome utente. Due gruppi non possono avere lo
stesso nome. Se perde la password, l'amministratore ne genera un'altra
dalla pagina del gruppo
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto.
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
@@ -754,10 +757,12 @@ docker compose --profile dev up -d --build
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
apre la piantina e rimanda in reparto quelli non ancora dati
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante: i territori non
ancora dati tornano in reparto, i membri si liberano, l'utenza viene
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello
storico. I territori già dati restano ai membri. Per ripartire si crea un
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
gruppo**, che non resta nello storico: i territori solo nel pacchetto
tornano in reparto, i membri si liberano, l'utenza viene disattivata (anche
una sessione aperta si chiude) e il suo nome utente torna libero. I
territori già dati restano ai membri come assegnazioni qualsiasi, che del
gruppo conservano solo il nome ("via gruppo …"). Per ripartire si crea un
gruppo nuovo; se il sorvegliante è lo stesso, si riattiva la sua utenza
- **Un sorvegliante che non è più attivo scioglie il gruppo**, come togliergli
il ruolo: succede se lo si rende non attivo (dall'elenco o dalla modifica),
@@ -773,8 +778,8 @@ docker compose --profile dev up -d --build
resta comunque sceglibile
- **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante
(gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal
gruppo, stato dell'utenza e ultimo accesso), membro, o sorvegliante di
gruppi ormai sciolti; il sorvegliante ritrova gli stessi numeri nel suo
gruppo, nome utente, stato della password e ultimo accesso) o membro; il
sorvegliante ritrova gli stessi numeri nel suo
*Profilo*
### Campagne
@@ -887,16 +892,16 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| Tabella | Descrizione |
|-------------------|--------------------------------------------------------------|
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova; `proclamatore_id` lega l'utenza del sorvegliante al suo proclamatore, `disattivato_at` la chiude allo scioglimento del gruppo |
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova; `email` facoltativa e `username` per il sorvegliante, che entra con il nome utente del gruppo; `proclamatore_id` lega l'utenza del sorvegliante al suo proclamatore, `disattivato_at` la chiude allo scioglimento del gruppo |
| `settings` | Configurazione singleton |
| `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio |
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
| `gruppi_servizio` | Gruppi di servizio: nome, sorvegliante, `sciolto_at` (sciolti restano per lo storico) |
| `gruppi_servizio` | Gruppi di servizio attivi: nome (unico), sorvegliante. Sciolto, il gruppo si elimina |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo |
| `anni_teocratici` | Periodi settembre → agosto |
| `campagne` | Periodi speciali con date e descrizione |
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo |
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo ancora attivo, `gruppo_nome` che resta anche dopo lo scioglimento |
| `activity_log` | Audit trail |
**Vincoli e indici principali**

View File

@@ -13,8 +13,11 @@ class LoginController extends Controller
{
public function __invoke(Request $request): RedirectResponse
{
// Il campo si chiama "email" ma accetta anche il nome utente del
// sorvegliante di gruppo, che l'email non ce l'ha: con la chiocciola è
// un'email, senza un nome utente (che la chiocciola non la contiene mai).
$credentials = $request->validate([
'email' => ['required', 'email'],
'email' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:6'],
'remember' => ['nullable', 'boolean'],
]);
@@ -29,8 +32,11 @@ class LoginController extends Controller
->withInput($request->only('email', 'remember'));
}
$identificativo = trim($credentials['email']);
$campo = str_contains($identificativo, '@') ? 'email' : 'username';
if (! Auth::attempt([
'email' => $credentials['email'],
$campo => $campo === 'username' ? Str::lower($identificativo) : $identificativo,
'password' => $credentials['password'],
], $request->boolean('remember'))) {
RateLimiter::hit($throttleKey);

View File

@@ -12,9 +12,10 @@ use Livewire\Component;
/**
* Gruppi di servizio (solo amministratore): elenco e creazione. Creando un
* gruppo si sceglie il sorvegliante, che riceve un'utenza con l'email
* indicata e una password temporanea, mostrata qui una volta sola.
* Membri e pacchetto si gestiscono nella pagina del gruppo (GruppoShow).
* gruppo si sceglie il sorvegliante, che riceve un'utenza con un nome utente
* ricavato dal nome del gruppo (niente email) e una password temporanea,
* mostrati qui una volta sola. Membri e pacchetto si gestiscono nella pagina
* del gruppo (GruppoShow).
*/
class GruppiIndex extends Component
{
@@ -24,8 +25,6 @@ class GruppiIndex extends Component
public ?int $sorveglianteId = null;
public string $email = '';
/** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */
#[Locked]
public ?int $creatoId = null;
@@ -38,19 +37,17 @@ class GruppiIndex extends Component
$this->authorize('settings.manage');
$this->validate([
'nome' => 'required|string|max:100',
'nome' => 'required|string|max:100|unique:gruppi_servizio,nome',
'sorveglianteId' => 'required|integer|exists:proclamatori,id',
'email' => 'required|email|max:255',
], [
'nome.unique' => 'Esiste già un gruppo con questo nome.',
'sorveglianteId.required' => 'Scegli il sorvegliante.',
'email.required' => "Serve l'email con cui il sorvegliante entrerà.",
]);
try {
[$gruppo, $password] = app(GruppiServizio::class)->crea(
$this->nome,
Proclamatore::findOrFail($this->sorveglianteId),
trim($this->email),
);
} catch (InvalidArgumentException $e) {
$this->addError('sorveglianteId', $e->getMessage());
@@ -58,7 +55,7 @@ class GruppiIndex extends Component
return;
}
$this->reset(['nome', 'sorveglianteId', 'email']);
$this->reset(['nome', 'sorveglianteId']);
$this->creatoId = $gruppo->id;
$this->passwordTemporanea = $password;
}
@@ -72,7 +69,6 @@ class GruppiIndex extends Component
{
$gruppi = GruppoServizio::with('sorvegliante')
->withCount(['membri', 'territori'])
->orderByRaw('sciolto_at IS NOT NULL')
->orderBy('nome')
->get();

View File

@@ -102,7 +102,7 @@ class GruppoShow extends Component
{
$this->servizio();
$utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first();
if (! $utente || $this->gruppo->sciolto_at) {
if (! $utente) {
$this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
return;
@@ -115,11 +115,17 @@ class GruppoShow extends Component
$this->passwordTemporanea = $password;
}
/** Toglie il ruolo di sorvegliante: il gruppo si scioglie. */
public function sciogli(): void
/**
* Toglie il ruolo di sorvegliante: il gruppo si scioglie e si elimina, si
* torna all'elenco dei gruppi.
*/
public function sciogli()
{
$this->esegui(fn () => $this->servizio()->sciogli($this->gruppo), "Gruppo {$this->gruppo->nome} sciolto.");
$this->passwordTemporanea = null;
$nome = $this->gruppo->nome;
$this->servizio()->sciogli($this->gruppo);
session()->flash('success', "Gruppo {$nome} sciolto: i territori non ancora dati sono tornati in reparto e l'utenza del sorvegliante è disattivata.");
return $this->redirect(route('gruppi.index'), navigate: true);
}
public function render()
@@ -129,21 +135,13 @@ class GruppoShow extends Component
$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();
$attivo = ! $this->gruppo->sciolto_at;
return view('livewire.gruppi.gruppo-show', [
'attivo' => $attivo,
'membri' => $membri,
'territori' => $territori,
// Si possono aggiungere i proclamatori attivi senza gruppo...
'candidatiMembri' => $attivo
? Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
: collect(),
'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).
'candidatiTerritori' => $attivo
? Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values()
: collect(),
'assegnazioniDelGruppo' => $this->gruppo->assegnazioni()->count(),
'candidatiTerritori' => Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values(),
]);
}
}

View File

@@ -175,7 +175,7 @@ class ProclamatoreIndex extends Component
return view('livewire.proclamatori.proclamatore-index', [
'proclamatori' => $proclamatori,
// Per avvisare, prima di disattivare o cestinare, che il gruppo si scioglie.
'gruppiSorvegliati' => GruppoServizio::attivi()->pluck('nome', 'sorvegliante_id'),
'gruppiSorvegliati' => GruppoServizio::pluck('nome', 'sorvegliante_id'),
]);
}
}

View File

@@ -6,7 +6,6 @@ use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component;
use App\Models\Proclamatore;
use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use Spatie\Activitylog\Models\Activity;
class ProclamatoreShow extends Component
@@ -40,7 +39,7 @@ class ProclamatoreShow extends Component
];
// Gruppo di servizio: sorvegliante (con i dettagli del gruppo e della
// sua utenza), membro, o sorvegliante di gruppi ormai sciolti.
// sua utenza) o membro. I gruppi sciolti non esistono più.
$gruppoSorvegliato = $this->proclamatore->gruppoSorvegliato();
$utenza = $gruppoSorvegliato ? $this->proclamatore->utente : null;
@@ -50,10 +49,6 @@ class ProclamatoreShow extends Component
'gruppoSorvegliato' => $gruppoSorvegliato,
'riepilogoGruppo' => $gruppoSorvegliato?->riepilogo(),
'gruppoMembro' => $gruppoSorvegliato ? null : $this->proclamatore->gruppo()->with('sorvegliante')->first(),
'gruppiPassati' => GruppoServizio::whereNotNull('sciolto_at')
->where('sorvegliante_id', $this->proclamatore->id)
->orderByDesc('sciolto_at')
->get(),
'utenza' => $utenza,
'ultimoAccesso' => $utenza
? Activity::whereMorphedTo('causer', $utenza)->where('description', 'login')->max('created_at')

View File

@@ -20,22 +20,24 @@ class Profile extends Component
{
$user = auth()->user();
$this->name = $user->name;
$this->email = $user->email;
$this->email = (string) $user->email;
}
public function updateProfile()
{
$user = auth()->user();
// Il sorvegliante di gruppo entra con il nome utente: l'email non gli
// si chiede, e il nome utente lo decide il nome del gruppo.
$senzaEmail = $user->isSorvegliante();
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
] + ($senzaEmail ? [] : [
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
]);
]));
$user->fill([
'name' => $validated['name'],
'email' => $validated['email'],
]);
$user->fill(['name' => $validated['name']] + ($senzaEmail ? [] : ['email' => $validated['email']]));
$emailCambiata = $user->isDirty('email');
$user->save();

View File

@@ -285,7 +285,7 @@ class Registro extends Component
public function render()
{
$query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna', 'gruppo']);
$query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
if ($this->filtroAnno) {
$query->where('anno_teocratico_id', $this->filtroAnno);

View File

@@ -525,15 +525,18 @@ class XmlExchange extends Component
$utentiDaCollegare = [];
foreach (($xml->users->user ?? []) as $userNode) {
$email = trim((string) ($userNode->email ?? ''));
$username = trim((string) ($userNode->username ?? ''));
$legacyId = (string) ($userNode['legacy_id'] ?? '');
if ($email === '') {
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
// Il sorvegliante di gruppo non ha email: si riconosce dal nome utente.
if ($email === '' && $username === '') {
$this->pushImportIssue('utente', $legacyId, 'Email e nome utente mancanti');
continue;
}
$user = User::query()->firstOrNew(['email' => $email]);
$user->name = (string) ($userNode->name ?? $email);
$user = User::query()->firstOrNew($email !== '' ? ['email' => $email] : ['username' => $username]);
$user->name = (string) ($userNode->name ?? ($email !== '' ? $email : $username));
$user->username = $username !== '' ? $username : null;
if (! $user->exists) {
// password is NOT NULL; the real hash is written raw below.
$user->password = Str::password(24);
@@ -657,10 +660,15 @@ class XmlExchange extends Component
$gruppoMap = [];
foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) {
$legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? '');
// Nei file del 19-20 ottobre 2026 un gruppo sciolto c'era ancora
// (<sciolto_at>): ora un gruppo sciolto non esiste più, quindi
// non si importa.
if (trim((string) ($gruppoNode->sciolto_at ?? '')) !== '') {
continue;
}
$g = GruppoServizio::create([
'nome' => (string) $gruppoNode->nome,
'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null,
'sciolto_at' => $this->normalizeDateTimeForDb((string) ($gruppoNode->sciolto_at ?? '')),
]);
$this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode);
$legacyId = (string) ($gruppoNode['legacy_id'] ?? '');
@@ -809,6 +817,7 @@ class XmlExchange extends Component
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null,
'gruppo_nome' => trim((string) ($assegnazioneNode->gruppo_nome ?? '')) ?: null,
'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null,
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
'note' => (string) ($assegnazioneNode->note ?? ''),
@@ -860,6 +869,7 @@ class XmlExchange extends Component
'legacy_id' => (string) $u->id,
'name' => (string) $u->name,
'email' => (string) $u->email,
'username' => (string) $u->username,
'password' => (string) $u->getAuthPassword(),
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
// remember_token no: all'import si azzera comunque, e nel file
@@ -874,11 +884,11 @@ class XmlExchange extends Component
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']),
'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'sciolto_at', 'created_at', 'updated_at']),
'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'created_at', 'updated_at']),
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'gruppo_servizio_id', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'gruppo_servizio_id', 'pdf_access_code', 'created_at', 'updated_at']),
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']),
];
}
@@ -1275,6 +1285,7 @@ class XmlExchange extends Component
}
$this->addXmlText($node, 'name', (string) ($user['name'] ?? ''));
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'username', (string) ($user['username'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
@@ -1352,7 +1363,6 @@ class XmlExchange extends Component
$this->addLegacyIdAttribute($node, $gruppo);
$this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? ''));
$node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? ''));
$node->addChild('sciolto_at', (string) ($gruppo['sciolto_at'] ?? ''));
$this->addTimestampNodes($node, $gruppo);
}
@@ -1382,6 +1392,7 @@ class XmlExchange extends Component
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? ''));
$this->addXmlText($node, 'gruppo_nome', (string) ($assegnazione['gruppo_nome'] ?? ''));
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));

View File

@@ -21,7 +21,7 @@ class TerritorioShow extends Component
public function render()
{
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa', 'gruppo'])
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa'])
->orderByDesc('assigned_at')
->get()
->groupBy(fn($a) => $a->annoTeocratico->label);

View File

@@ -26,6 +26,7 @@ class Assegnazione extends Model
'created_by',
'returned_by',
'gruppo_servizio_id',
'gruppo_nome',
];
/** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */

View File

@@ -17,9 +17,9 @@ use Spatie\Activitylog\Traits\LogsActivity;
* gruppo_servizio_id per dire da dove viene. Al rientro il territorio torna in
* reparto ed esce dal pacchetto.
*
* Un gruppo sciolto (sciolto_at) non ha più membri né pacchetto, ma resta: le
* assegnazioni nate da lui continuano a citarlo. Logica in
* App\Services\GruppiServizio.
* Sciolto, il gruppo si elimina: non resta nello storico (decisione del 20
* ottobre 2026). Le assegnazioni già date dal sorvegliante ne conservano solo
* il nome (assegnazioni.gruppo_nome). Logica in App\Services\GruppiServizio.
*/
class GruppoServizio extends Model
{
@@ -27,17 +27,17 @@ class GruppoServizio extends Model
protected $table = 'gruppi_servizio';
protected $fillable = ['nome', 'sorvegliante_id', 'sciolto_at'];
protected $fillable = ['nome', 'sorvegliante_id'];
protected function casts(): array
{
return ['sciolto_at' => 'datetime', 'sorvegliante_id' => 'integer'];
return ['sorvegliante_id' => 'integer'];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['nome', 'sorvegliante_id', 'sciolto_at'])
->logOnly(['nome', 'sorvegliante_id'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
@@ -63,16 +63,6 @@ class GruppoServizio extends Model
return $this->hasMany(Assegnazione::class, 'gruppo_servizio_id');
}
public function getScioltoAttribute(): bool
{
return $this->sciolto_at !== null;
}
public function scopeAttivi($query)
{
return $query->whereNull('sciolto_at');
}
/**
* I numeri del gruppo per la scheda del proclamatore e il profilo del
* sorvegliante: solo conteggi in SQL, nessun nome da decifrare.

View File

@@ -52,7 +52,7 @@ class Proclamatore extends Model
/** Il gruppo attivo di cui è sorvegliante, se c'è. */
public function gruppoSorvegliato(): ?GruppoServizio
{
return GruppoServizio::attivi()->where('sorvegliante_id', $this->id)->first();
return GruppoServizio::where('sorvegliante_id', $this->id)->first();
}
private function lasciaIlGruppo(string $motivo): void
@@ -185,7 +185,8 @@ class Proclamatore extends Model
$utente->syncRoles([]);
$utente->forceFill([
'name' => 'Anonimo',
'email' => "utente-{$utente->id}@anonimo.invalid",
'email' => null,
'username' => "anonimo.{$utente->id}",
'password' => \Illuminate\Support\Str::random(40),
'remember_token' => null,
'disattivato_at' => $utente->disattivato_at ?? now(),

View File

@@ -22,6 +22,7 @@ class User extends Authenticatable implements MustVerifyEmail
protected $fillable = [
'name',
'email',
'username',
'password',
'password_change_required',
'proclamatore_id',
@@ -59,14 +60,23 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasRole('sorvegliante');
}
/** Il gruppo (attivo) di cui è sorvegliante, se c'è. */
/** Il gruppo di cui è sorvegliante, se c'è (sciolto, il gruppo non esiste più). */
public function gruppoSorvegliato(): ?GruppoServizio
{
return $this->proclamatore_id
? GruppoServizio::attivi()->where('sorvegliante_id', $this->proclamatore_id)->first()
? GruppoServizio::where('sorvegliante_id', $this->proclamatore_id)->first()
: null;
}
/**
* Con cosa entra: l'email, o il nome utente per il sorvegliante di
* gruppo, che l'email non ce l'ha. Da mostrare dove prima c'era l'email.
*/
public function getIdentificativoAttribute(): string
{
return (string) ($this->email ?? $this->username ?? $this->name);
}
/** Utenza disattivata (gruppo sciolto): non può più entrare. */
public function isDisattivato(): bool
{

View File

@@ -22,20 +22,24 @@ use InvalidArgumentException;
class GruppiServizio
{
/**
* Crea il gruppo e l'utenza del sorvegliante: ruolo "sorvegliante",
* password temporanea da cambiare al primo accesso, nessuna conferma
* dell'email. Restituisce il gruppo e la password temporanea, da mostrare
* una volta sola all'amministratore.
* Crea il gruppo e l'utenza del sorvegliante: nome utente ricavato dal
* nome del gruppo (niente email), ruolo "sorvegliante", password
* temporanea da cambiare al primo accesso. Restituisce il gruppo e la
* password temporanea, da mostrare una volta sola all'amministratore
* insieme al nome utente ($gruppo->sorvegliante->utente->username).
*
* @param int[] $membri id dei proclamatori, oltre al sorvegliante
* @return array{0: GruppoServizio, 1: string}
*/
public function crea(string $nome, Proclamatore $sorvegliante, string $email, array $membri = []): array
public function crea(string $nome, Proclamatore $sorvegliante, array $membri = []): array
{
$nome = trim($nome);
if ($nome === '') {
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
}
if (GruppoServizio::where('nome', $nome)->exists()) {
throw new InvalidArgumentException("Esiste già un gruppo «{$nome}».");
}
if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
}
@@ -48,13 +52,10 @@ class GruppiServizio
if ($esistente && ! $esistente->isDisattivato()) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
}
if (User::where('email', $email)->when($esistente, fn ($q) => $q->whereKeyNot($esistente->id))->exists()) {
throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente.");
}
$password = Str::password(12, symbols: false);
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password, $esistente) {
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $membri, $password, $esistente) {
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri);
@@ -62,7 +63,7 @@ class GruppiServizio
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
$utente->forceFill([
'name' => $sorvegliante->nome_completo,
'email' => $email,
'username' => self::nomeUtente($nome),
'password' => $password,
'password_change_required' => true,
'disattivato_at' => null,
@@ -79,6 +80,23 @@ class GruppiServizio
return [$gruppo, $password];
}
/**
* Il nome utente dal nome del gruppo: minuscole, senza accenti, parole
* unite da un punto ("Gruppo Noce" → "gruppo.noce"); se è già preso, un
* numero in coda ("zisa2"). Mai una chiocciola: al login distingue il
* nome utente dall'email.
*/
public static function nomeUtente(string $nomeGruppo): string
{
$base = Str::limit(Str::slug($nomeGruppo, '.') ?: 'gruppo', 50, '');
$nome = $base;
for ($n = 2; User::where('username', $nome)->exists(); $n++) {
$nome = $base . $n;
}
return $nome;
}
/** @param int[] $ids */
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
{
@@ -205,6 +223,8 @@ class GruppiServizio
'assigned_at' => $data,
'created_by' => auth()->id(),
'gruppo_servizio_id' => $gruppo->id,
// Il nome resta anche quando il gruppo, sciolto, non c'è più.
'gruppo_nome' => $gruppo->nome,
'link_da_inviare' => (bool) $territorio->pdf_path,
]);
});
@@ -225,52 +245,58 @@ class GruppiServizio
}
/**
* Scioglie il gruppo (si toglie il ruolo di sorvegliante): i territori del
* pacchetto non ancora dati tornano in reparto, i membri restano senza
* gruppo, l'utenza del sorvegliante si disattiva. Le assegnazioni già
* fatte restano com'erano, con il loro riferimento al gruppo, che resta
* nello storico come sciolto.
* Scioglie il gruppo (si toglie il ruolo di sorvegliante, o il
* sorvegliante non è più attivo) e lo elimina: non resta nello storico.
* I territori del pacchetto non ancora dati tornano in reparto; quelli
* già dati restano ai membri come assegnazioni qualsiasi, che conservano
* solo il nome del gruppo (gruppo_nome). I membri restano senza gruppo.
* L'utenza del sorvegliante si disattiva e libera il nome utente; resta
* perché è l'autrice delle assegnazioni che ha fatto.
*/
public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void
{
if ($gruppo->sciolto_at) {
if (! GruppoServizio::whereKey($gruppo->id)->exists()) {
return;
}
DB::transaction(function () use ($gruppo, $motivo) {
// Esce dal pacchetto solo quello non ancora dato: un territorio
// assegnato resta del suo gruppo finché non rientra (Assegnazione
// lo libera al rientro), così non può essere ridato due volte.
$nonDati = Territorio::withTrashed()
$liberati = Territorio::withTrashed()
->where('gruppo_servizio_id', $gruppo->id)
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
$liberati = (clone $nonDati)->pluck('numero');
$nonDati->update(['gruppo_servizio_id' => null]);
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'))
->pluck('numero');
Territorio::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Assegnazione::where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
if ($utente) {
$utente->removeRole('sorvegliante');
$utente->forceFill(['disattivato_at' => now(), 'remember_token' => null])->save();
// Il nome utente del gruppo si libera; al suo posto uno che
// identifica l'utenza (backup XML) e con cui non si entra.
$utente->forceFill([
'disattivato_at' => now(),
'remember_token' => null,
'username' => "ex.sorvegliante.{$utente->id}",
])->save();
}
$gruppo->update(['sciolto_at' => now()]);
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(array_filter([
'gruppo' => $gruppo->nome,
'territori_liberati' => $liberati->all(),
'utente_disattivato' => $utente?->id,
'motivo' => $motivo,
], fn ($v) => $v !== null))
->log('gruppo_sciolto');
$gruppo->delete();
});
}
private function attivo(GruppoServizio $gruppo): void
{
if ($gruppo->sciolto_at) {
throw new InvalidArgumentException('Il gruppo è stato sciolto: creane uno nuovo.');
if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {
throw new InvalidArgumentException('Il gruppo non esiste più: è stato sciolto.');
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* Gruppi di servizio, 20 ottobre 2026 (decisioni dell'utente):
*
* - un gruppo sciolto non resta nello storico: si elimina. Le assegnazioni
* già date dal sorvegliante conservano il nome del gruppo
* (assegnazioni.gruppo_nome), così "via gruppo …" resta leggibile;
* - il sorvegliante entra con un nome utente ricavato dal nome del gruppo,
* non con l'email: users.username, e users.email diventa facoltativa.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('username', 60)->nullable()->unique()->after('email');
});
Schema::table('users', function (Blueprint $table) {
$table->string('email')->nullable()->change();
});
Schema::table('assegnazioni', function (Blueprint $table) {
$table->string('gruppo_nome', 100)->nullable()->after('gruppo_servizio_id');
});
foreach (DB::table('gruppi_servizio')->get() as $gruppo) {
DB::table('assegnazioni')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_nome' => $gruppo->nome]);
if ($gruppo->sciolto_at !== null) {
// Già sciolto: pacchetto e membri erano già stati liberati.
DB::table('territori')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
DB::table('proclamatori')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
DB::table('assegnazioni')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
DB::table('gruppi_servizio')->where('id', $gruppo->id)->delete();
continue;
}
// Sorvegliante di un gruppo attivo: il nome utente dal nome del gruppo.
$utente = DB::table('users')->where('proclamatore_id', $gruppo->sorvegliante_id)->first();
if ($utente && $utente->username === null) {
$base = Str::slug($gruppo->nome, '.') ?: 'gruppo';
$base = Str::limit($base, 50, '');
$username = $base;
for ($n = 2; DB::table('users')->where('username', $username)->exists(); $n++) {
$username = $base . $n;
}
DB::table('users')->where('id', $utente->id)->update(['username' => $username]);
}
}
Schema::table('gruppi_servizio', function (Blueprint $table) {
$table->dropColumn('sciolto_at');
});
}
public function down(): void
{
Schema::table('gruppi_servizio', function (Blueprint $table) {
$table->timestamp('sciolto_at')->nullable();
});
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropColumn('gruppo_nome');
});
// Senza email l'utenza non si può tenere com'era: chi non ce l'ha
// riceve un indirizzo segnaposto, da correggere a mano.
DB::table('users')->whereNull('email')->orderBy('id')->each(function ($u) {
DB::table('users')->where('id', $u->id)->update(['email' => ($u->username ?? "utente-{$u->id}") . '@senza-email.invalid']);
});
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['username']);
$table->dropColumn('username');
});
Schema::table('users', function (Blueprint $table) {
$table->string('email')->nullable(false)->change();
});
}
};

View File

@@ -5,7 +5,7 @@
{{ $forced ? 'Imposta una nuova password' : 'Cambia password' }}
</h1>
<p class="mt-1 text-sm text-gray-500">
Account: <span class="font-medium text-gray-700 break-all">{{ $user->email }}</span>
Account: <span class="font-medium text-gray-700 break-all">{{ $user->identificativo }}</span>
</p>
</div>
@@ -46,7 +46,7 @@
{{-- L'email in un campo nascosto: senza, i gestori di password non
sanno a quale account associare la credenziale che salvano. --}}
<input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}">
<input type="hidden" name="email" autocomplete="username" value="{{ $user->identificativo }}">
@unless($forced)
{{-- Dopo un ripristino non si chiede: la password attuale e'

View File

@@ -50,7 +50,7 @@
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span class="flex flex-col leading-tight text-left">
<span class="text-sm font-medium">{{ auth()->user()->name }}</span>
<span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
<span class="text-xs" style="opacity:.7">{{ auth()->user()->identificativo }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
</span>
</x-btn>
</div>

View File

@@ -16,8 +16,10 @@
<form method="POST" action="{{ route('login.store') }}" class="space-y-5">
@csrf
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input name="email" value="{{ old('email') }}" type="email" id="email" autocomplete="email"
{{-- Email, o nome utente per il sorvegliante di un gruppo di servizio --}}
<label for="email" class="block text-sm font-medium text-gray-700">Email o nome utente</label>
<input name="email" value="{{ old('email') }}" type="text" id="email" autocomplete="username"
autocapitalize="none" autocorrect="off" spellcheck="false"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm px-4 py-2.5">
@error('email') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>

View File

@@ -12,7 +12,7 @@
<p class="mt-1">Consegna queste credenziali a {{ $creato->sorvegliante?->nome_completo }}: la password si vede <strong>solo ora</strong> e al primo accesso va cambiata.</p>
<dl class="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt class="text-green-700">Indirizzo</dt><dd><code>{{ route('login') }}</code></dd>
<dt class="text-green-700">Email</dt><dd><code>{{ $creato->sorvegliante?->utente?->email }}</code></dd>
<dt class="text-green-700">Nome utente</dt><dd><code class="select-all">{{ $creato->sorvegliante?->utente?->username }}</code></dd>
<dt class="text-green-700">Password</dt><dd><code class="select-all">{{ $passwordTemporanea }}</code></dd>
</dl>
<div class="mt-3 flex flex-wrap gap-2">
@@ -29,6 +29,7 @@
<label for="nome-gruppo" class="block text-sm font-medium text-gray-700 mb-1">Nome del gruppo *</label>
<input wire:model="nome" id="nome-gruppo" type="text" placeholder="es. Zisa"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<p class="text-xs text-gray-500 mt-1">Dal nome nasce il nome utente del sorvegliante (es. «Gruppo Zisa» → <code>gruppo.zisa</code>).</p>
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
@@ -37,13 +38,6 @@
<p class="text-xs text-gray-500 mt-1">Un proclamatore attivo che non fa già parte di un gruppo. Diventa anche membro del gruppo.</p>
@error('sorveglianteId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="email-sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Email per l'accesso *</label>
<input wire:model="email" id="email-sorvegliante" type="email" autocomplete="off"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<p class="text-xs text-gray-500 mt-1">Resta solo nell'utenza del sorvegliante (non nel proclamatore) e non va confermata.</p>
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<x-btn variante="primary" type="submit">Crea il gruppo</x-btn>
</form>
</section>
@@ -55,15 +49,12 @@
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div>
<a data-ritorno href="{{ route('gruppi.show', $g) }}" class="font-semibold text-indigo-600 hover:underline">{{ $g->nome }}</a>
@if($g->sciolto_at)
<span class="ml-1 inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">sciolto il {{ $g->sciolto_at->format('d/m/Y') }}</span>
@endif
<p class="text-xs text-gray-500">
Sorvegliante: {{ $g->sorvegliante?->nome_completo ?? '—' }}
@unless($g->sciolto_at) · {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto @endunless
· {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto
</p>
</div>
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">{{ $g->sciolto_at ? 'Dettaglio' : 'Gestisci' }}</x-btn>
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">Gestisci</x-btn>
</li>
@empty
<li class="px-4 py-8 text-center text-sm text-gray-500">Nessun gruppo di servizio.</li>

View File

@@ -7,15 +7,10 @@
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
<p class="text-sm text-gray-500 mt-1">
Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong>
@if($attivo && $gruppo->sorvegliante?->utente)
· accede con <code>{{ $gruppo->sorvegliante->utente->email }}</code>
@if($gruppo->sorvegliante?->utente)
· nome utente <code class="select-all">{{ $gruppo->sorvegliante->utente->identificativo }}</code>
@endif
</p>
@unless($attivo)
<p class="mt-2 inline-flex rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700">
Sciolto il {{ $gruppo->sciolto_at->format('d/m/Y') }} · {{ $assegnazioniDelGruppo }} assegnazioni nate dal gruppo restano nello storico
</p>
@endunless
</div>
@if($passwordTemporanea)
@@ -25,7 +20,6 @@
</div>
@endif
@if($attivo)
{{-- ─── Pacchetto ─────────────────────────────────────────── --}}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-pacchetto">
<h2 id="titolo-pacchetto" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">
@@ -127,8 +121,7 @@
<x-btn variante="secondary" wire:click="nuovaPassword"
wire:confirm="Generare una nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
<x-btn variante="danger" wire:click="sciogli"
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene disattivata. Le assegnazioni già fatte restano.">Togli il ruolo e sciogli il gruppo</x-btn>
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie e viene eliminato: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene disattivata. Le assegnazioni già fatte restano ai membri.">Togli il ruolo e sciogli il gruppo</x-btn>
</div>
</section>
@endif
</div>

View File

@@ -18,8 +18,8 @@
<ul class="list-disc list-inside space-y-1">
<li><strong>Proclamatori</strong>: solo nome e cognome, cifrati nel database ({{ $cifratura }}), se sono attivi e l'eventuale gruppo di servizio di cui fanno parte. Nessun numero di telefono né indirizzo email.</li>
<li><strong>Territori e piantine</strong>: numero, zona, tipologia, confini, mappe e PDF — nessun dato personale.</li>
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio, con date di assegnazione e rientro, conteggio nelle campagne, codice del link alla piantina e stato del suo invio (inviato o no, e con quale canale).</li>
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata, tranne per i sorveglianti di gruppo), password conservata solo come impronta non reversibile, ruolo. L'utenza di un sorvegliante è collegata al suo proclamatore e viene disattivata quando il gruppo si scioglie.</li>
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio, con date di assegnazione e rientro, conteggio nelle campagne, codice del link alla piantina, stato del suo invio (inviato o no, e con quale canale) e, se l'ha data il sorvegliante di un gruppo di servizio, il nome del gruppo.</li>
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo. Il sorvegliante di un gruppo di servizio non ha email: entra con un nome utente ricavato dal nome del gruppo. La sua utenza è collegata al suo proclamatore e viene disattivata quando il gruppo si scioglie; il gruppo, sciolto, viene eliminato.</li>
<li><strong>Registro delle attività</strong>: chi ha fatto cosa e quando. I proclamatori vi compaiono solo con un identificativo numerico, mai con il nome.</li>
</ul>
</section>
@@ -58,7 +58,7 @@
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
@endforeach
</ul>
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (nome utente e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
</section>
<section>
@@ -100,7 +100,7 @@
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
<ul class="list-disc list-inside space-y-1">
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile, insieme a nome ed email dell'utenza se è stato sorvegliante di un gruppo.</li>
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile, insieme al nome dell'utenza se è stato sorvegliante di un gruppo.</li>
<li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li>
<li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li>
<li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>

View File

@@ -40,7 +40,7 @@
</div>
{{-- Gruppo di servizio --}}
@if($gruppoSorvegliato || $gruppoMembro || $gruppiPassati->isNotEmpty())
@if($gruppoSorvegliato || $gruppoMembro)
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" aria-labelledby="titolo-gruppo">
<h3 id="titolo-gruppo" class="text-lg font-semibold text-gray-900 mb-3">Gruppo di servizio</h3>
@@ -78,7 +78,7 @@
@if($utenza)
<p class="mt-4 border-t border-gray-100 pt-3 text-sm text-gray-600">
Utenza
@can('settings.manage') <code>{{ $utenza->email }}</code> @endcan
@can('settings.manage') <code>{{ $utenza->identificativo }}</code> @endcan
· {{ $utenza->password_change_required ? 'password temporanea, ancora da cambiare' : 'password personale impostata' }}
· {{ $ultimoAccesso ? 'ultimo accesso il ' . \Illuminate\Support\Carbon::parse($ultimoAccesso)->format('d/m/Y H:i') : 'non è ancora entrato' }}
</p>
@@ -94,15 +94,6 @@
sorvegliante {{ $gruppoMembro->sorvegliante?->nome_completo ?? '—' }}.
</p>
@endif
@if($gruppiPassati->isNotEmpty())
<p class="{{ $gruppoSorvegliato || $gruppoMembro ? 'mt-3' : '' }} text-xs text-gray-500">
In passato sorvegliante di:
@foreach($gruppiPassati as $g)
{{ $g->nome }} (sciolto il {{ $g->sciolto_at->format('d/m/Y') }}){{ $loop->last ? '.' : ',' }}
@endforeach
</p>
@endif
</section>
@endif

View File

@@ -17,7 +17,7 @@
<div class="min-w-0">
<h2 class="text-xl font-bold text-gray-900 truncate">{{ $user->name }}</h2>
<p class="text-sm text-gray-500 truncate mt-0.5">
<span class="break-all">{{ $user->email }}</span>
<span class="break-all">{{ $user->identificativo }}</span>
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 align-middle">
{{ ucfirst($user->roles->first()?->name ?? 'utente') }}
</span>
@@ -59,7 +59,7 @@
{{-- Dati profilo --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-1">Dati profilo</h3>
<p class="text-sm text-gray-500 mb-4">Aggiorna nome e cognome ed email.</p>
<p class="text-sm text-gray-500 mb-4">{{ $user->isSorvegliante() ? 'Aggiorna nome e cognome.' : 'Aggiorna nome e cognome ed email.' }}</p>
@if(session('profile_success'))
<div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
@@ -73,11 +73,20 @@
<input wire:model="name" type="text" id="name" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@if($user->isSorvegliante())
{{-- Il sorvegliante entra con il nome utente del gruppo, senza email --}}
<div>
<p class="block text-sm font-medium text-gray-700">Nome utente</p>
<p class="mt-1 text-sm"><code class="select-all">{{ $user->username }}</code></p>
<p class="mt-1 text-xs text-gray-500">Viene dal nome del gruppo: lo usi per entrare, insieme alla password.</p>
</div>
@else
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input wire:model="email" type="email" id="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@endif
<div class="flex justify-end">
<x-btn variante="primary" type="submit">Salva modifiche</x-btn>
</div>

View File

@@ -87,7 +87,7 @@
</div>
</div>
</td>
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}@if($a->gruppo)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700" title="Assegnato dal sorvegliante del gruppo di servizio">via gruppo {{ $a->gruppo->nome }}</span>@endif</td>
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}@if($a->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700" title="Assegnato dal sorvegliante del gruppo di servizio">via gruppo {{ $a->gruppo_nome }}</span>@endif</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2">
@if($a->returned_at)

View File

@@ -111,8 +111,14 @@
@else
<td data-label="Nome" class="px-4 py-2 text-gray-900">{{ $user->name }}</td>
<td data-label="Email" class="px-4 py-2 text-gray-700">
@if(! $user->email)
{{-- Sorvegliante di gruppo: entra con il nome utente, senza email --}}
@if($user->username)<span class="text-gray-500">nome utente</span> <code>{{ $user->username }}</code>@else<span class="text-gray-400">—</span>@endif
@else
{{ $user->email }}
@if($user->hasVerifiedEmail())
@endif
@if(! $user->email)
@elseif($user->hasVerifiedEmail())
<span class="ml-1 inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">confermata</span>
@else
<span class="ml-1 inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"

View File

@@ -178,7 +178,7 @@
<tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $assegnazione)
<tr>
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}@if($assegnazione->gruppo)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo->nome }}</span>@endif</td>
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}@if($assegnazione->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo_nome }}</span>@endif</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2">
@if($assegnazione->returned_at)

View File

@@ -68,12 +68,12 @@ class GruppiServizioTest extends TestCase
$rossi = $this->proclamatore('Rossi', 'Mario');
$verdi = $this->proclamatore('Verdi', 'Anna');
$bianchi = $this->proclamatore('Bianchi', 'Luca');
[$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, 'mario@example.test', [$verdi->id, $bianchi->id]);
[$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, [$verdi->id, $bianchi->id]);
$territori = collect(['12', '14', '35'])->map(fn ($n) => $this->territorio($n));
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all());
$territori = $territori->map->fresh();
$sorvegliante = User::where('email', 'mario@example.test')->sole();
$sorvegliante = User::where('username', 'zisa')->sole();
// Il cambio password al primo accesso ha i suoi test: qui si lavora.
// E l'email non confermata, come nella realtà (TestCase le conferma).
$sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save();
@@ -87,16 +87,20 @@ class GruppiServizioTest extends TestCase
{
$rossi = $this->proclamatore('Rossi', 'Mario');
// Niente email: il nome utente viene dal nome del gruppo.
$pagina = Livewire::test(GruppiIndex::class)
->assertDontSee('Email per l')
->set('nome', 'Zisa')
->set('sorveglianteId', $rossi->id)
->set('email', 'mario@example.test')
->call('crea')
->assertHasNoErrors();
->assertHasNoErrors()
->assertSee('Nome utente')
->assertSee('zisa');
$gruppo = GruppoServizio::sole();
$utente = User::where('email', 'mario@example.test')->sole();
$utente = User::where('username', 'zisa')->sole();
$this->assertNull($utente->email);
$this->assertSame($rossi->id, $gruppo->sorvegliante_id);
$this->assertSame($gruppo->id, $rossi->fresh()->gruppo_servizio_id, 'Il sorvegliante è anche membro');
$this->assertSame($rossi->id, $utente->proclamatore_id);
@@ -114,10 +118,27 @@ class GruppiServizioTest extends TestCase
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
}
public function test_il_nome_utente_viene_dal_nome_del_gruppo(): void
{
$servizio = app(GruppiServizio::class);
$servizio->crea('Gruppo Noce Città', $this->proclamatore('Neri'));
$this->assertSame('gruppo.noce.citta', User::whereNotNull('username')->sole()->username);
// Già preso (per esempio da un'utenza riattivata): un numero in coda.
User::create(['name' => 'Altro', 'username' => 'zisa', 'password' => 'password-di-prova']);
$servizio->crea('Zisa', $this->proclamatore('Gialli'));
$this->assertTrue(User::where('username', 'zisa2')->exists());
// Due gruppi con lo stesso nome no.
$this->expectException(InvalidArgumentException::class);
$servizio->crea('Zisa', $this->proclamatore('Blu'));
}
public function test_un_proclamatore_sta_in_un_solo_gruppo(): void
{
['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo();
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'), 'neri@example.test');
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'));
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiMembri($noce, [$verdi->id]);
@@ -199,16 +220,25 @@ class GruppiServizioTest extends TestCase
$this->get(route('dashboard'))->assertOk()->assertDontSee('Il mio gruppo')->assertSee('Gruppi di servizio');
}
public function test_il_sorvegliante_entra_senza_conferma_dell_email_e_va_al_suo_gruppo(): void
public function test_il_sorvegliante_entra_con_il_nome_utente_e_va_al_suo_gruppo(): void
{
$this->gruppo();
$this->flushSession();
auth()->logout();
$password = 'una-password-nuova-1';
User::where('email', 'mario@example.test')->sole()->forceFill(['password' => $password])->save();
User::where('username', 'zisa')->sole()->forceFill(['password' => $password])->save();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password])
$this->get(route('login'))->assertSee('Email o nome utente');
// Senza badare alle maiuscole (la tastiera del telefono le mette da sé).
$this->post(route('login.store'), ['email' => 'Zisa', 'password' => $password])
->assertRedirect(route('mio-gruppo'));
// Gli altri continuano a entrare con l'email.
auth()->logout();
$this->flushSession();
$this->post(route('login.store'), ['email' => 'admin@example.test', 'password' => 'password-di-prova'])
->assertRedirect(route('dashboard'));
}
// ─── Il sorvegliante al lavoro ──────────────────────────────
@@ -322,19 +352,29 @@ class GruppiServizioTest extends TestCase
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
$data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])->call('sciogli');
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])
->call('sciogli')
->assertRedirect(route('gruppi.index'));
$gruppo->refresh();
$this->assertNotNull($gruppo->sciolto_at);
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione normale.
// Il gruppo non resta nello storico: non esiste più.
$this->assertNull(GruppoServizio::find($gruppo->id));
$this->get(route('gruppi.index'))->assertOk()->assertSee('Nessun gruppo di servizio');
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione
// normale, che del gruppo conserva solo il nome.
$this->assertNull($territori[1]->fresh()->gruppo_servizio_id);
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id);
$this->assertSame('in_reparto', $territori[2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at);
$this->assertSame($gruppo->id, $data->fresh()->gruppo_servizio_id, 'Lo storico continua a dirlo');
// Membri liberi, utenza disattivata e senza ruolo.
$this->assertSame(0, Proclamatore::where('gruppo_servizio_id', $gruppo->id)->count());
$this->assertSame('assegnato', $territori[0]->fresh()->stato);
$this->assertNull($data->fresh()->gruppo_servizio_id);
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
$this->get(route('territori.show', $territori[0]))->assertOk()->assertSee('via gruppo Zisa');
// Membri liberi; utenza disattivata, senza ruolo, e il nome utente del
// gruppo è libero.
$this->assertSame(0, Proclamatore::whereNotNull('gruppo_servizio_id')->count());
$this->assertTrue($s->fresh()->isDisattivato());
$this->assertFalse($s->fresh()->hasRole('sorvegliante'));
$this->assertSame("ex.sorvegliante.{$s->id}", $s->fresh()->username);
$this->assertFalse(User::where('username', 'zisa')->exists());
// Per ripartire serve un gruppo nuovo.
$this->expectException(InvalidArgumentException::class);
@@ -350,7 +390,7 @@ class GruppiServizioTest extends TestCase
$this->flushSession();
auth()->logout();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password])
$this->post(route('login.store'), ['email' => 'zisa', 'password' => $password])
->assertSessionHasErrors('email');
$this->assertGuest();
@@ -365,13 +405,14 @@ class GruppiServizioTest extends TestCase
$pagina = Livewire::test(GruppiIndex::class);
$this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati');
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa 2', $rossi->fresh(), 'mario.nuovo@example.test');
// Sciolto, il nome non è più occupato: si può rifare "Zisa".
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi->fresh());
$utente = $s->fresh();
$this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza');
$this->assertFalse($utente->isDisattivato());
$this->assertTrue($utente->hasRole('sorvegliante'));
$this->assertSame('mario.nuovo@example.test', $utente->email);
$this->assertSame('zisa', $utente->username);
$this->assertTrue($utente->password_change_required);
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
$this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id);
@@ -382,7 +423,8 @@ class GruppiServizioTest extends TestCase
/** Il gruppo è sciolto come da "Togli il ruolo": tutto tranne l'assegnazione già fatta. */
private function assertSciolto(array $g, Assegnazione $data): void
{
$this->assertNotNull($g['gruppo']->fresh()->sciolto_at, 'Gruppo sciolto');
$this->assertNull(GruppoServizio::find($g['gruppo']->id), 'Gruppo sciolto ed eliminato');
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
$this->assertNull($g['territori'][1]->fresh()->gruppo_servizio_id, 'I non dati tornano in reparto');
$this->assertSame('in_reparto', $g['territori'][2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at, 'Quello già dato resta al membro');
@@ -406,7 +448,7 @@ class GruppiServizioTest extends TestCase
// Riattivarlo non ricostruisce il gruppo: se ne crea uno nuovo.
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('toggleActive', $g['rossi']->id);
$this->assertNotNull($g['gruppo']->fresh()->sciolto_at);
$this->assertSame(0, GruppoServizio::count());
$this->assertNull($g['rossi']->fresh()->gruppoSorvegliato());
}
@@ -445,7 +487,7 @@ class GruppiServizioTest extends TestCase
->call('toggleActive', $verdi->id)
->call('deleteProclamatore', $bianchi->id);
$this->assertNull($gruppo->fresh()->sciolto_at);
$this->assertNotNull(GruppoServizio::find($gruppo->id));
$this->assertSame($gruppo->id, $verdi->fresh()->gruppo_servizio_id, 'Non attivo resta membro, ma non riceve territori');
$this->assertNull($bianchi->fresh()->gruppo_servizio_id, 'Nel cestino lascia il gruppo');
}
@@ -459,17 +501,18 @@ class GruppiServizioTest extends TestCase
$this->get(route('proclamatori.show', $g['rossi']))->assertOk()
->assertSeeInOrder(['Gruppo di servizio', 'Sorvegliante', 'Zisa', 'Membri', '3', 'Nel pacchetto', '3', 'Da dare', '2', 'Assegnazioni dal gruppo', '1'])
->assertSee('mario@example.test')
->assertSee('<code>zisa</code>', false)
->assertSee('non è ancora entrato');
$this->get(route('proclamatori.show', $g['verdi']))->assertOk()
->assertSee('Membro del gruppo')
->assertSee('sorvegliante Rossi Mario');
// Sciolto il gruppo, nella scheda non ne resta traccia.
app(GruppiServizio::class)->sciogli($g['gruppo']);
$this->get(route('proclamatori.show', $g['rossi']))->assertOk()
->assertSee('In passato sorvegliante di:')
->assertDontSee('Assegnazioni dal gruppo');
->assertDontSee('Assegnazioni dal gruppo')
->assertDontSee('In passato');
}
public function test_chi_fa_parte_di_un_gruppo_e_segnalato_in_elenco_scheda_e_scelta(): void
@@ -529,7 +572,8 @@ class GruppiServizioTest extends TestCase
$utente = $s->fresh();
$this->assertSame('Anonimo', $utente->name);
$this->assertStringEndsWith('@anonimo.invalid', $utente->email);
$this->assertNull($utente->email);
$this->assertSame("anonimo.{$utente->id}", $utente->username);
$this->assertTrue($utente->isDisattivato());
$this->assertCount(0, $utente->roles);
}
@@ -579,6 +623,25 @@ class GruppiServizioTest extends TestCase
$this->assertEqualsCanonicalizing(['Rossi Mario', 'Verdi Anna', 'Bianchi Luca'], $g->membri->map->nome_completo->all());
$this->assertEqualsCanonicalizing(['12', '14', '35'], $g->territori->pluck('numero')->all());
$this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id);
$this->assertSame($g->sorvegliante_id, User::where('email', 'mario@example.test')->sole()->proclamatore_id);
$this->assertSame('Zisa', Assegnazione::sole()->gruppo_nome);
$utenza = User::where('username', 'zisa')->sole();
$this->assertNull($utenza->email);
$this->assertSame($g->sorvegliante_id, $utenza->proclamatore_id);
}
public function test_l_xml_conserva_il_nome_del_gruppo_sulle_assegnazioni_dopo_lo_scioglimento(): void
{
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
app(GruppiServizio::class)->sciogli($gruppo);
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$this->assertSame(0, GruppoServizio::count());
$this->assertNull(Assegnazione::sole()->gruppo_servizio_id);
$this->assertSame('Zisa', Assegnazione::sole()->gruppo_nome);
// L'utenza disattivata, autrice dell'assegnazione, sopravvive al giro.
$this->assertTrue(User::where('username', 'like', 'ex.sorvegliante.%')->sole()->isDisattivato());
}
}