++ 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>
This commit is contained in:
60
HANDOFF.md
60
HANDOFF.md
@@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
|
||||
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
||||
può dare: perché le cose sono come sono, e cosa resta aperto.
|
||||
|
||||
Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
|
||||
Ultimo aggiornamento: **19 ottobre 2026** (gruppi di servizio, solo su dev) — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
|
||||
|
||||
---
|
||||
|
||||
@@ -127,6 +127,64 @@ email di prova e di conferma arrivate, link aperto senza login, account
|
||||
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
|
||||
e dati di prova rimossi dopo.
|
||||
|
||||
### Gruppi di servizio (19 ottobre 2026, solo su dev)
|
||||
|
||||
Richiesta dell'utente: un **gruppo di servizio** con un sorvegliante che,
|
||||
entrato con una sua utenza, distribuisce ai membri un pacchetto di territori
|
||||
e non vede nient'altro. Scelte dell'utente:
|
||||
|
||||
- Il gruppo ha **membri** (proclamatori); un proclamatore sta in **un solo**
|
||||
gruppo; il sorvegliante è anche membro e assegna solo ai membri, a sé
|
||||
compreso.
|
||||
- Il **pacchetto non è un'assegnazione**: "la media non deve essere alterata".
|
||||
I territori del pacchetto sono "In gruppo …" (stato `in_gruppo`), fuori da
|
||||
"Da assegnare" e dalla pagina *Assegna* (`Territorio::scopeInReparto`
|
||||
esclude `gruppo_servizio_id`).
|
||||
- Quando il sorvegliante dà un territorio nasce un'**assegnazione normale**,
|
||||
tracciata con `assegnazioni.gruppo_servizio_id` ("via gruppo …" nel
|
||||
registro e nella scheda del territorio).
|
||||
- Al **rientro** il territorio esce dal pacchetto e torna in reparto
|
||||
(hook `updated` in `Assegnazione`, vale da qualunque pagina).
|
||||
- Il sorvegliante: assegna, manda il link (copia/condividi), registra i
|
||||
rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e
|
||||
pacchetti li gestisce **solo l'amministratore**.
|
||||
- Utenza: email e password temporanea da cambiare al primo accesso, **senza
|
||||
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il
|
||||
sorvegliante; anche cambiandola dal profilo).
|
||||
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
|
||||
non dati in reparto, membri liberi, utenza senza ruolo e disattivata
|
||||
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da
|
||||
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I
|
||||
territori già dati restano ai membri. Si riparte con un gruppo nuovo.
|
||||
|
||||
Dove sta:
|
||||
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
|
||||
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e
|
||||
assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`,
|
||||
permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà
|
||||
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`.
|
||||
- Regole in `App\Services\GruppiServizio` (crea, aggiungiMembri, togliMembro,
|
||||
aggiungiAlPacchetto, rimandaInReparto, assegna, sciogli); i componenti
|
||||
`Gruppi\GruppiIndex`, `Gruppi\GruppoShow` (amministratore) e
|
||||
`Gruppi\MioGruppo` (sorvegliante) le chiamano soltanto.
|
||||
- `LimitaSorvegliante` (gruppo `web`): chi ha **solo** il ruolo sorvegliante
|
||||
raggiunge `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo,
|
||||
controllo in `Rientra::autorizza`), profilo e cambio password; la Home lo
|
||||
rimanda al gruppo; il resto è 403.
|
||||
- L'invio del link è diventato il trait `Concerns\InviaLink` + il partial
|
||||
`assegnazioni/_invio-link`, usati da *Assegna* e da *Il mio gruppo*.
|
||||
- Il ruolo sorvegliante non compare nella pagina *Utenti* e le utenze legate a
|
||||
un proclamatore lì non si modificano né si eliminano: passano dal gruppo.
|
||||
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
|
||||
l'amministratore passa ogni `@can` (`Gate::before`).
|
||||
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
|
||||
assegnazioni e il legame utenza ↔ proclamatore.
|
||||
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
|
||||
del proclamatore).
|
||||
- Test: `tests/Feature/GruppiServizioTest.php`.
|
||||
|
||||
Non provato nel browser. **In produzione non c'è**: serve l'ok dell'utente.
|
||||
|
||||
### Proclamatori senza contatti; il link si copia o si condivide (19 ottobre 2026)
|
||||
|
||||
Decisione dell'utente, dopo il rilascio: i proclamatori **non hanno più email
|
||||
|
||||
78
README.md
78
README.md
@@ -736,6 +736,30 @@ docker compose --profile dev up -d --build
|
||||
- **Dopo una conferma o un annulla si torna alla pagina di partenza**, con i
|
||||
suoi filtri, ricerca, ordinamento e pagina (aperta dal menu: come prima)
|
||||
|
||||
### Gruppi di servizio
|
||||
- Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome,
|
||||
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
|
||||
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
|
||||
anche membro
|
||||
- Creando il gruppo il sorvegliante riceve un'**utenza**: l'email indicata e
|
||||
una password temporanea, mostrata una volta sola e da cambiare al primo
|
||||
accesso. **Nessuna conferma dell'email**. Se la perde, l'amministratore ne
|
||||
genera un'altra dalla pagina del gruppo
|
||||
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto.
|
||||
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
|
||||
cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
|
||||
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo
|
||||
(ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne
|
||||
nasce un'**assegnazione normale**, che conta come le altre ed è segnata
|
||||
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
|
||||
apre la piantina e rimanda in reparto quelli non ancora dati
|
||||
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
|
||||
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante: i territori non
|
||||
ancora dati tornano in reparto, i membri si liberano, l'utenza viene
|
||||
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello
|
||||
storico. I territori già dati restano ai membri. Per ripartire si crea un
|
||||
gruppo nuovo
|
||||
|
||||
### Campagne
|
||||
- CRUD con date di inizio e fine
|
||||
- Stato automatico: attiva, futura o conclusa
|
||||
@@ -771,24 +795,31 @@ docker compose --profile dev up -d --build
|
||||
|
||||
## Ruoli e permessi
|
||||
|
||||
Tre ruoli con permessi granulari (spatie/laravel-permission):
|
||||
Quattro ruoli con permessi granulari (spatie/laravel-permission):
|
||||
|
||||
| Permesso | Amministratore | Assistente | Operatore |
|
||||
|-----------------------|:--------------:|:----------:|:---------:|
|
||||
| `settings.manage` | ✅ | | |
|
||||
| `territori.manage` | ✅ | | |
|
||||
| `proclamatori.manage` | ✅ | ✅ | |
|
||||
| `campagne.manage` | ✅ | ✅ | |
|
||||
| `registro.view` | ✅ | ✅ | |
|
||||
| `registro.export` | ✅ | | |
|
||||
| `territori.assign` | ✅ | ✅ | ✅ |
|
||||
| `territori.return` | ✅ | ✅ | ✅ |
|
||||
| `audit.view` | ✅ | | |
|
||||
| `audit.export` | ✅ | | |
|
||||
| Permesso | Amministratore | Assistente | Operatore | Sorvegliante |
|
||||
|-----------------------|:--------------:|:----------:|:---------:|:------------:|
|
||||
| `settings.manage` | ✅ | | | |
|
||||
| `territori.manage` | ✅ | | | |
|
||||
| `proclamatori.manage` | ✅ | ✅ | | |
|
||||
| `campagne.manage` | ✅ | ✅ | | |
|
||||
| `registro.view` | ✅ | ✅ | | |
|
||||
| `registro.export` | ✅ | | | |
|
||||
| `territori.assign` | ✅ | ✅ | ✅ | |
|
||||
| `territori.return` | ✅ | ✅ | ✅ | |
|
||||
| `audit.view` | ✅ | | | |
|
||||
| `audit.export` | ✅ | | | |
|
||||
| `gruppo.gestisci` | | | | ✅ |
|
||||
|
||||
L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
|
||||
Il menu laterale mostra solo le voci per cui l'utente ha i permessi.
|
||||
|
||||
Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando
|
||||
un gruppo di servizio e sparisce sciogliendolo. Chi ha solo quel ruolo è
|
||||
confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro
|
||||
dei territori del gruppo e al profilo; le sue azioni ricontrollano che
|
||||
territorio e assegnazione siano del suo gruppo.
|
||||
|
||||
I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi
|
||||
una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
|
||||
@@ -838,15 +869,16 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
|
||||
| Tabella | Descrizione |
|
||||
|-------------------|--------------------------------------------------------------|
|
||||
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova |
|
||||
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova; `proclamatore_id` lega l'utenza del sorvegliante al suo proclamatore, `disattivato_at` la chiude allo scioglimento del gruppo |
|
||||
| `settings` | Configurazione singleton |
|
||||
| `zone` | Zone territoriali |
|
||||
| `tipologie` | Tipologie di territorio |
|
||||
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti) |
|
||||
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
|
||||
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
|
||||
| `gruppi_servizio` | Gruppi di servizio: nome, sorvegliante, `sciolto_at` (sciolti restano per lo storico) |
|
||||
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo |
|
||||
| `anni_teocratici` | Periodi settembre → agosto |
|
||||
| `campagne` | Periodi speciali con date e descrizione |
|
||||
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`) |
|
||||
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo |
|
||||
| `activity_log` | Audit trail |
|
||||
|
||||
**Vincoli e indici principali**
|
||||
@@ -873,7 +905,8 @@ Calcolato dinamicamente, non memorizzato:
|
||||
|
||||
| Stato | Condizione |
|
||||
|----------------|-----------------------------------------------------|
|
||||
| `in_reparto` | Attivo, nessuna assegnazione aperta |
|
||||
| `in_reparto` | Attivo, nessuna assegnazione aperta, fuori dai pacchetti dei gruppi |
|
||||
| `in_gruppo` | Nel pacchetto di un gruppo di servizio, non ancora dato a un membro |
|
||||
| `assegnato` | Ha un'assegnazione con `returned_at` nullo |
|
||||
| `da_rientrare` | Assegnato da più giorni della soglia configurata |
|
||||
| `inattivo` | Contrassegnato come non attivo |
|
||||
@@ -897,6 +930,15 @@ lo decide l'operatore con la spunta. Se più campagne corrispondono viene
|
||||
proposta la più recente. I territori assegnati durante la campagna e non
|
||||
ancora rientrati compaiono nel dettaglio come "in corso".
|
||||
|
||||
### Gruppi di servizio e statistiche
|
||||
Mettere un territorio nel pacchetto di un gruppo **non** è un'assegnazione:
|
||||
non crea righe in `assegnazioni`, quindi non tocca medie, durate, percorrenza
|
||||
delle campagne né il registro. Conta solo quando il sorvegliante lo dà a un
|
||||
membro: da lì è un'assegnazione come le altre (con `gruppo_servizio_id` per
|
||||
sapere da dove viene). Il tempo passato nel pacchetto prima di essere dato non
|
||||
viene contato. La priorità automatica guarda l'ultima assegnazione, come
|
||||
sempre.
|
||||
|
||||
---
|
||||
|
||||
## Architettura
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
68
app/Http/Middleware/LimitaSorvegliante.php
Normal file
68
app/Http/Middleware/LimitaSorvegliante.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
namespace App\Livewire\Assegnazioni;
|
||||
|
||||
use App\Livewire\Concerns\InviaLink;
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
@@ -15,25 +14,7 @@ use Illuminate\Support\Facades\DB;
|
||||
class Assegna extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
// ─── Invio del link ─────────────────────────────────────────
|
||||
//
|
||||
// Dopo l'assegnazione non si torna subito alla pagina: si sceglie il
|
||||
// canale (copia link o Condividi: il destinatario lo sceglie chi usa
|
||||
// l'app dai suoi contatti, i proclamatori non hanno numero né email),
|
||||
// si dice se il link è partito, e solo allora si va avanti. Se la
|
||||
// procedura si interrompe, l'assegnazione resta con link_da_inviare e lo
|
||||
// si segnala dove compare.
|
||||
|
||||
/** L'assegnazione di cui si sta mandando il link. */
|
||||
#[Locked]
|
||||
public ?int $invioId = null;
|
||||
|
||||
/** Il canale usato in questo invio (Assegnazione::CANALI_LINK). */
|
||||
#[Locked]
|
||||
public ?string $canaleUsato = null;
|
||||
|
||||
public string $erroreInvio = '';
|
||||
use InviaLink;
|
||||
|
||||
public ?int $territorio_id = null;
|
||||
public ?int $proclamatore_id = null;
|
||||
@@ -83,6 +64,12 @@ class Assegna extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
// Nel pacchetto di un gruppo di servizio: lo assegna il sorvegliante.
|
||||
if ($territorio->gruppo_servizio_id) {
|
||||
$this->addError('territorio_id', "Il territorio {$territorio->numero} è nel pacchetto del gruppo {$territorio->gruppo?->nome}: lo assegna il sorvegliante, oppure va prima tolto dal pacchetto.");
|
||||
return;
|
||||
}
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
||||
if (!$proclamatore->attivo) {
|
||||
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||
@@ -140,91 +127,16 @@ class Assegna extends Component
|
||||
$this->apriInvio($assegnazione->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apre l'invio del link: subito dopo l'assegnazione, o da un'assegnazione
|
||||
* rimasta in sospeso ("Invia link" nell'elenco, che è un componente a sé
|
||||
* e lo chiede con l'evento apri-invio).
|
||||
*/
|
||||
#[On('apri-invio')]
|
||||
public function apriInvio(int $assegnazioneId): void
|
||||
// ─── Invio del link (trait InviaLink) ──────────────────────
|
||||
|
||||
protected function puoInviare(Assegnazione $assegnazione): bool
|
||||
{
|
||||
$this->authorize('territori.assign');
|
||||
|
||||
$assegnazione = Assegnazione::aperte()->find($assegnazioneId);
|
||||
if (! $assegnazione) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->invioId = $assegnazione->id;
|
||||
$this->canaleUsato = null;
|
||||
$this->erroreInvio = '';
|
||||
return auth()->user()->can('territori.assign');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copia link o Condividi: l'invio vero lo fa chi usa l'app (il
|
||||
* destinatario lo sceglie lui dai suoi contatti), qui si annota il canale.
|
||||
*/
|
||||
public function canale(string $canale): void
|
||||
protected function paginaDopoInvio(): string
|
||||
{
|
||||
$assegnazione = $this->assegnazioneInInvio();
|
||||
if (! $assegnazione || ! in_array($canale, Assegnazione::CANALI_ATTIVI, true) || ! $assegnazione->shortPdfUrl()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->canaleUsato = $canale;
|
||||
$this->erroreInvio = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* La risposta a "Hai inviato il link?": la si registra e solo adesso si
|
||||
* va avanti. "Sì" vuole un canale usato; "No" lascia il link da inviare.
|
||||
*/
|
||||
public function concludiInvio(bool $inviato)
|
||||
{
|
||||
$assegnazione = $this->assegnazioneInInvio();
|
||||
if (! $assegnazione) {
|
||||
$this->invioId = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($inviato && $this->canaleUsato === null) {
|
||||
$this->erroreInvio = 'Scegli prima come mandare il link.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$assegnazione->registraInvioLink($inviato, $this->canaleUsato);
|
||||
|
||||
// Nel log il canale e l'id del proclamatore, mai nome, numero o email.
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($assegnazione)
|
||||
->withProperties([
|
||||
'territorio' => $assegnazione->territorio?->numero,
|
||||
'proclamatore_id' => $assegnazione->proclamatore_id,
|
||||
'canale' => $inviato ? $this->canaleUsato : null,
|
||||
])
|
||||
->log($inviato ? 'link_inviato' : 'link_non_inviato');
|
||||
|
||||
$numero = $assegnazione->territorio?->numero;
|
||||
$nome = $assegnazione->proclamatore?->nome_completo;
|
||||
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
|
||||
|
||||
session()->flash('success', $inviato
|
||||
? "Territorio {$numero} assegnato a {$nome}: link inviato."
|
||||
: "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco.");
|
||||
return $this->redirect($this->destinazione(route('assegnazioni.assegna')), navigate: true);
|
||||
}
|
||||
|
||||
private function assegnazioneInInvio(): ?Assegnazione
|
||||
{
|
||||
if (! $this->invioId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->authorize('territori.assign');
|
||||
|
||||
return Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
|
||||
return $this->destinazione(route('assegnazioni.assegna'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
@@ -233,30 +145,27 @@ class Assegna extends Component
|
||||
// i territori già assegnati, che non si possono scegliere: cercandone
|
||||
// uno si legge a chi è, invece di un "nessun risultato". La miniatura
|
||||
// serve all'anteprima, che si aggiorna nel browser.
|
||||
// Quelli nel pacchetto di un gruppo di servizio li assegna il
|
||||
// sorvegliante: qui si trovano ma non si scelgono.
|
||||
$territori = Territorio::attivi()
|
||||
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore'])
|
||||
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'gruppo'])
|
||||
->get()
|
||||
->sortBy('numero', SORT_NATURAL);
|
||||
|
||||
$opzioniTerritori = $territori
|
||||
->map(fn (Territorio $t) => $t->opzioneScelta(
|
||||
$t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null
|
||||
) + array_filter(['miniatura' => $t->miniaturaUrl()]))
|
||||
->map(fn (Territorio $t) => $t->opzioneScelta(match (true) {
|
||||
(bool) $t->assegnazioneCorrente => 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—'),
|
||||
(bool) $t->gruppo_servizio_id => 'nel pacchetto del gruppo ' . ($t->gruppo?->nome ?? ''),
|
||||
default => null,
|
||||
}) + array_filter(['miniatura' => $t->miniaturaUrl()]))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
|
||||
|
||||
// L'elenco dei territori assegnati sta in ElencoAssegnati.
|
||||
// L'invio in corso; se nel frattempo l'assegnazione è rientrata o
|
||||
// sparita non c'è più niente da mandare.
|
||||
$invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
|
||||
if ($this->invioId && ! $invio) {
|
||||
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
|
||||
}
|
||||
|
||||
return view('livewire.assegnazioni.assegna', [
|
||||
'invio' => $invio,
|
||||
'invio' => $this->invioCorrente(),
|
||||
'opzioniTerritori' => $opzioniTerritori,
|
||||
'opzioniProclamatori' => $opzioniProclamatori,
|
||||
'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
|
||||
|
||||
@@ -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()
|
||||
|
||||
132
app/Livewire/Concerns/InviaLink.php
Normal file
132
app/Livewire/Concerns/InviaLink.php
Normal 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;
|
||||
}
|
||||
}
|
||||
92
app/Livewire/Gruppi/GruppiIndex.php
Normal file
92
app/Livewire/Gruppi/GruppiIndex.php
Normal 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 già un'utenza.
|
||||
$candidati = Proclamatore::attivi()
|
||||
->whereNull('gruppo_servizio_id')
|
||||
->whereDoesntHave('utente')
|
||||
->get();
|
||||
|
||||
return view('livewire.gruppi.gruppi-index', [
|
||||
'gruppi' => $gruppi,
|
||||
'opzioniSorveglianti' => Proclamatore::opzioniScelta($candidati),
|
||||
'creato' => $this->creatoId ? GruppoServizio::with('sorvegliante.utente')->find($this->creatoId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
139
app/Livewire/Gruppi/GruppoShow.php
Normal file
139
app/Livewire/Gruppi/GruppoShow.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?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);
|
||||
$this->esegui(fn () => $this->servizio()->aggiungiMembri($this->gruppo, $ids), count($ids) . ' membri aggiunti.');
|
||||
$this->nuoviMembri = [];
|
||||
}
|
||||
|
||||
public function togliMembro(int $proclamatoreId): void
|
||||
{
|
||||
$membro = Proclamatore::findOrFail($proclamatoreId);
|
||||
$this->esegui(fn () => $this->servizio()->togliMembro($this->gruppo, $membro), "{$membro->nome_completo} tolto dal gruppo.");
|
||||
}
|
||||
|
||||
public function aggiungiTerritori(): void
|
||||
{
|
||||
$ids = array_map('intval', $this->nuoviTerritori);
|
||||
$this->esegui(fn () => $this->servizio()->aggiungiAlPacchetto($this->gruppo, $ids), count($ids) . ' territori aggiunti al pacchetto.');
|
||||
$this->nuoviTerritori = [];
|
||||
}
|
||||
|
||||
public function rimanda(int $territorioId): void
|
||||
{
|
||||
$territorio = Territorio::findOrFail($territorioId);
|
||||
$this->esegui(fn () => $this->servizio()->rimandaInReparto($this->gruppo, $territorio), "Territorio {$territorio->numero} rimandato in reparto.");
|
||||
}
|
||||
|
||||
/** Il sorvegliante ha perso la password: una nuova temporanea, da cambiare al primo accesso. */
|
||||
public function nuovaPassword(): void
|
||||
{
|
||||
$this->servizio();
|
||||
$utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first();
|
||||
if (! $utente || $this->gruppo->sciolto_at) {
|
||||
$this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$password = Str::password(12, symbols: false);
|
||||
$utente->forceFill(['password' => $password, 'password_change_required' => true])->save();
|
||||
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
|
||||
|
||||
$this->passwordTemporanea = $password;
|
||||
}
|
||||
|
||||
/** Toglie il ruolo di sorvegliante: il gruppo si scioglie. */
|
||||
public function sciogli(): void
|
||||
{
|
||||
$this->esegui(fn () => $this->servizio()->sciogli($this->gruppo), "Gruppo {$this->gruppo->nome} sciolto.");
|
||||
$this->passwordTemporanea = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->gruppo->load(['sorvegliante.utente']);
|
||||
|
||||
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values();
|
||||
$territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
|
||||
|
||||
$attivo = ! $this->gruppo->sciolto_at;
|
||||
|
||||
return view('livewire.gruppi.gruppo-show', [
|
||||
'attivo' => $attivo,
|
||||
'membri' => $membri,
|
||||
'territori' => $territori,
|
||||
// Si possono aggiungere i proclamatori attivi senza gruppo...
|
||||
'candidatiMembri' => $attivo
|
||||
? Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
|
||||
: collect(),
|
||||
// ...e i territori in reparto (attivi, non assegnati, fuori da ogni pacchetto).
|
||||
'candidatiTerritori' => $attivo
|
||||
? Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values()
|
||||
: collect(),
|
||||
'assegnazioniDelGruppo' => $this->gruppo->assegnazioni()->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
191
app/Livewire/Gruppi/MioGruppo.php
Normal file
191
app/Livewire/Gruppi/MioGruppo.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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'] ?? ''));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
75
app/Models/GruppoServizio.php
Normal file
75
app/Models/GruppoServizio.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -24,6 +24,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'email',
|
||||
'password',
|
||||
'password_change_required',
|
||||
'proclamatore_id',
|
||||
'disattivato_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -37,9 +39,50 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'password_change_required' => 'boolean',
|
||||
'disattivato_at' => 'datetime',
|
||||
'proclamatore_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** Il proclamatore di cui è l'utenza (sorvegliante di gruppo). */
|
||||
public function proclamatore()
|
||||
{
|
||||
return $this->belongsTo(Proclamatore::class, 'proclamatore_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorvegliante di gruppo: vede solo il proprio gruppo e il proprio
|
||||
* profilo (middleware LimitaSorvegliante).
|
||||
*/
|
||||
public function isSorvegliante(): bool
|
||||
{
|
||||
return $this->hasRole('sorvegliante');
|
||||
}
|
||||
|
||||
/** Il gruppo (attivo) di cui è sorvegliante, se c'è. */
|
||||
public function gruppoSorvegliato(): ?GruppoServizio
|
||||
{
|
||||
return $this->proclamatore_id
|
||||
? GruppoServizio::attivi()->where('sorvegliante_id', $this->proclamatore_id)->first()
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Utenza disattivata (gruppo sciolto): non può più entrare. */
|
||||
public function isDisattivato(): bool
|
||||
{
|
||||
return $this->disattivato_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Il sorvegliante non conferma l'email (scelta dell'utente, 19 ottobre
|
||||
* 2026): l'utenza la crea l'amministratore con una password temporanea.
|
||||
* Per tutti gli altri resta obbligatoria.
|
||||
*/
|
||||
public function hasVerifiedEmail(): bool
|
||||
{
|
||||
return ! is_null($this->email_verified_at) || $this->isSorvegliante();
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
@@ -65,6 +108,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
*/
|
||||
public function inviaConfermaEmail(): bool
|
||||
{
|
||||
if ($this->isSorvegliante()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->sendEmailVerificationNotification();
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
259
app/Services/GruppiServizio.php
Normal file
259
app/Services/GruppiServizio.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?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.");
|
||||
}
|
||||
if ($sorvegliante->utente()->exists()) {
|
||||
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
|
||||
}
|
||||
if (User::where('email', $email)->exists()) {
|
||||
throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente.");
|
||||
}
|
||||
|
||||
$password = Str::password(12, symbols: false);
|
||||
|
||||
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password) {
|
||||
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
|
||||
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||
$this->aggiungiMembri($gruppo, $membri);
|
||||
|
||||
$utente = User::create([
|
||||
'name' => $sorvegliante->nome_completo,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'password_change_required' => true,
|
||||
'proclamatore_id' => $sorvegliante->id,
|
||||
]);
|
||||
$utente->assignRole('sorvegliante');
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
|
||||
->log('gruppo_creato');
|
||||
|
||||
return $gruppo;
|
||||
});
|
||||
|
||||
return [$gruppo, $password];
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
foreach (Proclamatore::whereIn('id', $ids)->get() as $p) {
|
||||
if ($p->gruppo_servizio_id && $p->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("{$p->nome_completo} fa già parte di un altro gruppo.");
|
||||
}
|
||||
if (! $p->attivo) {
|
||||
throw new InvalidArgumentException("{$p->nome_completo} non è attivo.");
|
||||
}
|
||||
$p->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||
}
|
||||
}
|
||||
|
||||
public function togliMembro(GruppoServizio $gruppo, Proclamatore $membro): void
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
if ($membro->id === $gruppo->sorvegliante_id) {
|
||||
throw new InvalidArgumentException('Il sorvegliante non si toglie: per cambiarlo si scioglie il gruppo.');
|
||||
}
|
||||
if ($membro->gruppo_servizio_id === $gruppo->id) {
|
||||
$membro->update(['gruppo_servizio_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mette nel pacchetto del gruppo dei territori in reparto (attivi, non
|
||||
* assegnati, non già in un altro pacchetto). Restituisce quanti.
|
||||
*
|
||||
* @param int[] $ids
|
||||
*/
|
||||
public function aggiungiAlPacchetto(GruppoServizio $gruppo, array $ids): int
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
$territori = Territorio::whereIn('id', $ids)->get();
|
||||
foreach ($territori as $t) {
|
||||
if (! $t->attivo) {
|
||||
throw new InvalidArgumentException("Il territorio {$t->numero} non è attivo.");
|
||||
}
|
||||
if ($t->gruppo_servizio_id && $t->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("Il territorio {$t->numero} è già nel pacchetto di un altro gruppo.");
|
||||
}
|
||||
if ($t->assegnazioneCorrente) {
|
||||
throw new InvalidArgumentException("Il territorio {$t->numero} è assegnato: va prima fatto rientrare.");
|
||||
}
|
||||
}
|
||||
|
||||
Territorio::whereKey($territori->pluck('id'))->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['territori' => $territori->pluck('numero')->all()])
|
||||
->log('pacchetto_aggiunti');
|
||||
|
||||
return $territori->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rimanda in reparto un territorio del pacchetto non ancora dato a
|
||||
* nessuno (lo fa l'amministratore o il sorvegliante).
|
||||
*/
|
||||
public function rimandaInReparto(GruppoServizio $gruppo, Territorio $territorio): void
|
||||
{
|
||||
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
|
||||
}
|
||||
if ($territorio->assegnazioneCorrente) {
|
||||
throw new InvalidArgumentException("Il territorio {$territorio->numero} è assegnato: prima va registrato il rientro.");
|
||||
}
|
||||
|
||||
$territorio->update(['gruppo_servizio_id' => null]);
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['territorio' => $territorio->numero])
|
||||
->log('pacchetto_rimandato_in_reparto');
|
||||
}
|
||||
|
||||
/**
|
||||
* Il sorvegliante dà un territorio del pacchetto a un membro (o a sé):
|
||||
* nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il
|
||||
* territorio resta nel pacchetto finché non rientra.
|
||||
*/
|
||||
public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data): Assegnazione
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
|
||||
}
|
||||
if ($membro->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("{$membro->nome_completo} non fa parte del gruppo.");
|
||||
}
|
||||
if (! $membro->attivo) {
|
||||
throw new InvalidArgumentException("{$membro->nome_completo} non è attivo.");
|
||||
}
|
||||
if ($data->isFuture()) {
|
||||
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
|
||||
}
|
||||
|
||||
$esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data) {
|
||||
Territorio::whereKey($territorio->id)->lockForUpdate()->first();
|
||||
|
||||
$conflitto = Assegnazione::sovrappostaA($territorio->id, $data);
|
||||
if ($conflitto) {
|
||||
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}.";
|
||||
}
|
||||
|
||||
return Assegnazione::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'proclamatore_id' => $membro->id,
|
||||
'anno_teocratico_id' => AnnoTeocratico::perData($data)->id,
|
||||
'assigned_at' => $data,
|
||||
'created_by' => auth()->id(),
|
||||
'gruppo_servizio_id' => $gruppo->id,
|
||||
'link_da_inviare' => (bool) $territorio->pdf_path,
|
||||
]);
|
||||
});
|
||||
|
||||
if (is_string($esito)) {
|
||||
throw new InvalidArgumentException($esito);
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($esito)
|
||||
->withProperties([
|
||||
'territorio' => $territorio->numero,
|
||||
'proclamatore_id' => $membro->id,
|
||||
'gruppo_servizio_id' => $gruppo->id,
|
||||
])
|
||||
->log('assigned');
|
||||
|
||||
return $esito;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scioglie il gruppo (si toglie il ruolo di sorvegliante): i territori del
|
||||
* pacchetto non ancora dati tornano in reparto, i membri restano senza
|
||||
* gruppo, l'utenza del sorvegliante si disattiva. Le assegnazioni già
|
||||
* fatte restano com'erano, con il loro riferimento al gruppo, che resta
|
||||
* nello storico come sciolto.
|
||||
*/
|
||||
public function sciogli(GruppoServizio $gruppo): void
|
||||
{
|
||||
if ($gruppo->sciolto_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($gruppo) {
|
||||
// Esce dal pacchetto solo quello non ancora dato: un territorio
|
||||
// assegnato resta del suo gruppo finché non rientra (Assegnazione
|
||||
// lo libera al rientro), così non può essere ridato due volte.
|
||||
$nonDati = Territorio::withTrashed()
|
||||
->where('gruppo_servizio_id', $gruppo->id)
|
||||
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
|
||||
$liberati = (clone $nonDati)->pluck('numero');
|
||||
$nonDati->update(['gruppo_servizio_id' => null]);
|
||||
|
||||
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
|
||||
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
|
||||
if ($utente) {
|
||||
$utente->removeRole('sorvegliante');
|
||||
$utente->forceFill(['disattivato_at' => now(), 'remember_token' => null])->save();
|
||||
}
|
||||
|
||||
$gruppo->update(['sciolto_at' => now()]);
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['territori_liberati' => $liberati->all(), 'utente_disattivato' => $utente?->id])
|
||||
->log('gruppo_sciolto');
|
||||
});
|
||||
}
|
||||
|
||||
private function attivo(GruppoServizio $gruppo): void
|
||||
{
|
||||
if ($gruppo->sciolto_at) {
|
||||
throw new InvalidArgumentException('Il gruppo è stato sciolto: creane uno nuovo.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
78
database/migrations/2026_10_20_000010_gruppi_di_servizio.php
Normal file
78
database/migrations/2026_10_20_000010_gruppi_di_servizio.php
Normal 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();
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
72
resources/views/livewire/assegnazioni/_invio-link.blade.php
Normal file
72
resources/views/livewire/assegnazioni/_invio-link.blade.php
Normal 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
|
||||
@@ -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 }">
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
73
resources/views/livewire/gruppi/gruppi-index.blade.php
Normal file
73
resources/views/livewire/gruppi/gruppi-index.blade.php
Normal 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>
|
||||
136
resources/views/livewire/gruppi/gruppo-show.blade.php
Normal file
136
resources/views/livewire/gruppi/gruppo-show.blade.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<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" :disabled="count($nuoviTerritori) === 0">
|
||||
Aggiungi {{ count($nuoviTerritori) ?: '' }} al pacchetto
|
||||
</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>
|
||||
121
resources/views/livewire/gruppi/mio-gruppo.blade.php
Normal file
121
resources/views/livewire/gruppi/mio-gruppo.blade.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<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>
|
||||
@php($daDare = $territori->filter(fn ($t) => ! $t->assegnazioneCorrente)->count())
|
||||
<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())
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
375
tests/Feature/GruppiServizioTest.php
Normal file
375
tests/Feature/GruppiServizioTest.php
Normal file
@@ -0,0 +1,375 @@
|
||||
<?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());
|
||||
|
||||
$sorvegliante = User::where('email', 'mario@example.test')->sole();
|
||||
// Il cambio password al primo accesso ha i suoi test: qui si lavora.
|
||||
$sorvegliante->forceFill(['password_change_required' => false])->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.
|
||||
$this->assertNull($utente->email_verified_at);
|
||||
$this->assertTrue($utente->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'));
|
||||
}
|
||||
|
||||
// ─── Backup ─────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user