diff --git a/HANDOFF.md b/HANDOFF.md index 3f2203a..c2ade1a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non può dare: perché le cose sono come sono, e cosa resta aperto. -Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui). +Ultimo aggiornamento: **19 ottobre 2026** (gruppi di servizio, solo su dev) — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui). --- @@ -127,6 +127,64 @@ email di prova e di conferma arrivate, link aperto senza login, account sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione e dati di prova rimossi dopo. +### Gruppi di servizio (19 ottobre 2026, solo su dev) + +Richiesta dell'utente: un **gruppo di servizio** con un sorvegliante che, +entrato con una sua utenza, distribuisce ai membri un pacchetto di territori +e non vede nient'altro. Scelte dell'utente: + +- Il gruppo ha **membri** (proclamatori); un proclamatore sta in **un solo** + gruppo; il sorvegliante è anche membro e assegna solo ai membri, a sé + compreso. +- Il **pacchetto non è un'assegnazione**: "la media non deve essere alterata". + I territori del pacchetto sono "In gruppo …" (stato `in_gruppo`), fuori da + "Da assegnare" e dalla pagina *Assegna* (`Territorio::scopeInReparto` + esclude `gruppo_servizio_id`). +- Quando il sorvegliante dà un territorio nasce un'**assegnazione normale**, + tracciata con `assegnazioni.gruppo_servizio_id` ("via gruppo …" nel + registro e nella scheda del territorio). +- Al **rientro** il territorio esce dal pacchetto e torna in reparto + (hook `updated` in `Assegnazione`, vale da qualunque pagina). +- 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 + conferma dell'email** (`User::hasVerifiedEmail()` è vero per il + sorvegliante; anche cambiandola dal profilo). +- **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. + +Dove sta: +- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella + `gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e + assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`, + permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà + all'amministratore tutti i permessi **tranne** `gruppo.gestisci`. +- Regole in `App\Services\GruppiServizio` (crea, aggiungiMembri, togliMembro, + aggiungiAlPacchetto, rimandaInReparto, assegna, sciogli); i componenti + `Gruppi\GruppiIndex`, `Gruppi\GruppoShow` (amministratore) e + `Gruppi\MioGruppo` (sorvegliante) le chiamano soltanto. +- `LimitaSorvegliante` (gruppo `web`): chi ha **solo** il ruolo sorvegliante + raggiunge `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo, + controllo in `Rientra::autorizza`), profilo e cambio password; la Home lo + rimanda al gruppo; il resto è 403. +- L'invio del link è diventato il trait `Concerns\InviaLink` + il partial + `assegnazioni/_invio-link`, usati da *Assegna* e da *Il mio gruppo*. +- Il ruolo sorvegliante non compare nella pagina *Utenti* e le utenze legate a + un proclamatore lì non si modificano né si eliminano: passano dal gruppo. +- 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. +- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati + del proclamatore). +- Test: `tests/Feature/GruppiServizioTest.php`. + +Non provato nel browser. **In produzione non c'è**: serve l'ok dell'utente. + ### Proclamatori senza contatti; il link si copia o si condivide (19 ottobre 2026) Decisione dell'utente, dopo il rilascio: i proclamatori **non hanno più email diff --git a/README.md b/README.md index 69717de..fbeb726 100644 --- a/README.md +++ b/README.md @@ -736,6 +736,30 @@ docker compose --profile dev up -d --build - **Dopo una conferma o un annulla si torna alla pagina di partenza**, con i suoi filtri, ricerca, ordinamento e pagina (aperta dal menu: come prima) +### Gruppi di servizio +- Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome, + **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 +- 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* +- 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 + nasce un'**assegnazione normale**, che conta come le altre ed è segnata + "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 + gruppo nuovo + ### Campagne - CRUD con date di inizio e fine - Stato automatico: attiva, futura o conclusa @@ -771,24 +795,31 @@ docker compose --profile dev up -d --build ## Ruoli e permessi -Tre ruoli con permessi granulari (spatie/laravel-permission): +Quattro ruoli con permessi granulari (spatie/laravel-permission): -| Permesso | Amministratore | Assistente | Operatore | -|-----------------------|:--------------:|:----------:|:---------:| -| `settings.manage` | ✅ | | | -| `territori.manage` | ✅ | | | -| `proclamatori.manage` | ✅ | ✅ | | -| `campagne.manage` | ✅ | ✅ | | -| `registro.view` | ✅ | ✅ | | -| `registro.export` | ✅ | | | -| `territori.assign` | ✅ | ✅ | ✅ | -| `territori.return` | ✅ | ✅ | ✅ | -| `audit.view` | ✅ | | | -| `audit.export` | ✅ | | | +| Permesso | Amministratore | Assistente | Operatore | Sorvegliante | +|-----------------------|:--------------:|:----------:|:---------:|:------------:| +| `settings.manage` | ✅ | | | | +| `territori.manage` | ✅ | | | | +| `proclamatori.manage` | ✅ | ✅ | | | +| `campagne.manage` | ✅ | ✅ | | | +| `registro.view` | ✅ | ✅ | | | +| `registro.export` | ✅ | | | | +| `territori.assign` | ✅ | ✅ | ✅ | | +| `territori.return` | ✅ | ✅ | ✅ | | +| `audit.view` | ✅ | | | | +| `audit.export` | ✅ | | | | +| `gruppo.gestisci` | | | | ✅ | L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`). Il menu laterale mostra solo le voci per cui l'utente ha i permessi. +Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando +un gruppo di servizio e sparisce sciogliendolo. Chi ha solo quel ruolo è +confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro +dei territori del gruppo e al profilo; le sue azioni ricontrollano che +territorio e assegnazione siano del suo gruppo. + I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi una revoca ha effetto immediato senza bisogno di ricaricare la pagina. @@ -838,15 +869,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 | +| `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 | | `settings` | Configurazione singleton | | `zone` | Zone territoriali | | `tipologie` | Tipologie di territorio | -| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti) | -| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete | +| `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) | +| `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`) | +| `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 | | `activity_log` | Audit trail | **Vincoli e indici principali** @@ -873,7 +905,8 @@ Calcolato dinamicamente, non memorizzato: | Stato | Condizione | |----------------|-----------------------------------------------------| -| `in_reparto` | Attivo, nessuna assegnazione aperta | +| `in_reparto` | Attivo, nessuna assegnazione aperta, fuori dai pacchetti dei gruppi | +| `in_gruppo` | Nel pacchetto di un gruppo di servizio, non ancora dato a un membro | | `assegnato` | Ha un'assegnazione con `returned_at` nullo | | `da_rientrare` | Assegnato da più giorni della soglia configurata | | `inattivo` | Contrassegnato come non attivo | @@ -897,6 +930,15 @@ lo decide l'operatore con la spunta. Se più campagne corrispondono viene proposta la più recente. I territori assegnati durante la campagna e non ancora rientrati compaiono nel dettaglio come "in corso". +### Gruppi di servizio e statistiche +Mettere un territorio nel pacchetto di un gruppo **non** è un'assegnazione: +non crea righe in `assegnazioni`, quindi non tocca medie, durate, percorrenza +delle campagne né il registro. Conta solo quando il sorvegliante lo dà a un +membro: da lì è un'assegnazione come le altre (con `gruppo_servizio_id` per +sapere da dove viene). Il tempo passato nel pacchetto prima di essere dato non +viene contato. La priorità automatica guarda l'ultima assegnazione, come +sempre. + --- ## Architettura diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index ab68f58..8b2ba70 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -40,10 +40,25 @@ class LoginController extends Controller ->withInput($request->only('email', 'remember')); } + // Utenza disattivata (gruppo di servizio sciolto): la password è giusta + // ma non si entra. + if (Auth::user()->isDisattivato()) { + Auth::logout(); + + return back() + ->withErrors(['email' => 'Questa utenza è stata disattivata.']) + ->withInput($request->only('email', 'remember')); + } + RateLimiter::clear($throttleKey); $request->session()->regenerate(); activity()->causedBy(auth()->user())->log('login'); + // Il sorvegliante di gruppo va dritto al suo gruppo. + if (Auth::user()->isSorvegliante() && Auth::user()->roles->count() === 1) { + return redirect()->route('mio-gruppo'); + } + return redirect()->intended(route('dashboard')); } } \ No newline at end of file diff --git a/app/Http/Controllers/TerritorioFileController.php b/app/Http/Controllers/TerritorioFileController.php index f1ea9a4..0b87e62 100644 --- a/app/Http/Controllers/TerritorioFileController.php +++ b/app/Http/Controllers/TerritorioFileController.php @@ -25,6 +25,18 @@ class TerritorioFileController extends Controller return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf'); } + /** + * Il PDF di un territorio del pacchetto, per il sorvegliante di gruppo: + * solo se il territorio è nel pacchetto del gruppo che sorveglia. + */ + public function pdfGruppo(Territorio $territorio): Response + { + $gruppo = auth()->user()->gruppoSorvegliato(); + abort_unless($gruppo && $territorio->gruppo_servizio_id === $gruppo->id, 403); + + return $this->pdf($territorio); + } + public function miniatura(Territorio $territorio): Response { return $this->servi($territorio->thumbnail_path, null, null); diff --git a/app/Http/Middleware/LimitaSorvegliante.php b/app/Http/Middleware/LimitaSorvegliante.php new file mode 100644 index 0000000..41104af --- /dev/null +++ b/app/Http/Middleware/LimitaSorvegliante.php @@ -0,0 +1,68 @@ +user(); + if (! $user) { + return $next($request); + } + + if ($user->isDisattivato()) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login')->withErrors(['email' => 'Questa utenza è stata disattivata.']); + } + + if (! $user->isSorvegliante() || $user->roles->count() > 1) { + return $next($request); + } + + if ($request->routeIs(self::ROTTE_AMMESSE)) { + return $next($request); + } + + if ($request->routeIs('dashboard')) { + return redirect()->route('mio-gruppo'); + } + + abort(403, 'Il sorvegliante di gruppo può usare solo la pagina del proprio gruppo.'); + } +} diff --git a/app/Livewire/Assegnazioni/Assegna.php b/app/Livewire/Assegnazioni/Assegna.php index 9a7b106..151d1eb 100644 --- a/app/Livewire/Assegnazioni/Assegna.php +++ b/app/Livewire/Assegnazioni/Assegna.php @@ -2,10 +2,9 @@ namespace App\Livewire\Assegnazioni; +use App\Livewire\Concerns\InviaLink; use App\Livewire\Concerns\RicordaRitorno; use Livewire\Component; -use Livewire\Attributes\Locked; -use Livewire\Attributes\On; use App\Models\Territorio; use App\Models\Proclamatore; use App\Models\Assegnazione; @@ -15,25 +14,7 @@ use Illuminate\Support\Facades\DB; class Assegna extends Component { use RicordaRitorno; - - // ─── Invio del link ───────────────────────────────────────── - // - // Dopo l'assegnazione non si torna subito alla pagina: si sceglie il - // canale (copia link o Condividi: il destinatario lo sceglie chi usa - // l'app dai suoi contatti, i proclamatori non hanno numero né email), - // si dice se il link è partito, e solo allora si va avanti. Se la - // procedura si interrompe, l'assegnazione resta con link_da_inviare e lo - // si segnala dove compare. - - /** L'assegnazione di cui si sta mandando il link. */ - #[Locked] - public ?int $invioId = null; - - /** Il canale usato in questo invio (Assegnazione::CANALI_LINK). */ - #[Locked] - public ?string $canaleUsato = null; - - public string $erroreInvio = ''; + use InviaLink; public ?int $territorio_id = null; public ?int $proclamatore_id = null; @@ -83,6 +64,12 @@ class Assegna extends Component return; } + // Nel pacchetto di un gruppo di servizio: lo assegna il sorvegliante. + if ($territorio->gruppo_servizio_id) { + $this->addError('territorio_id', "Il territorio {$territorio->numero} è nel pacchetto del gruppo {$territorio->gruppo?->nome}: lo assegna il sorvegliante, oppure va prima tolto dal pacchetto."); + return; + } + $proclamatore = Proclamatore::findOrFail($this->proclamatore_id); if (!$proclamatore->attivo) { $this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo."); @@ -140,91 +127,16 @@ class Assegna extends Component $this->apriInvio($assegnazione->id); } - /** - * Apre l'invio del link: subito dopo l'assegnazione, o da un'assegnazione - * rimasta in sospeso ("Invia link" nell'elenco, che è un componente a sé - * e lo chiede con l'evento apri-invio). - */ - #[On('apri-invio')] - public function apriInvio(int $assegnazioneId): void + // ─── Invio del link (trait InviaLink) ────────────────────── + + protected function puoInviare(Assegnazione $assegnazione): bool { - $this->authorize('territori.assign'); - - $assegnazione = Assegnazione::aperte()->find($assegnazioneId); - if (! $assegnazione) { - return; - } - - $this->invioId = $assegnazione->id; - $this->canaleUsato = null; - $this->erroreInvio = ''; + return auth()->user()->can('territori.assign'); } - /** - * Copia link o Condividi: l'invio vero lo fa chi usa l'app (il - * destinatario lo sceglie lui dai suoi contatti), qui si annota il canale. - */ - public function canale(string $canale): void + protected function paginaDopoInvio(): string { - $assegnazione = $this->assegnazioneInInvio(); - if (! $assegnazione || ! in_array($canale, Assegnazione::CANALI_ATTIVI, true) || ! $assegnazione->shortPdfUrl()) { - return; - } - - $this->canaleUsato = $canale; - $this->erroreInvio = ''; - } - - /** - * La risposta a "Hai inviato il link?": la si registra e solo adesso si - * va avanti. "Sì" vuole un canale usato; "No" lascia il link da inviare. - */ - public function concludiInvio(bool $inviato) - { - $assegnazione = $this->assegnazioneInInvio(); - if (! $assegnazione) { - $this->invioId = null; - - return null; - } - - if ($inviato && $this->canaleUsato === null) { - $this->erroreInvio = 'Scegli prima come mandare il link.'; - - return null; - } - - $assegnazione->registraInvioLink($inviato, $this->canaleUsato); - - // Nel log il canale e l'id del proclamatore, mai nome, numero o email. - activity()->causedBy(auth()->user()) - ->performedOn($assegnazione) - ->withProperties([ - 'territorio' => $assegnazione->territorio?->numero, - 'proclamatore_id' => $assegnazione->proclamatore_id, - 'canale' => $inviato ? $this->canaleUsato : null, - ]) - ->log($inviato ? 'link_inviato' : 'link_non_inviato'); - - $numero = $assegnazione->territorio?->numero; - $nome = $assegnazione->proclamatore?->nome_completo; - $this->reset(['invioId', 'canaleUsato', 'erroreInvio']); - - session()->flash('success', $inviato - ? "Territorio {$numero} assegnato a {$nome}: link inviato." - : "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco."); - return $this->redirect($this->destinazione(route('assegnazioni.assegna')), navigate: true); - } - - private function assegnazioneInInvio(): ?Assegnazione - { - if (! $this->invioId) { - return null; - } - - $this->authorize('territori.assign'); - - return Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId); + return $this->destinazione(route('assegnazioni.assegna')); } public function render() @@ -233,30 +145,27 @@ class Assegna extends Component // i territori già assegnati, che non si possono scegliere: cercandone // uno si legge a chi è, invece di un "nessun risultato". La miniatura // serve all'anteprima, che si aggiorna nel browser. + // Quelli nel pacchetto di un gruppo di servizio li assegna il + // sorvegliante: qui si trovano ma non si scelgono. $territori = Territorio::attivi() - ->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']) + ->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'gruppo']) ->get() ->sortBy('numero', SORT_NATURAL); $opzioniTerritori = $territori - ->map(fn (Territorio $t) => $t->opzioneScelta( - $t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null - ) + array_filter(['miniatura' => $t->miniaturaUrl()])) + ->map(fn (Territorio $t) => $t->opzioneScelta(match (true) { + (bool) $t->assegnazioneCorrente => 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—'), + (bool) $t->gruppo_servizio_id => 'nel pacchetto del gruppo ' . ($t->gruppo?->nome ?? ''), + default => null, + }) + array_filter(['miniatura' => $t->miniaturaUrl()])) ->values() ->all(); $opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get()); // L'elenco dei territori assegnati sta in ElencoAssegnati. - // L'invio in corso; se nel frattempo l'assegnazione è rientrata o - // sparita non c'è più niente da mandare. - $invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null; - if ($this->invioId && ! $invio) { - $this->reset(['invioId', 'canaleUsato', 'erroreInvio']); - } - return view('livewire.assegnazioni.assegna', [ - 'invio' => $invio, + 'invio' => $this->invioCorrente(), 'opzioniTerritori' => $opzioniTerritori, 'opzioniProclamatori' => $opzioniProclamatori, 'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null, diff --git a/app/Livewire/Assegnazioni/Rientra.php b/app/Livewire/Assegnazioni/Rientra.php index d59d51d..69e2a44 100644 --- a/app/Livewire/Assegnazioni/Rientra.php +++ b/app/Livewire/Assegnazioni/Rientra.php @@ -27,6 +27,7 @@ class Rientra extends Component public function mount(Assegnazione $assegnazione) { + $this->autorizza($assegnazione); $this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']); if ($assegnazione->returned_at) { @@ -69,8 +70,28 @@ class Rientra extends Component ]; } + /** + * Chi ha territori.return registra qualsiasi rientro. Il sorvegliante di + * gruppo (gruppo.gestisci) solo quelli del suo gruppo: assegnazioni nate + * dal gruppo o territori del suo pacchetto. + */ + private function autorizza(Assegnazione $assegnazione): void + { + $utente = auth()->user(); + if ($utente->can('territori.return')) { + return; + } + + $gruppo = $utente->can('gruppo.gestisci') ? $utente->gruppoSorvegliato() : null; + abort_unless( + $gruppo && ($assegnazione->gruppo_servizio_id === $gruppo->id || $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id), + 403 + ); + } + public function save() { + $this->autorizza($this->assegnazione); $this->validate(); // mount() rejects an already-returned assignment, but the component @@ -112,7 +133,18 @@ class Rientra extends Component } session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni."); - return $this->redirect($this->destinazione(route('territori.show', $territorio)), navigate: true); + return $this->redirect($this->destinazione($this->paginaPredefinita()), navigate: true); + } + + /** + * Dove si torna senza una pagina di partenza: la scheda del territorio, + * o "Il mio gruppo" per il sorvegliante, che la scheda non la vede. + */ + public function paginaPredefinita(): string + { + return auth()->user()->can('territori.assign') + ? route('territori.show', $this->assegnazione->territorio_id) + : route('mio-gruppo'); } public function render() diff --git a/app/Livewire/Concerns/InviaLink.php b/app/Livewire/Concerns/InviaLink.php new file mode 100644 index 0000000..b57219e --- /dev/null +++ b/app/Livewire/Concerns/InviaLink.php @@ -0,0 +1,132 @@ +find($assegnazioneId); + if (! $assegnazione) { + return; + } + abort_unless($this->puoInviare($assegnazione), 403); + + $this->invioId = $assegnazione->id; + $this->canaleUsato = null; + $this->erroreInvio = ''; + } + + /** Copia link o Condividi: l'invio vero lo fa chi usa l'app, qui si annota il canale. */ + public function canale(string $canale): void + { + $assegnazione = $this->assegnazioneInInvio(); + if (! $assegnazione || ! in_array($canale, Assegnazione::CANALI_ATTIVI, true) || ! $assegnazione->shortPdfUrl()) { + return; + } + + $this->canaleUsato = $canale; + $this->erroreInvio = ''; + } + + /** + * La risposta a "Hai inviato il link?": la si registra e solo adesso si + * va avanti. "Sì" vuole un canale usato; "No" lascia il link da inviare. + */ + public function concludiInvio(bool $inviato) + { + $assegnazione = $this->assegnazioneInInvio(); + if (! $assegnazione) { + $this->invioId = null; + + return null; + } + + if ($inviato && $this->canaleUsato === null) { + $this->erroreInvio = 'Scegli prima come mandare il link.'; + + return null; + } + + $assegnazione->registraInvioLink($inviato, $this->canaleUsato); + + // Nel log il canale e l'id del proclamatore, mai il nome. + activity()->causedBy(auth()->user()) + ->performedOn($assegnazione) + ->withProperties([ + 'territorio' => $assegnazione->territorio?->numero, + 'proclamatore_id' => $assegnazione->proclamatore_id, + 'canale' => $inviato ? $this->canaleUsato : null, + ]) + ->log($inviato ? 'link_inviato' : 'link_non_inviato'); + + $numero = $assegnazione->territorio?->numero; + $nome = $assegnazione->proclamatore?->nome_completo; + $this->reset(['invioId', 'canaleUsato', 'erroreInvio']); + + session()->flash('success', $inviato + ? "Territorio {$numero} assegnato a {$nome}: link inviato." + : "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco."); + return $this->redirect($this->paginaDopoInvio(), navigate: true); + } + + /** L'invio in corso, per la vista; null se l'assegnazione nel frattempo è rientrata o sparita. */ + protected function invioCorrente(): ?Assegnazione + { + $invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null; + if ($this->invioId && ! $invio) { + $this->reset(['invioId', 'canaleUsato', 'erroreInvio']); + } + + return $invio; + } + + private function assegnazioneInInvio(): ?Assegnazione + { + if (! $this->invioId) { + return null; + } + + $assegnazione = Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId); + abort_unless($assegnazione && $this->puoInviare($assegnazione), 403); + + return $assegnazione; + } +} diff --git a/app/Livewire/Gruppi/GruppiIndex.php b/app/Livewire/Gruppi/GruppiIndex.php new file mode 100644 index 0000000..d0125fd --- /dev/null +++ b/app/Livewire/Gruppi/GruppiIndex.php @@ -0,0 +1,92 @@ +authorize('settings.manage'); + + $this->validate([ + 'nome' => 'required|string|max:100', + 'sorveglianteId' => 'required|integer|exists:proclamatori,id', + 'email' => 'required|email|max:255', + ], [ + '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()); + + return; + } + + $this->reset(['nome', 'sorveglianteId', 'email']); + $this->creatoId = $gruppo->id; + $this->passwordTemporanea = $password; + } + + public function chiudiPassword(): void + { + $this->reset(['creatoId', 'passwordTemporanea']); + } + + public function render() + { + $gruppi = GruppoServizio::with('sorvegliante') + ->withCount(['membri', 'territori']) + ->orderByRaw('sciolto_at IS NOT NULL') + ->orderBy('nome') + ->get(); + + // Possono diventare sorveglianti i proclamatori attivi senza gruppo e + // senza già un'utenza. + $candidati = Proclamatore::attivi() + ->whereNull('gruppo_servizio_id') + ->whereDoesntHave('utente') + ->get(); + + return view('livewire.gruppi.gruppi-index', [ + 'gruppi' => $gruppi, + 'opzioniSorveglianti' => Proclamatore::opzioniScelta($candidati), + 'creato' => $this->creatoId ? GruppoServizio::with('sorvegliante.utente')->find($this->creatoId) : null, + ]); + } +} diff --git a/app/Livewire/Gruppi/GruppoShow.php b/app/Livewire/Gruppi/GruppoShow.php new file mode 100644 index 0000000..2b155ae --- /dev/null +++ b/app/Livewire/Gruppi/GruppoShow.php @@ -0,0 +1,139 @@ +gruppo = $gruppo; + } + + private function servizio(): GruppiServizio + { + $this->authorize('settings.manage'); + + return app(GruppiServizio::class); + } + + private function esegui(callable $azione, string $ok): void + { + try { + $azione(); + } catch (InvalidArgumentException $e) { + $this->avvisaErrore($e->getMessage()); + + return; + } + + $this->gruppo->refresh(); + $this->avvisa($ok); + } + + public function aggiungiMembri(): void + { + $ids = array_map('intval', $this->nuoviMembri); + $this->esegui(fn () => $this->servizio()->aggiungiMembri($this->gruppo, $ids), count($ids) . ' membri aggiunti.'); + $this->nuoviMembri = []; + } + + public function togliMembro(int $proclamatoreId): void + { + $membro = Proclamatore::findOrFail($proclamatoreId); + $this->esegui(fn () => $this->servizio()->togliMembro($this->gruppo, $membro), "{$membro->nome_completo} tolto dal gruppo."); + } + + public function aggiungiTerritori(): void + { + $ids = array_map('intval', $this->nuoviTerritori); + $this->esegui(fn () => $this->servizio()->aggiungiAlPacchetto($this->gruppo, $ids), count($ids) . ' territori aggiunti al pacchetto.'); + $this->nuoviTerritori = []; + } + + public function rimanda(int $territorioId): void + { + $territorio = Territorio::findOrFail($territorioId); + $this->esegui(fn () => $this->servizio()->rimandaInReparto($this->gruppo, $territorio), "Territorio {$territorio->numero} rimandato in reparto."); + } + + /** Il sorvegliante ha perso la password: una nuova temporanea, da cambiare al primo accesso. */ + public function nuovaPassword(): void + { + $this->servizio(); + $utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first(); + if (! $utente || $this->gruppo->sciolto_at) { + $this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.'); + + return; + } + + $password = Str::password(12, symbols: false); + $utente->forceFill(['password' => $password, 'password_change_required' => true])->save(); + activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata'); + + $this->passwordTemporanea = $password; + } + + /** Toglie il ruolo di sorvegliante: il gruppo si scioglie. */ + public function sciogli(): void + { + $this->esegui(fn () => $this->servizio()->sciogli($this->gruppo), "Gruppo {$this->gruppo->nome} sciolto."); + $this->passwordTemporanea = null; + } + + public function render() + { + $this->gruppo->load(['sorvegliante.utente']); + + $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(), + // ...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(), + ]); + } +} diff --git a/app/Livewire/Gruppi/MioGruppo.php b/app/Livewire/Gruppi/MioGruppo.php new file mode 100644 index 0000000..3b654c5 --- /dev/null +++ b/app/Livewire/Gruppi/MioGruppo.php @@ -0,0 +1,191 @@ +dataAssegnazione = now()->format('Y-m-d'); + } + + private function gruppo(): ?GruppoServizio + { + return auth()->user()->gruppoSorvegliato(); + } + + private function gruppoOFallisci(): GruppoServizio + { + $gruppo = $this->gruppo(); + abort_unless($gruppo, 403, 'Non sei il sorvegliante di un gruppo di servizio attivo.'); + + return $gruppo; + } + + private function territorioDelPacchetto(int $id): Territorio + { + $gruppo = $this->gruppoOFallisci(); + $territorio = Territorio::find($id); + abort_unless($territorio && $territorio->gruppo_servizio_id === $gruppo->id, 403); + + return $territorio; + } + + // ─── Assegnare un territorio del pacchetto ────────────────── + + public function apriAssegnazione(int $territorioId): void + { + $this->territorioDelPacchetto($territorioId); + $this->territorioDaAssegnare = $territorioId; + $this->membroId = null; + $this->dataAssegnazione = now()->format('Y-m-d'); + $this->resetValidation(); + } + + public function annullaAssegnazione(): void + { + $this->reset(['territorioDaAssegnare', 'membroId']); + $this->resetValidation(); + } + + public function assegna(): void + { + $this->validate([ + 'membroId' => 'required|integer', + 'dataAssegnazione' => 'required|date|before_or_equal:today', + ], [ + 'membroId.required' => 'Scegli a chi dare il territorio.', + 'dataAssegnazione.before_or_equal' => 'La data non può essere nel futuro.', + ]); + + $gruppo = $this->gruppoOFallisci(); + $territorio = $this->territorioDelPacchetto((int) $this->territorioDaAssegnare); + $membro = Proclamatore::find($this->membroId); + + if (! $membro) { + $this->addError('membroId', 'Scegli un membro del gruppo.'); + + return; + } + + try { + $assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione)); + } catch (InvalidArgumentException $e) { + $this->addError('membroId', $e->getMessage()); + + return; + } + + $this->reset(['territorioDaAssegnare', 'membroId']); + + // Come dalla pagina Assegna: si apre l'invio del link. + $this->apriInvio($assegnazione->id); + } + + // ─── Rimandare in reparto ─────────────────────────────────── + + public function rimanda(int $territorioId): void + { + $territorio = $this->territorioDelPacchetto($territorioId); + + try { + app(GruppiServizio::class)->rimandaInReparto($this->gruppoOFallisci(), $territorio); + } catch (InvalidArgumentException $e) { + $this->avvisaErrore($e->getMessage()); + + return; + } + + $this->avvisa("Territorio {$territorio->numero} rimandato in reparto."); + } + + // ─── Link già inviato / condiviso dall'elenco ─────────────── + + private function assegnazioneDelGruppo(int $id): Assegnazione + { + $assegnazione = Assegnazione::aperte()->with('territorio')->find($id); + abort_unless($assegnazione && $this->puoInviare($assegnazione), 403); + + return $assegnazione; + } + + public function toggleLinkSent(int $assegnazioneId): void + { + $assegnazione = $this->assegnazioneDelGruppo($assegnazioneId); + $assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale); + } + + /** "Condividi" dall'elenco andato a buon fine (elencoCercabile.condividi). */ + public function segnaCondiviso(int $assegnazioneId): void + { + $this->assegnazioneDelGruppo($assegnazioneId)->registraInvioLink(true, 'condividi'); + } + + // ─── Invio del link (trait InviaLink) ─────────────────────── + + protected function puoInviare(Assegnazione $assegnazione): bool + { + $gruppo = $this->gruppo(); + + return $gruppo !== null + && ($assegnazione->gruppo_servizio_id === $gruppo->id + || $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id); + } + + protected function paginaDopoInvio(): string + { + return route('mio-gruppo'); + } + + public function render() + { + $gruppo = $this->gruppo(); + + $territori = $gruppo + ? $gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values() + : collect(); + + $membri = $gruppo + ? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values() + : collect(); + + return view('livewire.gruppi.mio-gruppo', [ + 'gruppo' => $gruppo, + 'territori' => $territori, + 'membri' => $membri, + 'opzioniMembri' => Proclamatore::opzioniScelta($membri), + 'invio' => $this->invioCorrente(), + ]); + } +} diff --git a/app/Livewire/Privacy.php b/app/Livewire/Privacy.php index cfe20e0..d80a545 100644 --- a/app/Livewire/Privacy.php +++ b/app/Livewire/Privacy.php @@ -29,6 +29,7 @@ class Privacy extends Component public const PERMESSI = [ 'settings.manage' => 'impostazioni, utenti, backup e import', 'proclamatori.manage' => 'anagrafica dei proclamatori', + 'gruppo.gestisci' => 'solo il proprio gruppo di servizio: i territori del pacchetto e i membri del gruppo', 'territori.manage' => 'gestione dei territori e delle piantine', 'campagne.manage' => 'campagne', 'registro.view' => 'consultazione del registro delle assegnazioni', diff --git a/app/Livewire/Profile.php b/app/Livewire/Profile.php index 592cff9..c89df8a 100644 --- a/app/Livewire/Profile.php +++ b/app/Livewire/Profile.php @@ -41,7 +41,8 @@ class Profile extends Component // L'indirizzo nuovo va confermato (User::booted lo segna da // confermare): finché non apre il link, l'account resta bloccato. - if ($emailCambiata) { + // Il sorvegliante di gruppo non conferma l'email (User::hasVerifiedEmail). + if ($emailCambiata && ! $user->isSorvegliante()) { $user->inviaConfermaEmail() ? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.") : session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.'); diff --git a/app/Livewire/Registro.php b/app/Livewire/Registro.php index 0f4117d..52a828d 100644 --- a/app/Livewire/Registro.php +++ b/app/Livewire/Registro.php @@ -285,7 +285,7 @@ class Registro extends Component public function render() { - $query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']); + $query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna', 'gruppo']); if ($this->filtroAnno) { $query->where('anno_teocratico_id', $this->filtroAnno); diff --git a/app/Livewire/Settings/UsersIndex.php b/app/Livewire/Settings/UsersIndex.php index 9e228d2..364311e 100644 --- a/app/Livewire/Settings/UsersIndex.php +++ b/app/Livewire/Settings/UsersIndex.php @@ -34,7 +34,10 @@ class UsersIndex extends Component public function mount(): void { + // "sorvegliante" si dà solo creando un gruppo di servizio, con + // l'utenza collegata al proclamatore: da qui non si assegna. $this->availableRoles = Role::query() + ->where('name', '!=', 'sorvegliante') ->orderBy('name') ->pluck('name') ->all(); @@ -111,9 +114,28 @@ class UsersIndex extends Component } } + /** + * Le utenze dei sorveglianti di gruppo (attive o disattivate) si gestiscono + * da Gruppi di servizio: cambiarle o cancellarle da qui lascerebbe un + * gruppo senza sorvegliante, o un sorvegliante con un ruolo diverso. + */ + private function gestitaDaiGruppi(User $user): bool + { + if ($user->proclamatore_id === null) { + return false; + } + + $this->avvisaErrore("L'utenza di {$user->name} è di un sorvegliante di gruppo: si gestisce da Gruppi di servizio."); + + return true; + } + public function startEdit(int $userId): void { $user = User::query()->with('roles')->findOrFail($userId); + if ($this->gestitaDaiGruppi($user)) { + return; + } $this->editingUserId = $user->id; $this->editName = $user->name; @@ -143,6 +165,9 @@ class UsersIndex extends Component $validated = $this->validate($this->editRules()); $user = User::query()->findOrFail($this->editingUserId); + if ($this->gestitaDaiGruppi($user)) { + return; + } // Togliere il ruolo di amministratore a sé stessi, o all'ultimo // rimasto, lascia l'installazione senza nessuno che possa gestire @@ -193,6 +218,9 @@ class UsersIndex extends Component { $currentUser = auth()->user(); $user = User::query()->with('roles')->findOrFail($userId); + if ($this->gestitaDaiGruppi($user)) { + return; + } if (! $currentUser || $currentUser->id === $user->id) { $this->avvisaErrore('Non puoi cancellare il tuo utente.'); diff --git a/app/Livewire/Settings/XmlExchange.php b/app/Livewire/Settings/XmlExchange.php index 59d70d0..deea148 100644 --- a/app/Livewire/Settings/XmlExchange.php +++ b/app/Livewire/Settings/XmlExchange.php @@ -7,6 +7,7 @@ use App\Jobs\ImportTerritoryPdfFolder; use App\Models\AnnoTeocratico; use App\Models\Assegnazione; use App\Models\Campagna; +use App\Models\GruppoServizio; use App\Models\Proclamatore; use App\Models\Setting; use App\Models\Territorio; @@ -469,6 +470,7 @@ class XmlExchange extends Component try { DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) { Assegnazione::query()->delete(); + GruppoServizio::query()->delete(); Campagna::query()->delete(); Territorio::query()->withTrashed()->forceDelete(); Proclamatore::query()->withTrashed()->forceDelete(); @@ -520,6 +522,7 @@ class XmlExchange extends Component // accounts in the file are (re)created. $userMap remaps the // created_by/returned_by FKs on the assignments below. $userMap = []; + $utentiDaCollegare = []; foreach (($xml->users->user ?? []) as $userNode) { $email = trim((string) ($userNode->email ?? '')); $legacyId = (string) ($userNode['legacy_id'] ?? ''); @@ -589,6 +592,13 @@ class XmlExchange extends Component DB::table('users')->where('id', $user->id)->update($meta); + // Utenza di un sorvegliante: si collega al proclamatore dopo che + // i proclamatori sono stati importati (vedi i gruppi di servizio). + $utentiDaCollegare[$user->id] = [ + 'proclamatore' => (string) ($userNode->legacy_proclamatore_id ?? ''), + 'disattivato_at' => $this->normalizeDateTimeForDb((string) ($userNode->disattivato_at ?? '')), + ]; + $stats['utenti_importati']++; if ($legacyId !== '') { $userMap[$legacyId] = $user->id; @@ -624,6 +634,7 @@ class XmlExchange extends Component } $proclamatoreMap = []; + $gruppiDeiProclamatori = []; foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) { $p = Proclamatore::create([ 'nome' => (string) $proclamatoreNode->nome, @@ -634,14 +645,42 @@ class XmlExchange extends Component ]); $this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true); $stats['proclamatori_importati']++; + $gruppiDeiProclamatori[$p->id] = (string) ($proclamatoreNode->legacy_gruppo_id ?? ''); $legacyId = (string) ($proclamatoreNode['legacy_id'] ?? ''); if ($legacyId !== '') { $proclamatoreMap[$legacyId] = $p->id; } } - $annoMap = []; - foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) { + // Gruppi di servizio, dopo i proclamatori: il sorvegliante è uno di + // loro. Poi i collegamenti che li aspettavano (membri, utenze). + $gruppoMap = []; + foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) { + $legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? ''); + $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'] ?? ''); + if ($legacyId !== '') { + $gruppoMap[$legacyId] = $g->id; + } + } + foreach ($gruppiDeiProclamatori as $idProclamatore => $legacyGruppo) { + if (isset($gruppoMap[$legacyGruppo])) { + DB::table('proclamatori')->where('id', $idProclamatore)->update(['gruppo_servizio_id' => $gruppoMap[$legacyGruppo]]); + } + } + foreach ($utentiDaCollegare as $idUtente => $collegamento) { + DB::table('users')->where('id', $idUtente)->update([ + 'proclamatore_id' => $proclamatoreMap[$collegamento['proclamatore']] ?? null, + 'disattivato_at' => $collegamento['disattivato_at'], + ]); + } + + $annoMap = []; foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) { $label = (string) $annoNode->label; $anno = AnnoTeocratico::create([ 'label' => $label, @@ -706,6 +745,7 @@ class XmlExchange extends Component 'note' => (string) ($territorioNode->note ?? ''), 'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1, 'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1, + 'gruppo_servizio_id' => $gruppoMap[(string) ($territorioNode->legacy_gruppo_id ?? '')] ?? null, 'pdf_path' => $pdfPath !== '' ? $pdfPath : null, 'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null, ]); @@ -768,6 +808,7 @@ class XmlExchange extends Component 'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null, '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, '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 ?? ''), @@ -824,16 +865,20 @@ class XmlExchange extends Component // remember_token no: all'import si azzera comunque, e nel file // sarebbe solo una credenziale in più per chi lo tiene. 'roles' => $u->roles->pluck('name')->all(), + // Sorvegliante di gruppo: il proclamatore collegato e se l'utenza è disattivata. + 'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '', + 'disattivato_at' => optional($u->disattivato_at)->toIso8601String() ?? '', 'created_at' => optional($u->created_at)->toIso8601String() ?? '', 'updated_at' => optional($u->updated_at)->toIso8601String() ?? '', ])->all(), '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', 'deleted_at', 'created_at', 'updated_at']), - 'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', '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']), + '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', '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', 'pdf_access_code', 'created_at', 'updated_at']), ]; } @@ -1232,6 +1277,8 @@ class XmlExchange extends Component $this->addXmlText($node, 'email', (string) ($user['email'] ?? '')); $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'] ?? '')); + $node->addChild('disattivato_at', (string) ($user['disattivato_at'] ?? '')); $node->addChild('created_at', (string) ($user['created_at'] ?? '')); $node->addChild('updated_at', (string) ($user['updated_at'] ?? '')); $rolesNode = $node->addChild('roles'); @@ -1265,6 +1312,7 @@ class XmlExchange extends Component $this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? '')); $this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? '')); $node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1))); + $node->addChild('legacy_gruppo_id', (string) ($proclamatore['gruppo_servizio_id'] ?? '')); $node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? '')); $this->addTimestampNodes($node, $proclamatore); } @@ -1280,6 +1328,7 @@ class XmlExchange extends Component $this->addXmlText($node, 'note', (string) ($territorio['note'] ?? '')); $node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1))); $node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0))); + $node->addChild('legacy_gruppo_id', (string) ($territorio['gruppo_servizio_id'] ?? '')); $this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? '')); $this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? '')); $node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? '')); @@ -1296,6 +1345,17 @@ class XmlExchange extends Component $this->addTimestampNodes($node, $anno); } + // Gruppi di servizio: il sorvegliante è un proclamatore (legacy id). + $gruppiNode = $xml->addChild('gruppi_servizio'); + foreach (($dataset['gruppi_servizio'] ?? []) as $gruppo) { + $node = $gruppiNode->addChild('gruppo'); + $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); + } + $campagneNode = $xml->addChild('campagne'); foreach ($dataset['campagne'] as $campagna) { $node = $campagneNode->addChild('campagna'); @@ -1321,6 +1381,7 @@ class XmlExchange extends Component $node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0))); $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, 'link_canale', (string) ($assegnazione['link_canale'] ?? '')); $this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? '')); $this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? '')); diff --git a/app/Livewire/Territori/TerritorioIndex.php b/app/Livewire/Territori/TerritorioIndex.php index 0051ee7..e865276 100644 --- a/app/Livewire/Territori/TerritorioIndex.php +++ b/app/Livewire/Territori/TerritorioIndex.php @@ -155,6 +155,7 @@ class TerritorioIndex extends Component 'tipologia', 'assegnazioneCorrente.proclamatore', 'ultimaAssegnazione', + 'gruppo', ]); if ($this->search) { @@ -179,6 +180,7 @@ class TerritorioIndex extends Component if ($this->filterStato) { match ($this->filterStato) { 'in_reparto' => $query->inReparto(), + 'in_gruppo' => $query->inGruppo(), 'assegnato' => $query->assegnato(), 'da_rientrare' => $query->daRientrare(), 'inattivo' => $query->where('attivo', false), diff --git a/app/Livewire/Territori/TerritorioShow.php b/app/Livewire/Territori/TerritorioShow.php index d34b8ac..7602ff5 100644 --- a/app/Livewire/Territori/TerritorioShow.php +++ b/app/Livewire/Territori/TerritorioShow.php @@ -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']) + ->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa', 'gruppo']) ->orderByDesc('assigned_at') ->get() ->groupBy(fn($a) => $a->annoTeocratico->label); diff --git a/app/Models/Assegnazione.php b/app/Models/Assegnazione.php index 3feb38b..1eeb547 100644 --- a/app/Models/Assegnazione.php +++ b/app/Models/Assegnazione.php @@ -25,8 +25,15 @@ class Assegnazione extends Model 'note', 'created_by', 'returned_by', + 'gruppo_servizio_id', ]; + /** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */ + public function gruppo() + { + return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id'); + } + protected function casts(): array { return [ @@ -35,6 +42,7 @@ class Assegnazione extends Model 'counted_in_campaign' => 'boolean', 'link_sent' => 'boolean', 'link_da_inviare' => 'boolean', + 'gruppo_servizio_id' => 'integer', ]; } @@ -56,6 +64,20 @@ class Assegnazione extends Model $territorio->update(['prioritario' => false]); } }); + + // Al rientro un territorio del pacchetto di un gruppo torna in reparto + // ed esce dal pacchetto. Qui e non nella pagina Rientra: vale per ogni + // via (rientro del sorvegliante, dell'amministratore, Registro). + static::updated(function (self $assegnazione): void { + if (! $assegnazione->wasChanged('returned_at') || $assegnazione->returned_at === null) { + return; + } + + Territorio::withTrashed() + ->whereKey($assegnazione->territorio_id) + ->whereNotNull('gruppo_servizio_id') + ->update(['gruppo_servizio_id' => null]); + }); } /** diff --git a/app/Models/GruppoServizio.php b/app/Models/GruppoServizio.php new file mode 100644 index 0000000..9740fd2 --- /dev/null +++ b/app/Models/GruppoServizio.php @@ -0,0 +1,75 @@ + 'datetime', 'sorvegliante_id' => 'integer']; + } + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['nome', 'sorvegliante_id', 'sciolto_at']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + public function sorvegliante() + { + return $this->belongsTo(Proclamatore::class, 'sorvegliante_id')->withTrashed(); + } + + public function membri() + { + return $this->hasMany(Proclamatore::class, 'gruppo_servizio_id'); + } + + /** Il pacchetto: i territori in carico al gruppo, dati o non ancora dati. */ + public function territori() + { + return $this->hasMany(Territorio::class, 'gruppo_servizio_id'); + } + + public function assegnazioni() + { + 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'); + } +} diff --git a/app/Models/Proclamatore.php b/app/Models/Proclamatore.php index 1fed1c5..0e5c092 100644 --- a/app/Models/Proclamatore.php +++ b/app/Models/Proclamatore.php @@ -16,7 +16,19 @@ class Proclamatore extends Model // Niente email né telefono (tolti il 19 ottobre 2026): il link della // piantina lo manda chi assegna, copiandolo o con "Condividi", e il // destinatario lo sceglie dai propri contatti. - protected $fillable = ['nome', 'cognome', 'attivo']; + protected $fillable = ['nome', 'cognome', 'attivo', 'gruppo_servizio_id']; + + /** Il gruppo di servizio di cui è membro (al massimo uno). */ + public function gruppo() + { + return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id'); + } + + /** L'utenza, se è sorvegliante di un gruppo. */ + public function utente() + { + return $this->hasOne(User::class, 'proclamatore_id'); + } protected function casts(): array { @@ -24,6 +36,7 @@ class Proclamatore extends Model 'nome' => 'encrypted', 'cognome' => 'encrypted', 'attivo' => 'boolean', + 'gruppo_servizio_id' => 'integer', ]; } diff --git a/app/Models/Territorio.php b/app/Models/Territorio.php index a35a0e4..349ce80 100644 --- a/app/Models/Territorio.php +++ b/app/Models/Territorio.php @@ -24,12 +24,20 @@ class Territorio extends Model 'thumbnail_path', 'attivo', 'prioritario', + 'gruppo_servizio_id', ]; + /** Il gruppo di servizio nel cui pacchetto si trova, se c'è. */ + public function gruppo() + { + return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id'); + } + protected function casts(): array { return [ 'attivo' => 'boolean', + 'gruppo_servizio_id' => 'integer', 'prioritario' => 'boolean', ]; } @@ -95,9 +103,25 @@ class Territorio extends Model return 'assegnato'; } + // Nel pacchetto di un gruppo e non ancora dato a nessuno: non è in + // reparto (non si assegna dalla pagina normale), è del gruppo. + if ($this->gruppo_servizio_id) { + return 'in_gruppo'; + } + return 'in_reparto'; } + /** Lo stato da mostrare: "In gruppo Zisa" invece di "In gruppo". */ + public function getStatoEtichettaAttribute(): string + { + $stato = $this->stato; + + return $stato === 'in_gruppo' + ? 'In gruppo ' . ($this->gruppo?->nome ?? '') + : str_replace('_', ' ', ucfirst($stato)); + } + public function getAssegnatarioAttribute(): ?Proclamatore { return $this->assegnazioneCorrente?->proclamatore; @@ -172,12 +196,23 @@ class Territorio extends Model public function scopeInReparto($query) { + // Quelli nel pacchetto di un gruppo non sono in reparto: stanno al + // gruppo finché il sorvegliante non li dà o non li rimanda indietro. return $query->attivi() + ->whereNull('gruppo_servizio_id') ->whereDoesntHave('assegnazioni', function ($q) { $q->whereNull('returned_at'); }); } + /** Nel pacchetto di un gruppo e non ancora dati a nessuno. */ + public function scopeInGruppo($query) + { + return $query->attivi() + ->whereNotNull('gruppo_servizio_id') + ->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at')); + } + public function scopeAssegnato($query) { return $query->attivi() diff --git a/app/Models/User.php b/app/Models/User.php index 74df4b0..dc30910 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -24,6 +24,8 @@ class User extends Authenticatable implements MustVerifyEmail 'email', 'password', 'password_change_required', + 'proclamatore_id', + 'disattivato_at', ]; protected $hidden = [ @@ -37,9 +39,50 @@ class User extends Authenticatable implements MustVerifyEmail 'email_verified_at' => 'datetime', 'password' => 'hashed', 'password_change_required' => 'boolean', + 'disattivato_at' => 'datetime', + 'proclamatore_id' => 'integer', ]; } + /** Il proclamatore di cui è l'utenza (sorvegliante di gruppo). */ + public function proclamatore() + { + return $this->belongsTo(Proclamatore::class, 'proclamatore_id')->withTrashed(); + } + + /** + * Sorvegliante di gruppo: vede solo il proprio gruppo e il proprio + * profilo (middleware LimitaSorvegliante). + */ + public function isSorvegliante(): bool + { + return $this->hasRole('sorvegliante'); + } + + /** Il gruppo (attivo) di cui è sorvegliante, se c'è. */ + public function gruppoSorvegliato(): ?GruppoServizio + { + return $this->proclamatore_id + ? GruppoServizio::attivi()->where('sorvegliante_id', $this->proclamatore_id)->first() + : null; + } + + /** Utenza disattivata (gruppo sciolto): non può più entrare. */ + public function isDisattivato(): bool + { + return $this->disattivato_at !== null; + } + + /** + * Il sorvegliante non conferma l'email (scelta dell'utente, 19 ottobre + * 2026): l'utenza la crea l'amministratore con una password temporanea. + * Per tutti gli altri resta obbligatoria. + */ + public function hasVerifiedEmail(): bool + { + return ! is_null($this->email_verified_at) || $this->isSorvegliante(); + } + public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() @@ -65,6 +108,10 @@ class User extends Authenticatable implements MustVerifyEmail */ public function inviaConfermaEmail(): bool { + if ($this->isSorvegliante()) { + return true; + } + try { $this->sendEmailVerificationNotification(); } catch (\Throwable $e) { diff --git a/app/Services/GruppiServizio.php b/app/Services/GruppiServizio.php new file mode 100644 index 0000000..6cc427d --- /dev/null +++ b/app/Services/GruppiServizio.php @@ -0,0 +1,259 @@ +attivo || $sorvegliante->trashed()) { + throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.'); + } + if ($sorvegliante->gruppo_servizio_id) { + throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo."); + } + if ($sorvegliante->utente()->exists()) { + throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza."); + } + if (User::where('email', $email)->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) { + $gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]); + $sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]); + $this->aggiungiMembri($gruppo, $membri); + + $utente = User::create([ + 'name' => $sorvegliante->nome_completo, + 'email' => $email, + 'password' => $password, + 'password_change_required' => true, + 'proclamatore_id' => $sorvegliante->id, + ]); + $utente->assignRole('sorvegliante'); + + activity()->causedBy(auth()->user())->performedOn($gruppo) + ->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id]) + ->log('gruppo_creato'); + + return $gruppo; + }); + + return [$gruppo, $password]; + } + + /** @param int[] $ids */ + public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void + { + $this->attivo($gruppo); + + foreach (Proclamatore::whereIn('id', $ids)->get() as $p) { + if ($p->gruppo_servizio_id && $p->gruppo_servizio_id !== $gruppo->id) { + throw new InvalidArgumentException("{$p->nome_completo} fa già parte di un altro gruppo."); + } + if (! $p->attivo) { + throw new InvalidArgumentException("{$p->nome_completo} non è attivo."); + } + $p->update(['gruppo_servizio_id' => $gruppo->id]); + } + } + + public function togliMembro(GruppoServizio $gruppo, Proclamatore $membro): void + { + $this->attivo($gruppo); + + if ($membro->id === $gruppo->sorvegliante_id) { + throw new InvalidArgumentException('Il sorvegliante non si toglie: per cambiarlo si scioglie il gruppo.'); + } + if ($membro->gruppo_servizio_id === $gruppo->id) { + $membro->update(['gruppo_servizio_id' => null]); + } + } + + /** + * Mette nel pacchetto del gruppo dei territori in reparto (attivi, non + * assegnati, non già in un altro pacchetto). Restituisce quanti. + * + * @param int[] $ids + */ + public function aggiungiAlPacchetto(GruppoServizio $gruppo, array $ids): int + { + $this->attivo($gruppo); + + $territori = Territorio::whereIn('id', $ids)->get(); + foreach ($territori as $t) { + if (! $t->attivo) { + throw new InvalidArgumentException("Il territorio {$t->numero} non è attivo."); + } + if ($t->gruppo_servizio_id && $t->gruppo_servizio_id !== $gruppo->id) { + throw new InvalidArgumentException("Il territorio {$t->numero} è già nel pacchetto di un altro gruppo."); + } + if ($t->assegnazioneCorrente) { + throw new InvalidArgumentException("Il territorio {$t->numero} è assegnato: va prima fatto rientrare."); + } + } + + Territorio::whereKey($territori->pluck('id'))->update(['gruppo_servizio_id' => $gruppo->id]); + + activity()->causedBy(auth()->user())->performedOn($gruppo) + ->withProperties(['territori' => $territori->pluck('numero')->all()]) + ->log('pacchetto_aggiunti'); + + return $territori->count(); + } + + /** + * Rimanda in reparto un territorio del pacchetto non ancora dato a + * nessuno (lo fa l'amministratore o il sorvegliante). + */ + public function rimandaInReparto(GruppoServizio $gruppo, Territorio $territorio): void + { + if ($territorio->gruppo_servizio_id !== $gruppo->id) { + throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo."); + } + if ($territorio->assegnazioneCorrente) { + throw new InvalidArgumentException("Il territorio {$territorio->numero} è assegnato: prima va registrato il rientro."); + } + + $territorio->update(['gruppo_servizio_id' => null]); + + activity()->causedBy(auth()->user())->performedOn($gruppo) + ->withProperties(['territorio' => $territorio->numero]) + ->log('pacchetto_rimandato_in_reparto'); + } + + /** + * Il sorvegliante dà un territorio del pacchetto a un membro (o a sé): + * nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il + * territorio resta nel pacchetto finché non rientra. + */ + public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data): Assegnazione + { + $this->attivo($gruppo); + + if ($territorio->gruppo_servizio_id !== $gruppo->id) { + throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo."); + } + if ($membro->gruppo_servizio_id !== $gruppo->id) { + throw new InvalidArgumentException("{$membro->nome_completo} non fa parte del gruppo."); + } + if (! $membro->attivo) { + throw new InvalidArgumentException("{$membro->nome_completo} non è attivo."); + } + if ($data->isFuture()) { + throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.'); + } + + $esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data) { + Territorio::whereKey($territorio->id)->lockForUpdate()->first(); + + $conflitto = Assegnazione::sovrappostaA($territorio->id, $data); + if ($conflitto) { + return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}."; + } + + return Assegnazione::create([ + 'territorio_id' => $territorio->id, + 'proclamatore_id' => $membro->id, + 'anno_teocratico_id' => AnnoTeocratico::perData($data)->id, + 'assigned_at' => $data, + 'created_by' => auth()->id(), + 'gruppo_servizio_id' => $gruppo->id, + 'link_da_inviare' => (bool) $territorio->pdf_path, + ]); + }); + + if (is_string($esito)) { + throw new InvalidArgumentException($esito); + } + + activity()->causedBy(auth()->user())->performedOn($esito) + ->withProperties([ + 'territorio' => $territorio->numero, + 'proclamatore_id' => $membro->id, + 'gruppo_servizio_id' => $gruppo->id, + ]) + ->log('assigned'); + + return $esito; + } + + /** + * 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. + */ + public function sciogli(GruppoServizio $gruppo): void + { + if ($gruppo->sciolto_at) { + return; + } + + DB::transaction(function () use ($gruppo) { + // 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() + ->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]); + + Proclamatore::withTrashed()->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(); + } + + $gruppo->update(['sciolto_at' => now()]); + + activity()->causedBy(auth()->user())->performedOn($gruppo) + ->withProperties(['territori_liberati' => $liberati->all(), 'utente_disattivato' => $utente?->id]) + ->log('gruppo_sciolto'); + }); + } + + private function attivo(GruppoServizio $gruppo): void + { + if ($gruppo->sciolto_at) { + throw new InvalidArgumentException('Il gruppo è stato sciolto: creane uno nuovo.'); + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 11f82f7..02b5b45 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -57,6 +57,8 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web(append: [ \Illuminate\Session\Middleware\AuthenticateSession::class, \App\Http\Middleware\RequirePasswordChange::class, + // Utenze disattivate fuori; il sorvegliante di gruppo solo nel suo gruppo. + \App\Http\Middleware\LimitaSorvegliante::class, ]); $middleware->alias([ diff --git a/database/migrations/2026_10_20_000010_gruppi_di_servizio.php b/database/migrations/2026_10_20_000010_gruppi_di_servizio.php new file mode 100644 index 0000000..89d262e --- /dev/null +++ b/database/migrations/2026_10_20_000010_gruppi_di_servizio.php @@ -0,0 +1,78 @@ +id(); + $table->string('nome', 100); + $table->foreignId('sorvegliante_id')->nullable()->constrained('proclamatori')->nullOnDelete(); + $table->timestamp('sciolto_at')->nullable(); + $table->timestamps(); + }); + + Schema::table('proclamatori', function (Blueprint $table) { + $table->foreignId('gruppo_servizio_id')->nullable()->after('attivo')->constrained('gruppi_servizio')->nullOnDelete(); + }); + + Schema::table('territori', function (Blueprint $table) { + $table->foreignId('gruppo_servizio_id')->nullable()->after('prioritario')->constrained('gruppi_servizio')->nullOnDelete(); + }); + + Schema::table('assegnazioni', function (Blueprint $table) { + $table->foreignId('gruppo_servizio_id')->nullable()->after('campaign_id')->constrained('gruppi_servizio')->nullOnDelete(); + }); + + Schema::table('users', function (Blueprint $table) { + $table->foreignId('proclamatore_id')->nullable()->unique()->after('email')->constrained('proclamatori')->nullOnDelete(); + $table->timestamp('disattivato_at')->nullable()->after('password_change_required'); + }); + + $permesso = Permission::firstOrCreate(['name' => 'gruppo.gestisci', 'guard_name' => 'web']); + Role::firstOrCreate(['name' => 'sorvegliante', 'guard_name' => 'web'])->givePermissionTo($permesso); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('proclamatore_id'); + $table->dropColumn('disattivato_at'); + }); + Schema::table('assegnazioni', fn (Blueprint $table) => $table->dropConstrainedForeignId('gruppo_servizio_id')); + Schema::table('territori', fn (Blueprint $table) => $table->dropConstrainedForeignId('gruppo_servizio_id')); + Schema::table('proclamatori', fn (Blueprint $table) => $table->dropConstrainedForeignId('gruppo_servizio_id')); + Schema::dropIfExists('gruppi_servizio'); + + Role::where('name', 'sorvegliante')->delete(); + Permission::where('name', 'gruppo.gestisci')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index b5bb6a0..5a81e5e 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -25,6 +25,7 @@ class RolesAndPermissionsSeeder extends Seeder 'territori.return', 'audit.view', 'audit.export', + 'gruppo.gestisci', ]; foreach ($permissions as $permission) { @@ -43,7 +44,9 @@ class RolesAndPermissionsSeeder extends Seeder // Amministratore: all permissions $admin = Role::firstOrCreate(['name' => 'amministratore']); - $admin->givePermissionTo($permissions); + // Tutti tranne gruppo.gestisci, che è del solo sorvegliante: l'admin + // gestisce i gruppi da settings.manage, non ne ha uno suo. + $admin->givePermissionTo(array_values(array_diff($permissions, ['gruppo.gestisci']))); // Assistente: proclamatori, campagne, assign, return $assistente = Role::firstOrCreate(['name' => 'assistente']); @@ -61,5 +64,10 @@ class RolesAndPermissionsSeeder extends Seeder 'territori.assign', 'territori.return', ]); + + // Sorvegliante di gruppo: solo il proprio gruppo di servizio. Il ruolo + // si dà creando un gruppo (Gruppi di servizio), mai dalla pagina Utenti. + $sorvegliante = Role::firstOrCreate(['name' => 'sorvegliante']); + $sorvegliante->givePermissionTo(['gruppo.gestisci']); } } diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 9378a68..4cd311d 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -79,11 +79,24 @@