Compare commits

...

6 Commits

Author SHA1 Message Date
Francesco Picone
9999a3b031 ++ gruppi: l'assegnazione dal pacchetto rilegge territorio e membro, e ricontrolla il pacchetto sotto lock
HANDOFF: trappola di Blade sul @php in linea, test sul server prima del push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:00:45 +02:00
Francesco Picone
93bc9cf2fe ++ gruppi: vista del sorvegliante compilabile, azzeramento completo, test sistemati
Nella vista Il mio gruppo un @php(...) in linea prima di un blocco
@php ... @endphp mandava in errore il compilatore di Blade. L'azzeramento
dell'applicazione ora cancella anche i gruppi di servizio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:59:31 +02:00
Francesco Picone
6489fb627d ++ gruppo: i pulsanti per aggiungere territori e membri non restano disabilitati
Il conteggio delle spunte arrivava al server solo al clic: il pulsante
restava disabilitato. Ora e' sempre attivo e senza spunte avvisa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:56:54 +02:00
Francesco Picone
f48d0e96c0 ++ gruppi di servizio: utenza dell'ex sorvegliante riattivabile, cestino e anonimizzazione, registro senza nomi
Un ex sorvegliante puo' guidare un gruppo nuovo: si riattiva la sua
utenza. Il sorvegliante di un gruppo attivo non si cestina ne' si
anonimizza; anonimizzando un ex sorvegliante si anonimizza anche
l'utenza. Nel registro delle attivita' nome ed email dell'utenza del
sorvegliante diventano "proclamatore #id". La voce "Il mio gruppo"
dipende dal ruolo: l'amministratore passa ogni @can.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:55:38 +02:00
Francesco Picone
96084499f5 ++ gruppi di servizio: sorvegliante con utenza limitata, pacchetto di territori, assegnazione ai membri
Il gruppo ha un sorvegliante (proclamatore con utenza: email e password
temporanea, senza conferma dell'email) e dei membri; l'amministratore
mette in blocco dei territori nel pacchetto del gruppo. Il pacchetto non
e' un'assegnazione e non tocca le statistiche; quando il sorvegliante da'
un territorio a un membro nasce un'assegnazione normale con
gruppo_servizio_id. Al rientro il territorio torna in reparto. Sciogliere
il gruppo libera pacchetto e membri e disattiva l'utenza.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:51:37 +02:00
Francesco Picone
5d0ae06eca ++ Update gitignore 2026-10-19 17:11:30 +02:00
44 changed files with 2504 additions and 228 deletions

1
.gitignore vendored
View File

@@ -18,7 +18,6 @@
/.phpunit.cache/
/.idea/
/.vscode/
.claude
*.swp
*.swo
*.sql

View File

@@ -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,82 @@ 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.
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.
- Il sorvegliante di un gruppo **attivo** non si sposta nel cestino né si
anonimizza (`ProclamatoreIndex`): prima si scioglie il gruppo. Un membro
spostato nel cestino lascia il gruppo. `Proclamatore::anonimizza()`
anonimizza anche l'utenza dell'ex sorvegliante (nome, email, password,
ruoli).
- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro
delle attività `User::tapActivity()` li sostituisce con "proclamatore #id",
come vale per tutti i proclamatori. Restano invece in chiaro nella tabella
`users`, come per ogni utente.
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).
- L'azzeramento dell'applicazione (*Zona pericolosa*) cancella anche i gruppi.
- Test: `tests/Feature/GruppiServizioTest.php`. Eseguiti sul server **prima**
del push, su una copia della checkout di dev con i commit portati da un
`git bundle` (la checkout di Dokploy è shallow: il bundle deve partire dal
commit che ha, `git log -1` lì dentro).
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
@@ -1556,6 +1632,14 @@ stringa invece di proteggere l'apostrofo, perché fra il file e l'attributo non
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
da nessuna parte; apostrofi italiani solo dentro backtick.
**In una vista Blade la forma in linea `@php(...)` non va messa prima di un
blocco `@php … @endphp`.** Blade estrae i blocchi `@php … @endphp` per primi,
con un'espressione che parte dalla prima `@php` che trova: se è quella in
linea, ingoia tutto fino al primo `@endphp` e la vista non compila più
("unexpected token class"). Successo in *Il mio gruppo* il 19/10/2026. E
vale anche dentro i commenti `{{-- --}}`, che Blade toglie *dopo*: lì la
parola `@php` non va scritta.
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.

View File

@@ -736,6 +736,33 @@ 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; se il sorvegliante è lo stesso, si riattiva la sua utenza
- Il sorvegliante di un gruppo attivo non si sposta nel cestino né si
anonimizza: prima si scioglie il gruppo. Anonimizzando un ex sorvegliante
si anonimizza anche la sua utenza
### Campagne
- CRUD con date di inizio e fine
- Stato automatico: attiva, futura o conclusa
@@ -771,24 +798,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.
@@ -827,7 +861,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
in chiaro
in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome
di un proclamatore)
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
in un luogo protetto e cancellati quando non servono
@@ -838,15 +873,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 +909,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 +934,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

View File

@@ -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'));
}
}

View File

@@ -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);

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Due regole per gli utenti autenticati.
*
* 1. Un'utenza disattivata (gruppo di servizio sciolto) non può più lavorare:
* anche una sessione già aperta viene chiusa alla richiesta successiva.
*
* 2. Il sorvegliante di gruppo vede solo il proprio gruppo e il proprio
* profilo. La Home lo porta a "Il mio gruppo"; ogni altra pagina è 403,
* anche scrivendone l'indirizzo a mano. Le azioni Livewire passano: i
* componenti che può aprire controllano da sé che territori e assegnazioni
* siano del suo gruppo, e gli altri non può caricarli.
*
* Vale solo per chi ha il ruolo sorvegliante e nessun altro: un utente con
* anche un altro ruolo resta regolato dai permessi di quel ruolo.
*/
class LimitaSorvegliante
{
private const ROTTE_AMMESSE = [
'mio-gruppo',
'mio-gruppo.*',
'assegnazioni.rientra',
'profile',
'password.change',
'password.change.store',
'logout',
'verification.*',
'livewire.*',
];
public function handle(Request $request, Closure $next): Response
{
$user = $request->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.');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Jobs;
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;
@@ -75,7 +76,7 @@ class ResetApplication implements ShouldQueue
$stato->avanza($this->operationId, 1);
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10);
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 11);
DB::transaction(function () use ($stato) {
$passi = [
@@ -87,6 +88,7 @@ class ResetApplication implements ShouldQueue
'Piantine' => fn () => TerritorioMappa::query()->delete(),
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
'Gruppi di servizio' => fn () => GruppoServizio::query()->delete(),
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
'Tipologie' => fn () => Tipologia::query()->delete(),
'Zone' => fn () => Zona::query()->delete(),

View File

@@ -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;
return auth()->user()->can('territori.assign');
}
$this->invioId = $assegnazione->id;
$this->canaleUsato = null;
$this->erroreInvio = '';
}
/**
* 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,

View File

@@ -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()

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Assegnazione;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
/**
* Invio del link della piantina dopo un'assegnazione, condiviso dalla pagina
* Assegna e da "Il mio gruppo" del sorvegliante. La vista è il partial
* livewire/assegnazioni/_invio-link.
*
* Dopo l'assegnazione non si va avanti: 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.
*
* Chi usa il trait dice chi può mandare il link di un'assegnazione
* (puoInviare) e dove si va dopo (paginaDopoInvio).
*/
trait InviaLink
{
/** 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 = '';
abstract protected function puoInviare(Assegnazione $assegnazione): bool;
abstract protected function paginaDopoInvio(): string;
/**
* Apre l'invio: subito dopo l'assegnazione, o da un'assegnazione rimasta
* in sospeso ("Invia link" nell'elenco, che lo chiede con apri-invio).
*/
#[On('apri-invio')]
public function apriInvio(int $assegnazioneId): void
{
$assegnazione = Assegnazione::aperte()->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;
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Livewire\Gruppi;
use App\Livewire\Concerns\Avvisa;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Services\GruppiServizio;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
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).
*/
class GruppiIndex extends Component
{
use Avvisa;
public string $nome = '';
public ?int $sorveglianteId = null;
public string $email = '';
/** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */
#[Locked]
public ?int $creatoId = null;
#[Locked]
public ?string $passwordTemporanea = null;
public function crea(): void
{
$this->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 un'utenza attiva (quella di un gruppo sciolto si riattiva).
$candidati = Proclamatore::attivi()
->whereNull('gruppo_servizio_id')
->whereDoesntHave('utente', fn ($q) => $q->whereNull('disattivato_at'))
->get();
return view('livewire.gruppi.gruppi-index', [
'gruppi' => $gruppi,
'opzioniSorveglianti' => Proclamatore::opzioniScelta($candidati),
'creato' => $this->creatoId ? GruppoServizio::with('sorvegliante.utente')->find($this->creatoId) : null,
]);
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Livewire\Gruppi;
use App\Livewire\Concerns\Avvisa;
use App\Livewire\Concerns\RicordaRitorno;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use App\Services\GruppiServizio;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Un gruppo di servizio, lato amministratore: membri (aggiungere/togliere),
* pacchetto (aggiungere in blocco, rimandare in reparto quelli non dati),
* nuova password temporanea per il sorvegliante, scioglimento.
*/
class GruppoShow extends Component
{
use Avvisa;
use RicordaRitorno;
public GruppoServizio $gruppo;
/** @var int[] proclamatori da aggiungere */
public array $nuoviMembri = [];
/** @var int[] territori da aggiungere al pacchetto */
public array $nuoviTerritori = [];
#[Locked]
public ?string $passwordTemporanea = null;
public function mount(GruppoServizio $gruppo): void
{
$this->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);
if ($ids === []) {
$this->avvisaErrore('Spunta almeno un proclamatore.');
return;
}
$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);
if ($ids === []) {
$this->avvisaErrore('Spunta almeno un territorio.');
return;
}
$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(),
]);
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Livewire\Gruppi;
use App\Livewire\Concerns\Avvisa;
use App\Livewire\Concerns\InviaLink;
use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Services\GruppiServizio;
use Carbon\Carbon;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* "Il mio gruppo": la sola pagina del sorvegliante di gruppo. Vede il
* pacchetto del suo gruppo e i membri; dà i territori ai membri (o a sé),
* manda il link, registra i rientri (pagina Rientra), apre la piantina e
* rimanda in reparto quelli non ancora dati. Le regole stanno in
* App\Services\GruppiServizio; qui ogni azione ricontrolla che il territorio
* o l'assegnazione siano del suo gruppo.
*/
class MioGruppo extends Component
{
use Avvisa;
use InviaLink;
/** Il territorio del pacchetto che si sta assegnando. */
#[Locked]
public ?int $territorioDaAssegnare = null;
public ?int $membroId = null;
public string $dataAssegnazione = '';
public function mount(): void
{
$this->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(),
]);
}
}

View File

@@ -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',

View File

@@ -6,6 +6,7 @@ use App\Livewire\Concerns\Avvisa;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
class ProclamatoreIndex extends Component
@@ -70,7 +71,12 @@ class ProclamatoreIndex extends Component
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
return;
}
if ($this->sorveglianteDiUnGruppo($proclamatore, 'spostarlo nel cestino')) {
return;
}
// Nel cestino non resta membro di un gruppo.
$proclamatore->update(['gruppo_servizio_id' => null]);
$proclamatore->delete();
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
}
@@ -80,10 +86,27 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage');
$proclamatore = Proclamatore::findOrFail($id);
if ($this->sorveglianteDiUnGruppo($proclamatore, 'anonimizzarlo')) {
return;
}
$proclamatore->anonimizza();
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
}
/** Il sorvegliante di un gruppo attivo si toglie sciogliendo il gruppo, non da qui. */
private function sorveglianteDiUnGruppo(Proclamatore $proclamatore, string $azione): bool
{
$gruppo = GruppoServizio::attivi()->where('sorvegliante_id', $proclamatore->id)->first();
if (! $gruppo) {
return false;
}
$this->avvisaErrore("{$proclamatore->nome_completo} è il sorvegliante del gruppo {$gruppo->nome}: per {$azione} va prima sciolto il gruppo (Gruppi di servizio).");
return true;
}
public function render()
{
// withCount rather than letting the view call

View File

@@ -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.');

View File

@@ -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);

View File

@@ -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.');

View File

@@ -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'] ?? ''));

View File

@@ -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),

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'])
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa', 'gruppo'])
->orderByDesc('assigned_at')
->get()
->groupBy(fn($a) => $a->annoTeocratico->label);

View File

@@ -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]);
});
}
/**

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Gruppo di servizio: un sorvegliante (proclamatore con un'utenza limitata
* al gruppo), i membri (proclamatori, un solo gruppo a testa) e un pacchetto
* di territori che il sorvegliante distribuisce ai membri.
*
* Il pacchetto non è un'assegnazione: un territorio del pacchetto non ancora
* dato a nessuno è "in carico al gruppo" e non entra nelle statistiche. Quando
* il sorvegliante lo dà a un membro nasce un'assegnazione normale, con
* 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.
*/
class GruppoServizio extends Model
{
use LogsActivity;
protected $table = 'gruppi_servizio';
protected $fillable = ['nome', 'sorvegliante_id', 'sciolto_at'];
protected function casts(): array
{
return ['sciolto_at' => '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');
}
}

View File

@@ -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',
];
}
@@ -95,7 +108,22 @@ class Proclamatore extends Model
$this->nome = 'Anonimo';
$this->cognome = 'Proclamatore #' . $this->id;
$this->attivo = false;
$this->gruppo_servizio_id = null;
$this->save();
$this->delete(); // soft delete
// L'utenza di un ex sorvegliante (già disattivata: il sorvegliante di
// un gruppo attivo non si anonimizza) porta nome ed email in chiaro.
$utente = $this->utente()->first();
if ($utente) {
$utente->syncRoles([]);
$utente->forceFill([
'name' => 'Anonimo',
'email' => "utente-{$utente->id}@anonimo.invalid",
'password' => \Illuminate\Support\Str::random(40),
'remember_token' => null,
'disattivato_at' => $utente->disattivato_at ?? now(),
])->save();
}
}
}

View File

@@ -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()

View File

@@ -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()
@@ -48,6 +91,32 @@ class User extends Authenticatable implements MustVerifyEmail
->dontSubmitEmptyLogs();
}
/**
* L'utenza di un sorvegliante porta nome (ed email, spesso) di un
* proclamatore: nel registro delle attività i proclamatori compaiono solo
* con l'identificativo, quindi anche qui.
*/
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
{
$proclamatoreId = $this->proclamatore_id ?? $this->getOriginal('proclamatore_id');
if (! $proclamatoreId) {
return;
}
$activity->properties = collect($activity->properties)->map(function ($valori) use ($proclamatoreId) {
if (! is_array($valori)) {
return $valori;
}
foreach (['name', 'email'] as $campo) {
if (array_key_exists($campo, $valori)) {
$valori[$campo] = "proclamatore #{$proclamatoreId}";
}
}
return $valori;
});
}
protected static function booted(): void
{
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
@@ -65,6 +134,10 @@ class User extends Authenticatable implements MustVerifyEmail
*/
public function inviaConfermaEmail(): bool
{
if ($this->isSorvegliante()) {
return true;
}
try {
$this->sendEmailVerificationNotification();
} catch (\Throwable $e) {

View File

@@ -0,0 +1,272 @@
<?php
namespace App\Services;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use InvalidArgumentException;
/**
* Le regole dei gruppi di servizio, in un posto solo: le usano le pagine
* dell'amministratore (Gruppi di servizio) e quella del sorvegliante (Il mio
* gruppo). Ogni metodo lancia InvalidArgumentException con un messaggio da
* mostrare così com'è quando una regola non è rispettata.
*/
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.
*
* @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
{
$nome = trim($nome);
if ($nome === '') {
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
}
if (! $sorvegliante->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.");
}
// Chi è stato sorvegliante di un gruppo sciolto ha già un'utenza,
// disattivata: si riattiva quella (un proclamatore, un'utenza).
$esistente = $sorvegliante->utente()->first();
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 = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri);
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
$utente->forceFill([
'name' => $sorvegliante->nome_completo,
'email' => $email,
'password' => $password,
'password_change_required' => true,
'disattivato_at' => null,
])->save();
$utente->syncRoles(['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);
// Si decide sui dati di adesso, non su quelli con cui è partita la
// pagina: nel frattempo l'amministratore può aver cambiato pacchetto
// o membri.
$territorio->refresh();
$membro->refresh();
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) {
$bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first();
if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) {
return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo.";
}
$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.');
}
}
}

View File

@@ -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([

View File

@@ -0,0 +1,78 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Gruppi di servizio (richiesta del 19 ottobre 2026).
*
* - gruppi_servizio: il gruppo, con il suo sorvegliante (un proclamatore) e
* sciolto_at quando viene sciolto (resta nello storico: le assegnazioni
* nate dal gruppo continuano a dirlo).
* - proclamatori.gruppo_servizio_id: i membri, un solo gruppo a testa.
* - territori.gruppo_servizio_id: il pacchetto del gruppo. Un territorio nel
* pacchetto non ancora dato a nessuno è "in carico al gruppo"; il pacchetto
* non è un'assegnazione e non entra nelle statistiche.
* - assegnazioni.gruppo_servizio_id: l'assegnazione è nata dal gruppo (data
* dal sorvegliante a un membro). È un'assegnazione normale a tutti gli
* effetti, segnata così.
* - users.proclamatore_id: l'utenza del sorvegliante, collegata al suo
* proclamatore. users.disattivato_at: utenza che non può più entrare (un
* gruppo sciolto disattiva quella del suo sorvegliante).
* - Ruolo "sorvegliante" con il solo permesso "gruppo.gestisci". Si crea qui
* e non solo nel seeder, che sulle installazioni esistenti non gira più.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('gruppi_servizio', function (Blueprint $table) {
$table->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();
}
};

View File

@@ -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']);
}
}

View File

@@ -79,11 +79,24 @@
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 lg:z-auto lg:shadow-none lg:border-r lg:border-gray-200 pt-16 lg:pt-0">
<nav class="mt-4 px-3 space-y-0.5">
{{-- Il sorvegliante di gruppo vede solo il suo gruppo (LimitaSorvegliante).
Il ruolo, non @can: l'amministratore passa ogni @can
(Gate::before) ma un gruppo suo non ce l'ha. --}}
@if(auth()->user()->isSorvegliante())
<a href="{{ route('mio-gruppo') }}"
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('mio-gruppo*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Il mio gruppo
</a>
@endif
@unless(auth()->user()->isSorvegliante() && auth()->user()->roles->count() === 1)
<a href="{{ route('dashboard') }}"
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('dashboard') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/></svg>
Home
</a>
@endunless
@can('territori.assign')
<a href="{{ route('territori.index') }}"
@@ -146,6 +159,11 @@
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Utenti
</a>
<a href="{{ route('gruppi.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('gruppi.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
Gruppi di servizio
</a>
<a href="{{ route('zone.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('zone.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>

View File

@@ -0,0 +1,72 @@
{{-- Finestra di invio del link dopo un'assegnazione. Logica nel trait
App\Livewire\Concerns\InviaLink; la includono Assegna e Il mio gruppo. --}}
{{-- Invio del link (InviaLink::apriInvio): blocca la pagina finché non si
risponde "inviato / non ancora". Se si chiude la scheda prima,
l'assegnazione resta segnalata con "link ancora da inviare". --}}
@if($invio)
@php
$link = $invio->shortPdfUrl();
$messaggio = $invio->messaggioLink($link);
@endphp
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-gray-900/50 p-4 sm:items-center"
role="dialog" aria-modal="true" aria-labelledby="invio-titolo" wire:key="invio-{{ $invio->id }}">
<section class="tm-da-inviare tm-da-inviare-finestra">
<h2 id="invio-titolo" class="tm-da-inviare-titolo">
Territorio N° {{ $invio->territorio?->numero }} assegnato a {{ $invio->proclamatore?->nome_completo }}
</h2>
@if($link)
<p class="text-sm text-gray-700 mb-2">Come vuoi mandare il link della piantina? Il destinatario lo scegli tu, tra i tuoi contatti.</p>
<div class="tm-da-inviare-link">
<code id="invio-link" x-data x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
</div>
{{-- Il messaggio sta in data-messaggio: dentro <x-btn> le direttive
Blade (@js) non si compilano, quindi i pulsanti lo leggono da qui. --}}
<div class="tm-da-inviare-azioni" x-data="{ copiato: false, condivisibile: !! navigator.share }"
data-messaggio="{{ $messaggio }}" data-titolo="Territorio N° {{ $invio->territorio?->numero }}">
<x-btn :variante="$canaleUsato === 'copia' ? 'primary' : 'secondary'"
x-on:click="navigator.clipboard?.writeText(document.getElementById('invio-link').textContent.trim()).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) }).catch(() => {}); $wire.canale('copia')">
<span x-show="! copiato">Copia link</span>
<span x-show="copiato" x-cloak>Copiato</span>
</x-btn>
{{-- Condividi: il menu di condivisione del telefono (WhatsApp,
Telegram, SMS, posta…), da cui si sceglie app e contatto. Solo
dove il browser lo offre. Il canale si annota a condivisione
fatta, non se la si annulla. --}}
<x-btn :variante="$canaleUsato === 'condividi' ? 'primary' : 'secondary'" x-show="condivisibile" x-cloak
x-on:click="const d = $el.closest('[data-messaggio]').dataset; navigator.share({ title: d.titolo, text: d.messaggio }).then(() => $wire.canale('condividi')).catch(() => {})">
Condividi…
</x-btn>
</div>
@if($canaleUsato)
<p class="mt-2 text-sm text-gray-700">Canale scelto: {{ \App\Models\Assegnazione::CANALI_LINK[$canaleUsato] }}.</p>
@endif
@if($erroreInvio)
<p class="mt-2 text-sm text-red-600" role="alert">{{ $erroreInvio }}</p>
@endif
<div class="tm-da-inviare-scelta">
<p>Hai inviato il link?</p>
<div>
<x-btn variante="primary" wire:click="concludiInvio(true)" :disabled="$canaleUsato === null"
:title="$canaleUsato === null ? 'Scegli prima come mandare il link' : null">Sì, inviato</x-btn>
<x-btn variante="secondary" wire:click="concludiInvio(false)">No, non ancora</x-btn>
</div>
</div>
@else
<p class="text-sm text-gray-600">
Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio.
</p>
<div class="tm-da-inviare-scelta">
<div>
<x-btn variante="secondary" wire:click="concludiInvio(false)">Ho capito</x-btn>
</div>
</div>
@endif
</section>
</div>
@endif

View File

@@ -8,76 +8,8 @@
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p>
</div>
{{-- Invio del link (Assegna::apriInvio): blocca la pagina finché non si
risponde "inviato / non ancora". Se si chiude la scheda prima,
l'assegnazione resta segnalata con "link ancora da inviare". --}}
@if($invio)
@php
$link = $invio->shortPdfUrl();
$messaggio = $invio->messaggioLink($link);
@endphp
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-gray-900/50 p-4 sm:items-center"
role="dialog" aria-modal="true" aria-labelledby="invio-titolo" wire:key="invio-{{ $invio->id }}">
<section class="tm-da-inviare tm-da-inviare-finestra">
<h2 id="invio-titolo" class="tm-da-inviare-titolo">
Territorio N° {{ $invio->territorio?->numero }} assegnato a {{ $invio->proclamatore?->nome_completo }}
</h2>
@include('livewire.assegnazioni._invio-link')
@if($link)
<p class="text-sm text-gray-700 mb-2">Come vuoi mandare il link della piantina? Il destinatario lo scegli tu, tra i tuoi contatti.</p>
<div class="tm-da-inviare-link">
<code id="invio-link" x-data x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
</div>
{{-- Il messaggio sta in data-messaggio: dentro <x-btn> le direttive
Blade (@js) non si compilano, quindi i pulsanti lo leggono da qui. --}}
<div class="tm-da-inviare-azioni" x-data="{ copiato: false, condivisibile: !! navigator.share }"
data-messaggio="{{ $messaggio }}" data-titolo="Territorio N° {{ $invio->territorio?->numero }}">
<x-btn :variante="$canaleUsato === 'copia' ? 'primary' : 'secondary'"
x-on:click="navigator.clipboard?.writeText(document.getElementById('invio-link').textContent.trim()).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) }).catch(() => {}); $wire.canale('copia')">
<span x-show="! copiato">Copia link</span>
<span x-show="copiato" x-cloak>Copiato</span>
</x-btn>
{{-- Condividi: il menu di condivisione del telefono (WhatsApp,
Telegram, SMS, posta…), da cui si sceglie app e contatto. Solo
dove il browser lo offre. Il canale si annota a condivisione
fatta, non se la si annulla. --}}
<x-btn :variante="$canaleUsato === 'condividi' ? 'primary' : 'secondary'" x-show="condivisibile" x-cloak
x-on:click="const d = $el.closest('[data-messaggio]').dataset; navigator.share({ title: d.titolo, text: d.messaggio }).then(() => $wire.canale('condividi')).catch(() => {})">
Condividi…
</x-btn>
</div>
@if($canaleUsato)
<p class="mt-2 text-sm text-gray-700">Canale scelto: {{ \App\Models\Assegnazione::CANALI_LINK[$canaleUsato] }}.</p>
@endif
@if($erroreInvio)
<p class="mt-2 text-sm text-red-600" role="alert">{{ $erroreInvio }}</p>
@endif
<div class="tm-da-inviare-scelta">
<p>Hai inviato il link?</p>
<div>
<x-btn variante="primary" wire:click="concludiInvio(true)" :disabled="$canaleUsato === null"
:title="$canaleUsato === null ? 'Scegli prima come mandare il link' : null">Sì, inviato</x-btn>
<x-btn variante="secondary" wire:click="concludiInvio(false)">No, non ancora</x-btn>
</div>
</div>
@else
<p class="text-sm text-gray-600">
Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio.
</p>
<div class="tm-da-inviare-scelta">
<div>
<x-btn variante="secondary" wire:click="concludiInvio(false)">Ho capito</x-btn>
</div>
</div>
@endif
</section>
</div>
@endif
<div x-data="{ formOpen: true }">

View File

@@ -1,8 +1,8 @@
<div>
<div class="mb-6">
<a href="{{ $ritorno ?? route('territori.show', $assegnazione->territorio) }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<a href="{{ $ritorno ?? $this->paginaPredefinita() }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
{{ $ritorno ? 'Torna indietro' : 'Torna al territorio' }}
{{ $ritorno ? 'Torna indietro' : (auth()->user()->can('territori.assign') ? 'Torna al territorio' : 'Torna al mio gruppo') }}
</a>
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1>
</div>
@@ -62,7 +62,7 @@
<div class="flex flex-wrap items-center gap-3 pt-4">
<x-btn variante="primary" type="submit">Registra rientro</x-btn>
<x-btn variante="secondary" :href="$ritorno ?? route('territori.show', $assegnazione->territorio)">Annulla</x-btn>
<x-btn variante="secondary" :href="$ritorno ?? $this->paginaPredefinita()">Annulla</x-btn>
</div>
</form>
</div>

View File

@@ -0,0 +1,73 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Gruppi di servizio</h1>
<p class="text-sm text-gray-500 mt-1">
Ogni gruppo ha un sorvegliante, che con la sua utenza vede solo il proprio gruppo e assegna ai membri i territori del pacchetto.
</p>
</div>
@if($creato && $passwordTemporanea)
<div class="mb-6 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
<p class="font-semibold">Gruppo «{{ $creato->nome }}» creato.</p>
<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">Password</dt><dd><code class="select-all">{{ $passwordTemporanea }}</code></dd>
</dl>
<div class="mt-3 flex flex-wrap gap-2">
<x-btn variante="primary" dimensione="sm" :href="route('gruppi.show', $creato)">Aggiungi membri e territori</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiPassword">Ho annotato la password</x-btn>
</div>
</div>
@endif
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl" aria-labelledby="titolo-nuovo">
<h2 id="titolo-nuovo" class="text-sm font-semibold text-gray-800 mb-3">Nuovo gruppo</h2>
<form wire:submit="crea" class="space-y-4">
<div>
<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">
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Sorvegliante *</label>
<x-scelta id="sorvegliante" wire:model="sorveglianteId" :opzioni="$opzioniSorveglianti" segnaposto="Cognome o nome" :vuoto="null" />
<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>
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" aria-labelledby="titolo-elenco">
<h2 id="titolo-elenco" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">Gruppi</h2>
<ul class="divide-y divide-gray-100">
@forelse($gruppi as $g)
<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
</p>
</div>
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">{{ $g->sciolto_at ? 'Dettaglio' : 'Gestisci' }}</x-btn>
</li>
@empty
<li class="px-4 py-8 text-center text-sm text-gray-500">Nessun gruppo di servizio.</li>
@endforelse
</ul>
</section>
</div>

View File

@@ -0,0 +1,134 @@
<div>
<div class="mb-6">
<a href="{{ $ritorno ?? route('gruppi.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
{{ $ritorno ? 'Torna indietro' : 'Gruppi di servizio' }}
</a>
<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>
@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)
<div class="mb-6 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
<p>Nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}, da cambiare al primo accesso. Si vede <strong>solo ora</strong>:</p>
<p class="mt-2"><code class="select-all text-base">{{ $passwordTemporanea }}</code></p>
</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">
Pacchetto ({{ $territori->count() }} territori)
</h2>
<ul class="divide-y divide-gray-100">
@forelse($territori as $t)
@php($a = $t->assegnazioneCorrente)
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-2" wire:key="pacchetto-{{ $t->id }}">
<span class="text-sm">
<a data-ritorno href="{{ route('territori.show', $t) }}" class="font-semibold text-indigo-600 hover:underline">N° {{ $t->numero }}</a>
<span class="text-gray-500">{{ $t->zona?->nome }}</span>
—
@if($a)
<span class="text-gray-700">a {{ $a->proclamatore?->nome_completo }} dal {{ $a->assigned_at->format('d/m/Y') }}</span>
@else
<span class="text-gray-700">da dare</span>
@endif
</span>
@unless($a)
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="rimanda({{ $t->id }})"
wire:confirm="Rimandare in reparto il territorio {{ $t->numero }}?">Rimanda in reparto</x-btn>
@endunless
</li>
@empty
<li class="px-4 py-6 text-center text-sm text-gray-500">Il pacchetto è vuoto.</li>
@endforelse
</ul>
{{-- Assegnazione in blocco: si spuntano i territori in reparto --}}
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi territori al pacchetto</h3>
@if($candidatiTerritori->isEmpty())
<p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p>
@else
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i territori in reparto"
placeholder="Filtra per numero o zona…"
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
@foreach($candidatiTerritori as $c)
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40"
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->numero . ' ' . $c->zona?->nome)) }}"
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-t-{{ $c->id }}">
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviTerritori" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
<span><strong>N° {{ $c->numero }}</strong> <span class="text-gray-500">{{ $c->zona?->nome }}</span></span>
</label>
@endforeach
</div>
<div class="mt-3">
<x-btn variante="primary" wire:click="aggiungiTerritori">Aggiungi i territori selezionati</x-btn>
</div>
@endif
</div>
</section>
{{-- ─── Membri ───────────────────────────────────────────── --}}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-membri">
<h2 id="titolo-membri" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">Membri ({{ $membri->count() }})</h2>
<ul class="divide-y divide-gray-100">
@foreach($membri as $m)
<li class="px-4 py-2.5 flex items-center justify-between gap-2 text-sm" wire:key="membro-{{ $m->id }}">
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
@if($m->id !== $gruppo->sorvegliante_id)
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="togliMembro({{ $m->id }})">Togli</x-btn>
@endif
</li>
@endforeach
</ul>
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi membri</h3>
@if($candidatiMembri->isEmpty())
<p class="text-sm text-gray-500">Tutti i proclamatori attivi fanno già parte di un gruppo.</p>
@else
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i proclamatori"
placeholder="Filtra per nome…"
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
@foreach($candidatiMembri as $c)
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40"
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->nome_completo)) }}"
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-p-{{ $c->id }}">
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviMembri" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
<span>{{ $c->nome_completo }}</span>
</label>
@endforeach
</div>
<div class="mt-3">
<x-btn variante="primary" wire:click="aggiungiMembri">Aggiungi i membri selezionati</x-btn>
</div>
@endif
</div>
</section>
{{-- ─── Sorvegliante ─────────────────────────────────────── --}}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-4" aria-labelledby="titolo-sorvegliante">
<h2 id="titolo-sorvegliante" class="text-sm font-semibold text-gray-800 mb-2">Sorvegliante</h2>
<div class="flex flex-wrap gap-2">
<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>
</div>
</section>
@endif
</div>

View File

@@ -0,0 +1,123 @@
<div>
{{-- Il mio gruppo (MioGruppo): la sola pagina del sorvegliante di gruppo.
Pensata prima per il telefono: schede una sotto l'altra, pulsanti
grandi. Copia, Condividi e ricerca vengono da elencoCercabile
(resources/js/app.js), l'invio del link dal partial _invio-link. --}}
@if(! $gruppo)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-sm text-gray-600">
Non sei il sorvegliante di un gruppo di servizio attivo.
</div>
@else
<div class="mb-5">
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
{{-- In questa vista solo blocchi php…endphp: la forma in linea, se
viene prima di un blocco, confonde il compilatore di Blade. --}}
@php $daDare = $territori->filter(fn ($t) => ! $t->assegnazioneCorrente)->count(); @endphp
<p class="text-sm text-gray-500 mt-1">
{{ $territori->count() }} {{ $territori->count() === 1 ? 'territorio' : 'territori' }} nel pacchetto ·
{{ $daDare }} da dare · {{ $membri->count() }} {{ $membri->count() === 1 ? 'membro' : 'membri' }}
</p>
</div>
@include('livewire.assegnazioni._invio-link')
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" x-data="elencoCercabile" aria-labelledby="titolo-pacchetto">
<svg aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
<symbol id="tm-i-copia" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></symbol>
<symbol id="tm-i-spunta" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></symbol>
<symbol id="tm-i-condividi" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></symbol>
<symbol id="tm-i-avviso" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></symbol>
</svg>
<div class="px-4 py-3 border-b border-gray-100 flex flex-wrap items-center justify-between gap-2">
<h2 id="titolo-pacchetto" class="text-sm font-semibold text-gray-800">Territori del pacchetto</h2>
@if($territori->count() > 5)
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto"
placeholder="Cerca numero, zona o nome…"
class="w-full sm:w-64 rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
@endif
</div>
<div class="divide-y divide-gray-100">
@forelse($territori as $t)
@php
$a = $t->assegnazioneCorrente;
$pdfUrl = $a?->shortPdfUrl();
$cerca = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii(implode(' ', array_filter([$t->numero, $t->zona?->nome, $a?->proclamatore?->nome_completo]))));
@endphp
<div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
<div class="tm-assegnato-testa">
<span><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif</span>
@if($a)
<span class="tm-assegnato-info"><b>{{ $a->proclamatore?->nome_completo }}</b> · dal {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
@else
<span class="tm-assegnato-info"><b>Da dare</b></span>
@endif
</div>
@if($a?->link_in_sospeso)
<div class="tm-assegnato-sospeso" role="status"><svg aria-hidden="true"><use href="#tm-i-avviso"/></svg> Link ancora da inviare <x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="apriInvio({{ $a->id }})">Invia link</x-btn></div>
@endif
@if($a)
{{-- Assegnato: rientro, link (copia / condividi), stato dell'invio --}}
<div class="tm-assegnato-link" @if($pdfUrl) data-link data-id="{{ $a->id }}" data-messaggio="{{ $a->messaggioLink($pdfUrl) }}" data-titolo="Territorio N° {{ $t->numero }}" @endif>
<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a)">Rientro</x-btn>
@if($pdfUrl)
<code x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="tm-copia" x-on:click="copia($el)" title="Copia link" aria-label="Copia il link della piantina"><svg aria-hidden="true"><use href="#tm-i-copia"/></svg><svg aria-hidden="true"><use href="#tm-i-spunta"/></svg></x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona x-show="condivisibile" x-cloak x-on:click="condividi($el)" title="Condividi il link" aria-label="Condividi il link della piantina"><svg aria-hidden="true"><use href="#tm-i-condividi"/></svg></x-btn>
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" wire:click="toggleLinkSent({{ $a->id }})" aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}">{{ $a->link_sent ? 'Link inviato' : 'Non inviato' }}</x-btn>
@endif
</div>
@elseif($territorioDaAssegnare === $t->id)
{{-- Da dare: a chi e da quando --}}
<div class="mt-3 rounded-lg border border-indigo-100 bg-indigo-50/50 p-3 space-y-3">
<div>
<label for="membro-{{ $t->id }}" class="block text-sm font-medium text-gray-700 mb-1">A chi lo dai?</label>
<x-scelta id="membro-{{ $t->id }}" wire:model="membroId" :opzioni="$opzioniMembri" segnaposto="Cognome o nome" :vuoto="null" />
@error('membroId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="data-{{ $t->id }}" class="block text-sm font-medium text-gray-700 mb-1">Data</label>
<input wire:model="dataAssegnazione" type="date" id="data-{{ $t->id }}" max="{{ now()->format('Y-m-d') }}"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
@error('dataAssegnazione') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap gap-2">
<x-btn variante="primary" wire:click="assegna">Assegna</x-btn>
<x-btn variante="ghost" wire:click="annullaAssegnazione">Annulla</x-btn>
</div>
</div>
@else
<div class="tm-assegnato-link">
<x-btn variante="primary" dimensione="sm" wire:click="apriAssegnazione({{ $t->id }})">Assegna</x-btn>
@if($t->pdf_path)
<x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('mio-gruppo.pdf', $t)" target="_blank" rel="noopener">Piantina</x-btn>
@endif
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="rimanda({{ $t->id }})"
wire:confirm="Rimandare in reparto il territorio {{ $t->numero }}? Uscirà dal pacchetto del gruppo.">Rimanda in reparto</x-btn>
</div>
@endif
</div>
@empty
<p class="px-4 py-8 text-center text-sm text-gray-500">Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo.</p>
@endforelse
<p x-show="nessuno" x-cloak class="px-4 py-8 text-center text-sm text-gray-400">Nessun risultato per «<span x-text="q"></span>».</p>
</div>
</section>
<section class="mt-5 bg-white rounded-xl shadow-sm border border-gray-200 p-4" aria-labelledby="titolo-membri">
<h2 id="titolo-membri" class="text-sm font-semibold text-gray-800 mb-2">Membri del gruppo</h2>
<ul class="text-sm text-gray-700 space-y-1">
@foreach($membri as $m)
@php $quanti = $territori->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp
<li class="flex justify-between gap-3">
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
<span class="text-gray-500">{{ $quanti }} {{ $quanti === 1 ? 'territorio' : 'territori' }}</span>
</li>
@endforeach
</ul>
</section>
@endif
</div>

View File

@@ -16,10 +16,10 @@
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Dati trattati</h2>
<ul class="list-disc list-inside space-y-1">
<li><strong>Proclamatori</strong>: solo nome e cognome, cifrati nel database ({{ $cifratura }}), e se sono attivi. Nessun numero di telefono né indirizzo email.</li>
<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), password conservata solo come impronta non reversibile, ruolo.</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>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.</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 (email 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.</li>
<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>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

@@ -87,7 +87,7 @@
</div>
</div>
</td>
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</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="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

@@ -129,6 +129,12 @@
</div>
</td>
<td class="px-4 py-2 cell-actions">
@if($user->proclamatore_id)
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo. --}}
<span class="text-xs text-gray-500">
{{ $user->disattivato_at ? 'Disattivata (gruppo sciolto)' : 'Sorvegliante: si gestisce da Gruppi di servizio' }}
</span>
@else
<div class="flex gap-2">
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
@unless($user->hasVerifiedEmail())
@@ -145,6 +151,7 @@
Elimina
</x-btn>
</div>
@endif
</td>
@endif
</tr>

View File

@@ -46,6 +46,7 @@
<select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Tutti gli stati</option>
<option value="in_reparto">In reparto</option>
<option value="in_gruppo">In un gruppo di servizio</option>
<option value="prioritari">Prioritari</option>
<option value="assegnato">Assegnato</option>
<option value="da_rientrare">Da rientrare</option>
@@ -126,12 +127,13 @@
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
{{ match($stato) {
'in_reparto' => 'bg-green-100 text-green-800',
'in_gruppo' => 'bg-purple-100 text-purple-800',
'assegnato' => 'bg-blue-100 text-blue-800',
'da_rientrare' => 'bg-red-100 text-red-800',
'inattivo' => 'bg-gray-100 text-gray-600',
default => 'bg-gray-100 text-gray-600'
} }}">
{{ str_replace('_', ' ', ucfirst($stato)) }}
{{ $territorio->stato_etichetta }}
</span>
@if($territorio->is_prioritario)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">

View File

@@ -55,12 +55,13 @@
<span class="inline-flex items-center mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium
{{ match($stato) {
'in_reparto' => 'bg-green-100 text-green-800',
'in_gruppo' => 'bg-purple-100 text-purple-800',
'assegnato' => 'bg-blue-100 text-blue-800',
'da_rientrare' => 'bg-red-100 text-red-800',
'inattivo' => 'bg-gray-100 text-gray-600',
default => 'bg-gray-100 text-gray-600'
} }}">
{{ str_replace('_', ' ', ucfirst($stato)) }}
{{ $territorio->stato_etichetta }}
</span>
@if($territorio->is_prioritario)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">★ Prioritario</span>
@@ -177,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' }}</td>
<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="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

@@ -24,6 +24,9 @@ use App\Livewire\Proclamatori\ProclamatoreEdit;
use App\Livewire\Proclamatori\ProclamatoreCestino;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\Rientra;
use App\Livewire\Gruppi\GruppiIndex;
use App\Livewire\Gruppi\GruppoShow;
use App\Livewire\Gruppi\MioGruppo;
use App\Livewire\Campagne\CampagnaIndex;
use App\Livewire\Campagne\CampagnaCreate;
use App\Livewire\Campagne\CampagnaShow;
@@ -163,10 +166,20 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware('permission:territori.assign')->group(function () {
Route::get('assegnazioni/assegna/{territorioId?}', Assegna::class)->name('assegnazioni.assegna');
});
Route::middleware('permission:territori.return')->group(function () {
// Il rientro lo registra anche il sorvegliante di gruppo, ma solo per il
// suo gruppo: il controllo fine è in Rientra::autorizza().
Route::middleware('permission:territori.return|gruppo.gestisci')->group(function () {
Route::get('assegnazioni/{assegnazione}/rientra', Rientra::class)->name('assegnazioni.rientra');
});
// Il mio gruppo: la sola pagina del sorvegliante di gruppo (vedi anche
// il middleware LimitaSorvegliante).
Route::middleware('permission:gruppo.gestisci')->group(function () {
Route::get('mio-gruppo', MioGruppo::class)->name('mio-gruppo');
Route::get('mio-gruppo/territori/{territorio}/pdf', [TerritorioFileController::class, 'pdfGruppo'])
->name('mio-gruppo.pdf');
});
// Campagne — visualizzazione per chi ha campagne.manage; sola lettura per i
// non-admin.
Route::middleware('permission:campagne.manage')->group(function () {
@@ -200,6 +213,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware('permission:settings.manage')->group(function () {
Route::get('impostazioni', SettingsEdit::class)->name('settings.edit');
Route::get('utenti', UsersIndex::class)->name('users.index');
Route::get('gruppi', GruppiIndex::class)->name('gruppi.index');
Route::get('gruppi/{gruppo}', GruppoShow::class)->name('gruppi.show');
Route::get('zone', ZoneIndex::class)->name('zone.index');
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');

View File

@@ -0,0 +1,459 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\Rientra;
use App\Livewire\Gruppi\GruppiIndex;
use App\Livewire\Gruppi\GruppoShow;
use App\Livewire\Gruppi\MioGruppo;
use App\Livewire\Settings\UsersIndex;
use App\Livewire\Settings\XmlExchange;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use App\Services\GruppiServizio;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Gruppi di servizio (19 ottobre 2026): un sorvegliante con un'utenza
* limitata al proprio gruppo distribuisce ai membri i territori del pacchetto.
*/
class GruppiServizioTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Storage::fake('public');
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
private function proclamatore(string $cognome, string $nome = 'Test'): Proclamatore
{
return Proclamatore::create(['nome' => $nome, 'cognome' => $cognome, 'attivo' => true]);
}
private function territorio(string $numero, bool $pdf = true): Territorio
{
if ($pdf) {
Storage::disk('public')->put("territori-pdf/t{$numero}.pdf", '%PDF-1.4');
}
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => $pdf ? "territori-pdf/t{$numero}.pdf" : null]);
}
/** Gruppo "Zisa": sorvegliante Rossi, membri Verdi e Bianchi, pacchetto 12, 14, 35. */
private function gruppo(): array
{
$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]);
$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();
// 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();
return compact('gruppo', 'password', 'rossi', 'verdi', 'bianchi', 'territori', 'sorvegliante');
}
// ─── Creazione ──────────────────────────────────────────────
public function test_creare_un_gruppo_da_un_utenza_limitata_al_sorvegliante(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$pagina = Livewire::test(GruppiIndex::class)
->set('nome', 'Zisa')
->set('sorveglianteId', $rossi->id)
->set('email', 'mario@example.test')
->call('crea')
->assertHasNoErrors();
$gruppo = GruppoServizio::sole();
$utente = User::where('email', 'mario@example.test')->sole();
$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);
$this->assertTrue($utente->hasRole('sorvegliante'));
$this->assertTrue($utente->password_change_required);
// Nessuna conferma dell'email per il sorvegliante (TestCase conferma
// gli utenti creati: qui si toglie).
$utente->forceFill(['email_verified_at' => null])->save();
$this->assertTrue($utente->fresh()->hasVerifiedEmail());
// La password temporanea si vede una volta, ed è quella vera.
$password = $pagina->get('passwordTemporanea');
$this->assertNotEmpty($password);
$pagina->assertSee($password);
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
}
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');
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiMembri($noce, [$verdi->id]);
}
public function test_il_ruolo_sorvegliante_non_si_da_dalla_pagina_utenti(): void
{
['sorvegliante' => $sorvegliante] = $this->gruppo();
$utenti = Livewire::test(UsersIndex::class);
$this->assertNotContains('sorvegliante', $utenti->get('availableRoles'));
$utenti->call('startEdit', $sorvegliante->id)->assertSet('editingUserId', null);
$utenti->call('deleteUser', $sorvegliante->id);
$this->assertNotNull($sorvegliante->fresh());
}
// ─── Pacchetto ──────────────────────────────────────────────
public function test_il_pacchetto_non_e_un_assegnazione_e_non_e_in_reparto(): void
{
['territori' => $territori] = $this->gruppo();
$libero = $this->territorio('99');
$this->assertSame(0, Assegnazione::count(), 'Il pacchetto non crea assegnazioni: medie e statistiche non cambiano');
$this->assertSame([$libero->id], Territorio::inReparto()->pluck('id')->all());
$this->assertSame('in_gruppo', $territori[0]->fresh()->stato);
$this->assertSame('In gruppo Zisa', $territori[0]->fresh()->stato_etichetta);
}
public function test_dalla_pagina_assegna_un_territorio_del_pacchetto_non_si_assegna(): void
{
['territori' => $territori] = $this->gruppo();
Livewire::test(Assegna::class)
->set('territorio_id', $territori[0]->id)
->set('proclamatore_id', $this->proclamatore('Neri')->id)
->call('save')
->assertHasErrors('territorio_id');
$this->assertSame(0, Assegnazione::count());
}
public function test_un_territorio_assegnato_non_entra_nel_pacchetto(): void
{
['gruppo' => $gruppo] = $this->gruppo();
$t = $this->territorio('50');
Assegnazione::create([
'territorio_id' => $t->id, 'proclamatore_id' => $this->proclamatore('Neri')->id,
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
]);
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$t->id]);
}
// ─── Accesso del sorvegliante ───────────────────────────────
public function test_il_sorvegliante_vede_solo_il_suo_gruppo_e_il_profilo(): void
{
['sorvegliante' => $sorvegliante] = $this->gruppo();
$this->flushSession();
$this->actingAs($sorvegliante);
$this->get(route('dashboard'))->assertRedirect(route('mio-gruppo'));
$this->get(route('mio-gruppo'))->assertOk()->assertSee('Gruppo Zisa')->assertSee('N° 12');
$this->get(route('profile'))->assertOk();
foreach (['territori.index', 'proclamatori.index', 'registro.index', 'campagne.index', 'assegnazioni.assegna', 'users.index', 'gruppi.index', 'privacy'] as $rotta) {
$this->get(route($rotta))->assertForbidden();
}
}
public function test_l_amministratore_non_ha_la_voce_il_mio_gruppo(): void
{
$this->gruppo();
// Passa ogni @can (Gate::before), ma un gruppo suo non ce l'ha.
$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
{
$this->gruppo();
$this->flushSession();
auth()->logout();
$password = 'una-password-nuova-1';
User::where('email', 'mario@example.test')->sole()->forceFill(['password' => $password])->save();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password])
->assertRedirect(route('mio-gruppo'));
}
// ─── Il sorvegliante al lavoro ──────────────────────────────
public function test_il_sorvegliante_assegna_a_un_membro_e_nasce_un_assegnazione_normale(): void
{
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori, 'gruppo' => $gruppo] = $this->gruppo();
$this->flushSession();
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $territori[0]->id)
->set('membroId', $verdi->id)
->call('assegna')
->assertHasNoErrors()
// Come dalla pagina Assegna: si apre l'invio del link.
->assertSee('Hai inviato il link?');
$a = Assegnazione::sole();
$this->assertSame($verdi->id, $a->proclamatore_id);
$this->assertSame($gruppo->id, $a->gruppo_servizio_id, 'Tracciato che viene dal gruppo');
$this->assertTrue($a->link_da_inviare);
$this->assertSame($gruppo->id, $territori[0]->fresh()->gruppo_servizio_id, 'Resta nel pacchetto finché non rientra');
$this->assertSame('assegnato', $territori[0]->fresh()->stato);
}
public function test_il_sorvegliante_puo_tenere_un_territorio_per_se(): void
{
['sorvegliante' => $s, 'rossi' => $rossi, 'territori' => $territori] = $this->gruppo();
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $territori[1]->id)
->set('membroId', $rossi->id)
->call('assegna')
->assertHasNoErrors();
$this->assertSame($rossi->id, Assegnazione::sole()->proclamatore_id);
}
public function test_solo_ai_membri_e_solo_i_territori_del_pacchetto(): void
{
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
$estraneo = $this->proclamatore('Neri');
$fuori = $this->territorio('77');
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $territori[0]->id)
->set('membroId', $estraneo->id)
->call('assegna')
->assertHasErrors('membroId');
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $fuori->id)
->assertForbidden();
$this->assertSame(0, Assegnazione::count());
}
public function test_il_rientro_lo_registra_il_sorvegliante_e_il_territorio_torna_in_reparto(): void
{
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori, 'gruppo' => $gruppo] = $this->gruppo();
$a = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now()->subDays(3));
$this->flushSession();
$this->actingAs($s)->get(route('assegnazioni.rientra', $a))->assertOk();
Livewire::actingAs($s)->test(Rientra::class, ['assegnazione' => $a])
->call('save')
->assertRedirect(route('mio-gruppo'));
$this->assertNotNull($a->fresh()->returned_at);
$this->assertNull($territori[0]->fresh()->gruppo_servizio_id, 'Esce dal pacchetto');
$this->assertSame('in_reparto', $territori[0]->fresh()->stato);
}
public function test_il_sorvegliante_non_registra_rientri_di_altri(): void
{
['sorvegliante' => $s] = $this->gruppo();
$altro = Assegnazione::create([
'territorio_id' => $this->territorio('80')->id, 'proclamatore_id' => $this->proclamatore('Neri')->id,
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
]);
$this->flushSession();
$this->actingAs($s)->get(route('assegnazioni.rientra', $altro))->assertForbidden();
}
public function test_il_sorvegliante_rimanda_in_reparto_quelli_non_dati(): void
{
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
Livewire::actingAs($s)->test(MioGruppo::class)->call('rimanda', $territori[2]->id);
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id);
$this->assertSame('in_reparto', $territori[2]->fresh()->stato);
}
public function test_la_piantina_del_pacchetto_si_apre_solo_per_il_proprio_gruppo(): void
{
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
$fuori = $this->territorio('88');
$this->flushSession();
$this->actingAs($s)->get(route('mio-gruppo.pdf', $territori[0]))->assertOk();
$this->actingAs($s)->get(route('mio-gruppo.pdf', $fuori))->assertForbidden();
}
// ─── Scioglimento ───────────────────────────────────────────
public function test_togliere_il_ruolo_scioglie_il_gruppo(): void
{
['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');
$gruppo->refresh();
$this->assertNotNull($gruppo->sciolto_at);
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione normale.
$this->assertNull($territori[1]->fresh()->gruppo_servizio_id);
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id);
$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->assertTrue($s->fresh()->isDisattivato());
$this->assertFalse($s->fresh()->hasRole('sorvegliante'));
// Per ripartire serve un gruppo nuovo.
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$territori[1]->id]);
}
public function test_un_utenza_disattivata_non_entra_e_viene_buttata_fuori(): void
{
['gruppo' => $gruppo, 'sorvegliante' => $s] = $this->gruppo();
$password = 'una-password-nuova-1';
$s->forceFill(['password' => $password])->save();
app(GruppiServizio::class)->sciogli($gruppo);
$this->flushSession();
auth()->logout();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password])
->assertSessionHasErrors('email');
$this->assertGuest();
$this->actingAs($s->fresh())->get(route('profile'))->assertRedirect(route('login'));
}
public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo_con_la_stessa_utenza(): void
{
['gruppo' => $vecchio, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
app(GruppiServizio::class)->sciogli($vecchio);
$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');
$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->assertTrue($utente->password_change_required);
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
$this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id);
}
// ─── Proclamatori: cestino, anonimizzazione, registro ───────
public function test_il_sorvegliante_di_un_gruppo_attivo_non_si_cestina_ne_si_anonimizza(): void
{
['rossi' => $rossi, 'verdi' => $verdi] = $this->gruppo();
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
->call('deleteProclamatore', $rossi->id)
->call('anonimizza', $rossi->id);
$this->assertFalse($rossi->fresh()->trashed());
$this->assertSame('Rossi', $rossi->fresh()->cognome);
// Un membro qualsiasi sì, e lascia il gruppo.
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('deleteProclamatore', $verdi->id);
$this->assertTrue($verdi->fresh()->trashed());
$this->assertNull($verdi->fresh()->gruppo_servizio_id);
}
public function test_anonimizzare_un_ex_sorvegliante_anonimizza_anche_la_sua_utenza(): void
{
['gruppo' => $gruppo, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
app(GruppiServizio::class)->sciogli($gruppo);
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id);
$utente = $s->fresh();
$this->assertSame('Anonimo', $utente->name);
$this->assertStringEndsWith('@anonimo.invalid', $utente->email);
$this->assertTrue($utente->isDisattivato());
$this->assertCount(0, $utente->roles);
}
public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void
{
['sorvegliante' => $s] = $this->gruppo();
$s->update(['email' => 'mario.rossi@example.test']);
$voci = \Spatie\Activitylog\Models\Activity::where('subject_type', User::class)->where('subject_id', $s->id)->get();
$this->assertNotEmpty($voci);
foreach ($voci as $voce) {
$json = json_encode($voce->properties);
$this->assertStringNotContainsString('Rossi', $json);
$this->assertStringNotContainsString('mario', $json);
}
}
// ─── Backup e azzeramento ───────────────────────────────────
public function test_l_azzeramento_toglie_anche_gruppi_e_utenza_del_sorvegliante(): void
{
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori, 'sorvegliante' => $s] = $this->gruppo();
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
$stato = app(\App\Services\BackupOperationState::class);
$stato->initialize('azzeramento-gruppi', 'azzeramento', 'In attesa');
(new \App\Jobs\ResetApplication('azzeramento-gruppi', $this->admin->id))->handle($stato, app(\App\Services\BackupArchive::class));
$this->assertSame('completed', $stato->get('azzeramento-gruppi')['status'] ?? null, json_encode($stato->get('azzeramento-gruppi')));
$this->assertSame(0, GruppoServizio::count());
$this->assertNull(User::find($s->id));
}
public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void
{
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$g = GruppoServizio::sole();
$this->assertSame('Zisa', $g->nome);
$this->assertSame('Rossi Mario', $g->sorvegliante->nome_completo);
$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);
}
}