Compare commits
6 Commits
main
...
9999a3b031
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9999a3b031 | ||
|
|
93bc9cf2fe | ||
|
|
6489fb627d | ||
|
|
f48d0e96c0 | ||
|
|
96084499f5 | ||
|
|
5d0ae06eca |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,7 +18,6 @@
|
|||||||
/.phpunit.cache/
|
/.phpunit.cache/
|
||||||
/.idea/
|
/.idea/
|
||||||
/.vscode/
|
/.vscode/
|
||||||
.claude
|
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*.sql
|
*.sql
|
||||||
|
|||||||
131
HANDOFF.md
131
HANDOFF.md
@@ -4,52 +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
|
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.
|
può dare: perché le cose sono come sono, e cosa resta aperto.
|
||||||
|
|
||||||
Ultimo aggiornamento: **21 ottobre 2026** (rilascio di sicurezza di PHP) — vedi [Sicurezza: PHP](#sicurezza-php-cve-2026-109618--cve-2026-109619-21-ottobre-2026).
|
Ultimo aggiornamento: **19 ottobre 2026** (gruppi di servizio, solo su dev) — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
|
||||||
|
|
||||||
> **Questo è l'HANDOFF di `main` (produzione).** Il 21/10/2026 in produzione
|
|
||||||
> è andata **solo la correzione di PHP**, scelta dell'utente: le funzioni nuove
|
|
||||||
> di `dev` (gruppi di servizio e lavori del 20 ottobre) restano sulla demo.
|
|
||||||
> Il quadro completo e aggiornato del progetto è nell'HANDOFF di `dev`, che
|
|
||||||
> sostituirà questo quando `dev` verrà portato in produzione.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026)
|
|
||||||
|
|
||||||
Due esecuzioni di codice da remoto nel core di PHP (CVSS v4 9.2), patch alle
|
|
||||||
14:00 UTC del 21/10/2026; versione corretta per il nostro ramo: **8.3.36**.
|
|
||||||
La produzione girava su 8.3.35. Rilasciati su `main` solo i due commit di
|
|
||||||
sicurezza di `dev` (cherry-pick):
|
|
||||||
|
|
||||||
- `docker/php/Dockerfile`: `ARG PHP_VERSION=8.3.36` e `FROM
|
|
||||||
php:${PHP_VERSION}-fpm`. Prima era `php:8.3-fpm`, un tag che si sposta ma
|
|
||||||
che Docker tiene in cache: una ricostruzione senza `--pull` (anche su
|
|
||||||
Dokploy) poteva riusare la base vulnerabile. Ora una build o prende la
|
|
||||||
8.3.36 o fallisce. Test `tests/Unit/VersionePhpTest.php` contro il ritorno a
|
|
||||||
un tag mobile o a una versione inferiore.
|
|
||||||
- phpredis da GitHub (`ARG PHPREDIS_VERSION=6.3.0`) invece di `pecl install`:
|
|
||||||
quel giorno pecl.php.net rispondeva 504 e la build falliva.
|
|
||||||
- `docker/php/php.ini`: `session.upload_progress.enabled = 0` e
|
|
||||||
`session.use_strict_mode = 1`, la mitigazione indicata da Sansec in attesa
|
|
||||||
della patch, da tenere anche dopo (l'applicazione usa le sessioni di
|
|
||||||
Laravel, in Redis).
|
|
||||||
- Solo per provare in locale prima che l'immagine sia su Docker Hub:
|
|
||||||
`PHP_VERSION=8.3.35 docker compose ...` (mai per un deploy).
|
|
||||||
|
|
||||||
**In produzione dal 21 ottobre 2026, ore 18:48** (16:48 UTC; `main` =
|
|
||||||
`1ddb973`). Su Docker Hub il tag `php:8.3.36-fpm` è comparso alle 15:38 UTC
|
|
||||||
ma vuoto: l'immagine per amd64 (il server) è arrivata alle 16:41 UTC, e prima
|
|
||||||
la build falliva con "no match for platform in manifest". Prima del deploy:
|
|
||||||
test di questa versione su PHP 8.3.36 (524 PHP + 13 JS verdi) e backup
|
|
||||||
`/etc/dokploy/termanager2-backups/prod-20261021-164333` (database, 78 MB di
|
|
||||||
`storage/app`, conteggi). Deploy lanciato a mano (l'autodeploy non è
|
|
||||||
partito), uno solo. Verifica: PHP 8.3.36, `session.upload_progress.enabled`
|
|
||||||
Off e `session.use_strict_mode` On dal `php.ini` dell'immagine, nessuna
|
|
||||||
migrazione in sospeso, conteggi identici al backup (274 territori, 1130
|
|
||||||
assegnazioni di cui 77 aperte, 111 proclamatori, 4 utenti, 1251 righe di log,
|
|
||||||
35 migrazioni), login 200, container sani, nessun errore nei log. La
|
|
||||||
mitigazione applicata a mano ai container la mattina (in `conf.d`) è
|
|
||||||
sparita con la ricostruzione, come previsto: vale il `php.ini`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -172,6 +127,82 @@ email di prova e di conferma arrivate, link aperto senza login, account
|
|||||||
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
|
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
|
||||||
e dati di prova rimossi dopo.
|
e dati di prova rimossi dopo.
|
||||||
|
|
||||||
|
### Gruppi di servizio (19 ottobre 2026, solo su dev)
|
||||||
|
|
||||||
|
Richiesta dell'utente: un **gruppo di servizio** con un sorvegliante che,
|
||||||
|
entrato con una sua utenza, distribuisce ai membri un pacchetto di territori
|
||||||
|
e non vede nient'altro. Scelte dell'utente:
|
||||||
|
|
||||||
|
- Il gruppo ha **membri** (proclamatori); un proclamatore sta in **un solo**
|
||||||
|
gruppo; il sorvegliante è anche membro e assegna solo ai membri, a sé
|
||||||
|
compreso.
|
||||||
|
- Il **pacchetto non è un'assegnazione**: "la media non deve essere alterata".
|
||||||
|
I territori del pacchetto sono "In gruppo …" (stato `in_gruppo`), fuori da
|
||||||
|
"Da assegnare" e dalla pagina *Assegna* (`Territorio::scopeInReparto`
|
||||||
|
esclude `gruppo_servizio_id`).
|
||||||
|
- Quando il sorvegliante dà un territorio nasce un'**assegnazione normale**,
|
||||||
|
tracciata con `assegnazioni.gruppo_servizio_id` ("via gruppo …" nel
|
||||||
|
registro e nella scheda del territorio).
|
||||||
|
- Al **rientro** il territorio esce dal pacchetto e torna in reparto
|
||||||
|
(hook `updated` in `Assegnazione`, vale da qualunque pagina).
|
||||||
|
- Il sorvegliante: assegna, manda il link (copia/condividi), registra i
|
||||||
|
rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e
|
||||||
|
pacchetti li gestisce **solo l'amministratore**.
|
||||||
|
- Utenza: email e password temporanea da cambiare al primo accesso, **senza
|
||||||
|
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il
|
||||||
|
sorvegliante; anche cambiandola dal profilo).
|
||||||
|
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
|
||||||
|
non dati in reparto, membri liberi, utenza senza ruolo e disattivata
|
||||||
|
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da
|
||||||
|
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I
|
||||||
|
territori già dati restano ai membri. Si riparte con un gruppo nuovo.
|
||||||
|
|
||||||
|
Casi aggiunti di mio, da far confermare all'utente:
|
||||||
|
- Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la
|
||||||
|
sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id`
|
||||||
|
è unico), con email e password temporanea nuove.
|
||||||
|
- Il sorvegliante di un gruppo **attivo** non si sposta nel cestino né si
|
||||||
|
anonimizza (`ProclamatoreIndex`): prima si scioglie il gruppo. Un membro
|
||||||
|
spostato nel cestino lascia il gruppo. `Proclamatore::anonimizza()`
|
||||||
|
anonimizza anche l'utenza dell'ex sorvegliante (nome, email, password,
|
||||||
|
ruoli).
|
||||||
|
- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro
|
||||||
|
delle attività `User::tapActivity()` li sostituisce con "proclamatore #id",
|
||||||
|
come vale per tutti i proclamatori. Restano invece in chiaro nella tabella
|
||||||
|
`users`, come per ogni utente.
|
||||||
|
|
||||||
|
Dove sta:
|
||||||
|
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
|
||||||
|
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e
|
||||||
|
assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`,
|
||||||
|
permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà
|
||||||
|
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`.
|
||||||
|
- Regole in `App\Services\GruppiServizio` (crea, aggiungiMembri, togliMembro,
|
||||||
|
aggiungiAlPacchetto, rimandaInReparto, assegna, sciogli); i componenti
|
||||||
|
`Gruppi\GruppiIndex`, `Gruppi\GruppoShow` (amministratore) e
|
||||||
|
`Gruppi\MioGruppo` (sorvegliante) le chiamano soltanto.
|
||||||
|
- `LimitaSorvegliante` (gruppo `web`): chi ha **solo** il ruolo sorvegliante
|
||||||
|
raggiunge `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo,
|
||||||
|
controllo in `Rientra::autorizza`), profilo e cambio password; la Home lo
|
||||||
|
rimanda al gruppo; il resto è 403.
|
||||||
|
- L'invio del link è diventato il trait `Concerns\InviaLink` + il partial
|
||||||
|
`assegnazioni/_invio-link`, usati da *Assegna* e da *Il mio gruppo*.
|
||||||
|
- Il ruolo sorvegliante non compare nella pagina *Utenti* e le utenze legate a
|
||||||
|
un proclamatore lì non si modificano né si eliminano: passano dal gruppo.
|
||||||
|
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
|
||||||
|
l'amministratore passa ogni `@can` (`Gate::before`).
|
||||||
|
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
|
||||||
|
assegnazioni e il legame utenza ↔ proclamatore.
|
||||||
|
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
|
||||||
|
del proclamatore).
|
||||||
|
- L'azzeramento dell'applicazione (*Zona pericolosa*) cancella anche i gruppi.
|
||||||
|
- Test: `tests/Feature/GruppiServizioTest.php`. Eseguiti sul server **prima**
|
||||||
|
del push, su una copia della checkout di dev con i commit portati da un
|
||||||
|
`git bundle` (la checkout di Dokploy è shallow: il bundle deve partire dal
|
||||||
|
commit che ha, `git log -1` lì dentro).
|
||||||
|
|
||||||
|
Non provato nel browser. **In produzione non c'è**: serve l'ok dell'utente.
|
||||||
|
|
||||||
### Proclamatori senza contatti; il link si copia o si condivide (19 ottobre 2026)
|
### 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
|
Decisione dell'utente, dopo il rilascio: i proclamatori **non hanno più email
|
||||||
@@ -1601,6 +1632,14 @@ stringa invece di proteggere l'apostrofo, perché fra il file e l'attributo non
|
|||||||
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
|
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
|
||||||
da nessuna parte; apostrofi italiani solo dentro backtick.
|
da nessuna parte; apostrofi italiani solo dentro backtick.
|
||||||
|
|
||||||
|
**In una vista Blade la forma in linea `@php(...)` non va messa prima di un
|
||||||
|
blocco `@php … @endphp`.** Blade estrae i blocchi `@php … @endphp` per primi,
|
||||||
|
con un'espressione che parte dalla prima `@php` che trova: se è quella in
|
||||||
|
linea, ingoia tutto fino al primo `@endphp` e la vista non compila più
|
||||||
|
("unexpected token class"). Successo in *Il mio gruppo* il 19/10/2026. E
|
||||||
|
vale anche dentro i commenti `{{-- --}}`, che Blade toglie *dopo*: lì la
|
||||||
|
parola `@php` non va scritta.
|
||||||
|
|
||||||
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
|
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
|
||||||
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
|
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
|
||||||
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.
|
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.
|
||||||
|
|||||||
93
README.md
93
README.md
@@ -736,6 +736,33 @@ docker compose --profile dev up -d --build
|
|||||||
- **Dopo una conferma o un annulla si torna alla pagina di partenza**, con i
|
- **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)
|
suoi filtri, ricerca, ordinamento e pagina (aperta dal menu: come prima)
|
||||||
|
|
||||||
|
### Gruppi di servizio
|
||||||
|
- Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome,
|
||||||
|
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
|
||||||
|
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
|
||||||
|
anche membro
|
||||||
|
- Creando il gruppo il sorvegliante riceve un'**utenza**: l'email indicata e
|
||||||
|
una password temporanea, mostrata una volta sola e da cambiare al primo
|
||||||
|
accesso. **Nessuna conferma dell'email**. Se la perde, l'amministratore ne
|
||||||
|
genera un'altra dalla pagina del gruppo
|
||||||
|
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto.
|
||||||
|
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
|
||||||
|
cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
|
||||||
|
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo
|
||||||
|
(ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne
|
||||||
|
nasce un'**assegnazione normale**, che conta come le altre ed è segnata
|
||||||
|
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
|
||||||
|
apre la piantina e rimanda in reparto quelli non ancora dati
|
||||||
|
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
|
||||||
|
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante: i territori non
|
||||||
|
ancora dati tornano in reparto, i membri si liberano, l'utenza viene
|
||||||
|
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello
|
||||||
|
storico. I territori già dati restano ai membri. Per ripartire si crea un
|
||||||
|
gruppo nuovo; se il sorvegliante è lo stesso, si riattiva la sua utenza
|
||||||
|
- Il sorvegliante di un gruppo attivo non si sposta nel cestino né si
|
||||||
|
anonimizza: prima si scioglie il gruppo. Anonimizzando un ex sorvegliante
|
||||||
|
si anonimizza anche la sua utenza
|
||||||
|
|
||||||
### Campagne
|
### Campagne
|
||||||
- CRUD con date di inizio e fine
|
- CRUD con date di inizio e fine
|
||||||
- Stato automatico: attiva, futura o conclusa
|
- Stato automatico: attiva, futura o conclusa
|
||||||
@@ -771,24 +798,31 @@ docker compose --profile dev up -d --build
|
|||||||
|
|
||||||
## Ruoli e permessi
|
## Ruoli e permessi
|
||||||
|
|
||||||
Tre ruoli con permessi granulari (spatie/laravel-permission):
|
Quattro ruoli con permessi granulari (spatie/laravel-permission):
|
||||||
|
|
||||||
| Permesso | Amministratore | Assistente | Operatore |
|
| Permesso | Amministratore | Assistente | Operatore | Sorvegliante |
|
||||||
|-----------------------|:--------------:|:----------:|:---------:|
|
|-----------------------|:--------------:|:----------:|:---------:|:------------:|
|
||||||
| `settings.manage` | ✅ | | |
|
| `settings.manage` | ✅ | | | |
|
||||||
| `territori.manage` | ✅ | | |
|
| `territori.manage` | ✅ | | | |
|
||||||
| `proclamatori.manage` | ✅ | ✅ | |
|
| `proclamatori.manage` | ✅ | ✅ | | |
|
||||||
| `campagne.manage` | ✅ | ✅ | |
|
| `campagne.manage` | ✅ | ✅ | | |
|
||||||
| `registro.view` | ✅ | ✅ | |
|
| `registro.view` | ✅ | ✅ | | |
|
||||||
| `registro.export` | ✅ | | |
|
| `registro.export` | ✅ | | | |
|
||||||
| `territori.assign` | ✅ | ✅ | ✅ |
|
| `territori.assign` | ✅ | ✅ | ✅ | |
|
||||||
| `territori.return` | ✅ | ✅ | ✅ |
|
| `territori.return` | ✅ | ✅ | ✅ | |
|
||||||
| `audit.view` | ✅ | | |
|
| `audit.view` | ✅ | | | |
|
||||||
| `audit.export` | ✅ | | |
|
| `audit.export` | ✅ | | | |
|
||||||
|
| `gruppo.gestisci` | | | | ✅ |
|
||||||
|
|
||||||
L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
|
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 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
|
I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi
|
||||||
una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||||
|
|
||||||
@@ -812,13 +846,6 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
|
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
|
||||||
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
|
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
|
||||||
restituisce 404
|
restituisce 404
|
||||||
- **Versione di PHP fissata alla patch** (`ARG PHP_VERSION` nel Dockerfile,
|
|
||||||
oggi 8.3.36 per CVE-2026-109618/109619): una build o prende quella versione
|
|
||||||
o fallisce, mai una base vecchia rimasta in cache. Va alzata a ogni rilascio
|
|
||||||
di sicurezza del ramo 8.3
|
|
||||||
- **Sessioni native di PHP chiuse**: `session.upload_progress.enabled = 0` e
|
|
||||||
`session.use_strict_mode = 1` in `php.ini` (l'applicazione usa le sessioni
|
|
||||||
di Laravel; è la mitigazione indicata per le stesse CVE)
|
|
||||||
|
|
||||||
### GDPR
|
### GDPR
|
||||||
- **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
|
- **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
|
||||||
@@ -834,7 +861,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
||||||
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
||||||
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
||||||
in chiaro
|
in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome
|
||||||
|
di un proclamatore)
|
||||||
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
|
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
|
||||||
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
|
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
|
||||||
in un luogo protetto e cancellati quando non servono
|
in un luogo protetto e cancellati quando non servono
|
||||||
@@ -845,15 +873,16 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
|
|
||||||
| Tabella | Descrizione |
|
| 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 |
|
| `settings` | Configurazione singleton |
|
||||||
| `zone` | Zone territoriali |
|
| `zone` | Zone territoriali |
|
||||||
| `tipologie` | Tipologie di territorio |
|
| `tipologie` | Tipologie di territorio |
|
||||||
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti) |
|
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
|
||||||
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
|
| `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 |
|
| `anni_teocratici` | Periodi settembre → agosto |
|
||||||
| `campagne` | Periodi speciali con date e descrizione |
|
| `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 |
|
| `activity_log` | Audit trail |
|
||||||
|
|
||||||
**Vincoli e indici principali**
|
**Vincoli e indici principali**
|
||||||
@@ -880,7 +909,8 @@ Calcolato dinamicamente, non memorizzato:
|
|||||||
|
|
||||||
| Stato | Condizione |
|
| 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 |
|
| `assegnato` | Ha un'assegnazione con `returned_at` nullo |
|
||||||
| `da_rientrare` | Assegnato da più giorni della soglia configurata |
|
| `da_rientrare` | Assegnato da più giorni della soglia configurata |
|
||||||
| `inattivo` | Contrassegnato come non attivo |
|
| `inattivo` | Contrassegnato come non attivo |
|
||||||
@@ -904,6 +934,15 @@ lo decide l'operatore con la spunta. Se più campagne corrispondono viene
|
|||||||
proposta la più recente. I territori assegnati durante la campagna e non
|
proposta la più recente. I territori assegnati durante la campagna e non
|
||||||
ancora rientrati compaiono nel dettaglio come "in corso".
|
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
|
## Architettura
|
||||||
@@ -913,7 +952,7 @@ ancora rientrati compaiono nel dettaglio come "in corso".
|
|||||||
| Componente | Tecnologia |
|
| Componente | Tecnologia |
|
||||||
|-------------------|--------------------------------------------|
|
|-------------------|--------------------------------------------|
|
||||||
| Framework | Laravel 11 |
|
| Framework | Laravel 11 |
|
||||||
| PHP | 8.3 (FPM), versione fissata nel Dockerfile |
|
| PHP | 8.3 (FPM) |
|
||||||
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
|
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
|
||||||
| Database | MariaDB 11 |
|
| Database | MariaDB 11 |
|
||||||
| Cache/Sessioni/Code| Redis 7 |
|
| Cache/Sessioni/Code| Redis 7 |
|
||||||
|
|||||||
@@ -40,10 +40,25 @@ class LoginController extends Controller
|
|||||||
->withInput($request->only('email', 'remember'));
|
->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);
|
RateLimiter::clear($throttleKey);
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
activity()->causedBy(auth()->user())->log('login');
|
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'));
|
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');
|
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
|
public function miniatura(Territorio $territorio): Response
|
||||||
{
|
{
|
||||||
return $this->servi($territorio->thumbnail_path, null, null);
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Jobs;
|
|||||||
use App\Models\AnnoTeocratico;
|
use App\Models\AnnoTeocratico;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
use App\Models\Campagna;
|
use App\Models\Campagna;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
@@ -75,7 +76,7 @@ class ResetApplication implements ShouldQueue
|
|||||||
$stato->avanza($this->operationId, 1);
|
$stato->avanza($this->operationId, 1);
|
||||||
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
||||||
|
|
||||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10);
|
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 11);
|
||||||
|
|
||||||
DB::transaction(function () use ($stato) {
|
DB::transaction(function () use ($stato) {
|
||||||
$passi = [
|
$passi = [
|
||||||
@@ -87,6 +88,7 @@ class ResetApplication implements ShouldQueue
|
|||||||
'Piantine' => fn () => TerritorioMappa::query()->delete(),
|
'Piantine' => fn () => TerritorioMappa::query()->delete(),
|
||||||
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
|
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
|
||||||
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
||||||
|
'Gruppi di servizio' => fn () => GruppoServizio::query()->delete(),
|
||||||
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
||||||
'Tipologie' => fn () => Tipologia::query()->delete(),
|
'Tipologie' => fn () => Tipologia::query()->delete(),
|
||||||
'Zone' => fn () => Zona::query()->delete(),
|
'Zone' => fn () => Zona::query()->delete(),
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Assegnazioni;
|
namespace App\Livewire\Assegnazioni;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\InviaLink;
|
||||||
use App\Livewire\Concerns\RicordaRitorno;
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\Attributes\Locked;
|
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
@@ -15,25 +14,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
class Assegna extends Component
|
class Assegna extends Component
|
||||||
{
|
{
|
||||||
use RicordaRitorno;
|
use RicordaRitorno;
|
||||||
|
use InviaLink;
|
||||||
// ─── 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 = '';
|
|
||||||
|
|
||||||
public ?int $territorio_id = null;
|
public ?int $territorio_id = null;
|
||||||
public ?int $proclamatore_id = null;
|
public ?int $proclamatore_id = null;
|
||||||
@@ -83,6 +64,12 @@ class Assegna extends Component
|
|||||||
return;
|
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);
|
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
||||||
if (!$proclamatore->attivo) {
|
if (!$proclamatore->attivo) {
|
||||||
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||||
@@ -140,91 +127,16 @@ class Assegna extends Component
|
|||||||
$this->apriInvio($assegnazione->id);
|
$this->apriInvio($assegnazione->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── Invio del link (trait InviaLink) ──────────────────────
|
||||||
* 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é
|
protected function puoInviare(Assegnazione $assegnazione): bool
|
||||||
* e lo chiede con l'evento apri-invio).
|
|
||||||
*/
|
|
||||||
#[On('apri-invio')]
|
|
||||||
public function apriInvio(int $assegnazioneId): void
|
|
||||||
{
|
{
|
||||||
$this->authorize('territori.assign');
|
return auth()->user()->can('territori.assign');
|
||||||
|
|
||||||
$assegnazione = Assegnazione::aperte()->find($assegnazioneId);
|
|
||||||
if (! $assegnazione) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->invioId = $assegnazione->id;
|
|
||||||
$this->canaleUsato = null;
|
|
||||||
$this->erroreInvio = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function paginaDopoInvio(): string
|
||||||
* 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
|
|
||||||
{
|
{
|
||||||
$assegnazione = $this->assegnazioneInInvio();
|
return $this->destinazione(route('assegnazioni.assegna'));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
@@ -233,30 +145,27 @@ class Assegna extends Component
|
|||||||
// i territori già assegnati, che non si possono scegliere: cercandone
|
// i territori già assegnati, che non si possono scegliere: cercandone
|
||||||
// uno si legge a chi è, invece di un "nessun risultato". La miniatura
|
// uno si legge a chi è, invece di un "nessun risultato". La miniatura
|
||||||
// serve all'anteprima, che si aggiorna nel browser.
|
// 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()
|
$territori = Territorio::attivi()
|
||||||
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore'])
|
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'gruppo'])
|
||||||
->get()
|
->get()
|
||||||
->sortBy('numero', SORT_NATURAL);
|
->sortBy('numero', SORT_NATURAL);
|
||||||
|
|
||||||
$opzioniTerritori = $territori
|
$opzioniTerritori = $territori
|
||||||
->map(fn (Territorio $t) => $t->opzioneScelta(
|
->map(fn (Territorio $t) => $t->opzioneScelta(match (true) {
|
||||||
$t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null
|
(bool) $t->assegnazioneCorrente => 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—'),
|
||||||
) + array_filter(['miniatura' => $t->miniaturaUrl()]))
|
(bool) $t->gruppo_servizio_id => 'nel pacchetto del gruppo ' . ($t->gruppo?->nome ?? ''),
|
||||||
|
default => null,
|
||||||
|
}) + array_filter(['miniatura' => $t->miniaturaUrl()]))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
|
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
|
||||||
|
|
||||||
// L'elenco dei territori assegnati sta in ElencoAssegnati.
|
// 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', [
|
return view('livewire.assegnazioni.assegna', [
|
||||||
'invio' => $invio,
|
'invio' => $this->invioCorrente(),
|
||||||
'opzioniTerritori' => $opzioniTerritori,
|
'opzioniTerritori' => $opzioniTerritori,
|
||||||
'opzioniProclamatori' => $opzioniProclamatori,
|
'opzioniProclamatori' => $opzioniProclamatori,
|
||||||
'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
|
'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)
|
public function mount(Assegnazione $assegnazione)
|
||||||
{
|
{
|
||||||
|
$this->autorizza($assegnazione);
|
||||||
$this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']);
|
$this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']);
|
||||||
|
|
||||||
if ($assegnazione->returned_at) {
|
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()
|
public function save()
|
||||||
{
|
{
|
||||||
|
$this->autorizza($this->assegnazione);
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
// mount() rejects an already-returned assignment, but the component
|
// 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.");
|
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()
|
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 un'utenza attiva (quella di un gruppo sciolto si riattiva).
|
||||||
|
$candidati = Proclamatore::attivi()
|
||||||
|
->whereNull('gruppo_servizio_id')
|
||||||
|
->whereDoesntHave('utente', fn ($q) => $q->whereNull('disattivato_at'))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('livewire.gruppi.gruppi-index', [
|
||||||
|
'gruppi' => $gruppi,
|
||||||
|
'opzioniSorveglianti' => Proclamatore::opzioniScelta($candidati),
|
||||||
|
'creato' => $this->creatoId ? GruppoServizio::with('sorvegliante.utente')->find($this->creatoId) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/Livewire/Gruppi/GruppoShow.php
Normal file
149
app/Livewire/Gruppi/GruppoShow.php
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Gruppi;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\Avvisa;
|
||||||
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\GruppiServizio;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un gruppo di servizio, lato amministratore: membri (aggiungere/togliere),
|
||||||
|
* pacchetto (aggiungere in blocco, rimandare in reparto quelli non dati),
|
||||||
|
* nuova password temporanea per il sorvegliante, scioglimento.
|
||||||
|
*/
|
||||||
|
class GruppoShow extends Component
|
||||||
|
{
|
||||||
|
use Avvisa;
|
||||||
|
use RicordaRitorno;
|
||||||
|
|
||||||
|
public GruppoServizio $gruppo;
|
||||||
|
|
||||||
|
/** @var int[] proclamatori da aggiungere */
|
||||||
|
public array $nuoviMembri = [];
|
||||||
|
|
||||||
|
/** @var int[] territori da aggiungere al pacchetto */
|
||||||
|
public array $nuoviTerritori = [];
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $passwordTemporanea = null;
|
||||||
|
|
||||||
|
public function mount(GruppoServizio $gruppo): void
|
||||||
|
{
|
||||||
|
$this->gruppo = $gruppo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function servizio(): GruppiServizio
|
||||||
|
{
|
||||||
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
|
return app(GruppiServizio::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function esegui(callable $azione, string $ok): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$azione();
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$this->avvisaErrore($e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->gruppo->refresh();
|
||||||
|
$this->avvisa($ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function aggiungiMembri(): void
|
||||||
|
{
|
||||||
|
$ids = array_map('intval', $this->nuoviMembri);
|
||||||
|
if ($ids === []) {
|
||||||
|
$this->avvisaErrore('Spunta almeno un proclamatore.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->esegui(fn () => $this->servizio()->aggiungiMembri($this->gruppo, $ids), count($ids) . ' membri aggiunti.');
|
||||||
|
$this->nuoviMembri = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function togliMembro(int $proclamatoreId): void
|
||||||
|
{
|
||||||
|
$membro = Proclamatore::findOrFail($proclamatoreId);
|
||||||
|
$this->esegui(fn () => $this->servizio()->togliMembro($this->gruppo, $membro), "{$membro->nome_completo} tolto dal gruppo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function aggiungiTerritori(): void
|
||||||
|
{
|
||||||
|
$ids = array_map('intval', $this->nuoviTerritori);
|
||||||
|
if ($ids === []) {
|
||||||
|
$this->avvisaErrore('Spunta almeno un territorio.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->esegui(fn () => $this->servizio()->aggiungiAlPacchetto($this->gruppo, $ids), count($ids) . ' territori aggiunti al pacchetto.');
|
||||||
|
$this->nuoviTerritori = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rimanda(int $territorioId): void
|
||||||
|
{
|
||||||
|
$territorio = Territorio::findOrFail($territorioId);
|
||||||
|
$this->esegui(fn () => $this->servizio()->rimandaInReparto($this->gruppo, $territorio), "Territorio {$territorio->numero} rimandato in reparto.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Il sorvegliante ha perso la password: una nuova temporanea, da cambiare al primo accesso. */
|
||||||
|
public function nuovaPassword(): void
|
||||||
|
{
|
||||||
|
$this->servizio();
|
||||||
|
$utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first();
|
||||||
|
if (! $utente || $this->gruppo->sciolto_at) {
|
||||||
|
$this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = Str::password(12, symbols: false);
|
||||||
|
$utente->forceFill(['password' => $password, 'password_change_required' => true])->save();
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
|
||||||
|
|
||||||
|
$this->passwordTemporanea = $password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toglie il ruolo di sorvegliante: il gruppo si scioglie. */
|
||||||
|
public function sciogli(): void
|
||||||
|
{
|
||||||
|
$this->esegui(fn () => $this->servizio()->sciogli($this->gruppo), "Gruppo {$this->gruppo->nome} sciolto.");
|
||||||
|
$this->passwordTemporanea = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$this->gruppo->load(['sorvegliante.utente']);
|
||||||
|
|
||||||
|
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values();
|
||||||
|
$territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
|
||||||
|
|
||||||
|
$attivo = ! $this->gruppo->sciolto_at;
|
||||||
|
|
||||||
|
return view('livewire.gruppi.gruppo-show', [
|
||||||
|
'attivo' => $attivo,
|
||||||
|
'membri' => $membri,
|
||||||
|
'territori' => $territori,
|
||||||
|
// Si possono aggiungere i proclamatori attivi senza gruppo...
|
||||||
|
'candidatiMembri' => $attivo
|
||||||
|
? Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
|
||||||
|
: collect(),
|
||||||
|
// ...e i territori in reparto (attivi, non assegnati, fuori da ogni pacchetto).
|
||||||
|
'candidatiTerritori' => $attivo
|
||||||
|
? Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values()
|
||||||
|
: collect(),
|
||||||
|
'assegnazioniDelGruppo' => $this->gruppo->assegnazioni()->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 = [
|
public const PERMESSI = [
|
||||||
'settings.manage' => 'impostazioni, utenti, backup e import',
|
'settings.manage' => 'impostazioni, utenti, backup e import',
|
||||||
'proclamatori.manage' => 'anagrafica dei proclamatori',
|
'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',
|
'territori.manage' => 'gestione dei territori e delle piantine',
|
||||||
'campagne.manage' => 'campagne',
|
'campagne.manage' => 'campagne',
|
||||||
'registro.view' => 'consultazione del registro delle assegnazioni',
|
'registro.view' => 'consultazione del registro delle assegnazioni',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Livewire\Concerns\Avvisa;
|
|||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
|
|
||||||
class ProclamatoreIndex extends Component
|
class ProclamatoreIndex extends Component
|
||||||
@@ -70,7 +71,12 @@ class ProclamatoreIndex extends Component
|
|||||||
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
|
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ($this->sorveglianteDiUnGruppo($proclamatore, 'spostarlo nel cestino')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nel cestino non resta membro di un gruppo.
|
||||||
|
$proclamatore->update(['gruppo_servizio_id' => null]);
|
||||||
$proclamatore->delete();
|
$proclamatore->delete();
|
||||||
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
||||||
}
|
}
|
||||||
@@ -80,10 +86,27 @@ class ProclamatoreIndex extends Component
|
|||||||
$this->authorize('settings.manage');
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
$proclamatore = Proclamatore::findOrFail($id);
|
$proclamatore = Proclamatore::findOrFail($id);
|
||||||
|
if ($this->sorveglianteDiUnGruppo($proclamatore, 'anonimizzarlo')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$proclamatore->anonimizza();
|
$proclamatore->anonimizza();
|
||||||
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
|
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Il sorvegliante di un gruppo attivo si toglie sciogliendo il gruppo, non da qui. */
|
||||||
|
private function sorveglianteDiUnGruppo(Proclamatore $proclamatore, string $azione): bool
|
||||||
|
{
|
||||||
|
$gruppo = GruppoServizio::attivi()->where('sorvegliante_id', $proclamatore->id)->first();
|
||||||
|
if (! $gruppo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->avvisaErrore("{$proclamatore->nome_completo} è il sorvegliante del gruppo {$gruppo->nome}: per {$azione} va prima sciolto il gruppo (Gruppi di servizio).");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
// withCount rather than letting the view call
|
// withCount rather than letting the view call
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ class Profile extends Component
|
|||||||
|
|
||||||
// L'indirizzo nuovo va confermato (User::booted lo segna da
|
// L'indirizzo nuovo va confermato (User::booted lo segna da
|
||||||
// confermare): finché non apre il link, l'account resta bloccato.
|
// 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()
|
$user->inviaConfermaEmail()
|
||||||
? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.")
|
? 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.');
|
: 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()
|
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) {
|
if ($this->filtroAnno) {
|
||||||
$query->where('anno_teocratico_id', $this->filtroAnno);
|
$query->where('anno_teocratico_id', $this->filtroAnno);
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ class UsersIndex extends Component
|
|||||||
|
|
||||||
public function mount(): void
|
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()
|
$this->availableRoles = Role::query()
|
||||||
|
->where('name', '!=', 'sorvegliante')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->pluck('name')
|
->pluck('name')
|
||||||
->all();
|
->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
|
public function startEdit(int $userId): void
|
||||||
{
|
{
|
||||||
$user = User::query()->with('roles')->findOrFail($userId);
|
$user = User::query()->with('roles')->findOrFail($userId);
|
||||||
|
if ($this->gestitaDaiGruppi($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->editingUserId = $user->id;
|
$this->editingUserId = $user->id;
|
||||||
$this->editName = $user->name;
|
$this->editName = $user->name;
|
||||||
@@ -143,6 +165,9 @@ class UsersIndex extends Component
|
|||||||
|
|
||||||
$validated = $this->validate($this->editRules());
|
$validated = $this->validate($this->editRules());
|
||||||
$user = User::query()->findOrFail($this->editingUserId);
|
$user = User::query()->findOrFail($this->editingUserId);
|
||||||
|
if ($this->gestitaDaiGruppi($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
|
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
|
||||||
// rimasto, lascia l'installazione senza nessuno che possa gestire
|
// rimasto, lascia l'installazione senza nessuno che possa gestire
|
||||||
@@ -193,6 +218,9 @@ class UsersIndex extends Component
|
|||||||
{
|
{
|
||||||
$currentUser = auth()->user();
|
$currentUser = auth()->user();
|
||||||
$user = User::query()->with('roles')->findOrFail($userId);
|
$user = User::query()->with('roles')->findOrFail($userId);
|
||||||
|
if ($this->gestitaDaiGruppi($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $currentUser || $currentUser->id === $user->id) {
|
if (! $currentUser || $currentUser->id === $user->id) {
|
||||||
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
|
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Jobs\ImportTerritoryPdfFolder;
|
|||||||
use App\Models\AnnoTeocratico;
|
use App\Models\AnnoTeocratico;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
use App\Models\Campagna;
|
use App\Models\Campagna;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
@@ -469,6 +470,7 @@ class XmlExchange extends Component
|
|||||||
try {
|
try {
|
||||||
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
|
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
|
||||||
Assegnazione::query()->delete();
|
Assegnazione::query()->delete();
|
||||||
|
GruppoServizio::query()->delete();
|
||||||
Campagna::query()->delete();
|
Campagna::query()->delete();
|
||||||
Territorio::query()->withTrashed()->forceDelete();
|
Territorio::query()->withTrashed()->forceDelete();
|
||||||
Proclamatore::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
|
// accounts in the file are (re)created. $userMap remaps the
|
||||||
// created_by/returned_by FKs on the assignments below.
|
// created_by/returned_by FKs on the assignments below.
|
||||||
$userMap = [];
|
$userMap = [];
|
||||||
|
$utentiDaCollegare = [];
|
||||||
foreach (($xml->users->user ?? []) as $userNode) {
|
foreach (($xml->users->user ?? []) as $userNode) {
|
||||||
$email = trim((string) ($userNode->email ?? ''));
|
$email = trim((string) ($userNode->email ?? ''));
|
||||||
$legacyId = (string) ($userNode['legacy_id'] ?? '');
|
$legacyId = (string) ($userNode['legacy_id'] ?? '');
|
||||||
@@ -589,6 +592,13 @@ class XmlExchange extends Component
|
|||||||
|
|
||||||
DB::table('users')->where('id', $user->id)->update($meta);
|
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']++;
|
$stats['utenti_importati']++;
|
||||||
if ($legacyId !== '') {
|
if ($legacyId !== '') {
|
||||||
$userMap[$legacyId] = $user->id;
|
$userMap[$legacyId] = $user->id;
|
||||||
@@ -624,6 +634,7 @@ class XmlExchange extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
$proclamatoreMap = [];
|
$proclamatoreMap = [];
|
||||||
|
$gruppiDeiProclamatori = [];
|
||||||
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
|
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
|
||||||
$p = Proclamatore::create([
|
$p = Proclamatore::create([
|
||||||
'nome' => (string) $proclamatoreNode->nome,
|
'nome' => (string) $proclamatoreNode->nome,
|
||||||
@@ -634,14 +645,42 @@ class XmlExchange extends Component
|
|||||||
]);
|
]);
|
||||||
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
|
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
|
||||||
$stats['proclamatori_importati']++;
|
$stats['proclamatori_importati']++;
|
||||||
|
$gruppiDeiProclamatori[$p->id] = (string) ($proclamatoreNode->legacy_gruppo_id ?? '');
|
||||||
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
|
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
|
||||||
if ($legacyId !== '') {
|
if ($legacyId !== '') {
|
||||||
$proclamatoreMap[$legacyId] = $p->id;
|
$proclamatoreMap[$legacyId] = $p->id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$annoMap = [];
|
// Gruppi di servizio, dopo i proclamatori: il sorvegliante è uno di
|
||||||
foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
|
// 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;
|
$label = (string) $annoNode->label;
|
||||||
$anno = AnnoTeocratico::create([
|
$anno = AnnoTeocratico::create([
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
@@ -706,6 +745,7 @@ class XmlExchange extends Component
|
|||||||
'note' => (string) ($territorioNode->note ?? ''),
|
'note' => (string) ($territorioNode->note ?? ''),
|
||||||
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
||||||
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
|
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
|
||||||
|
'gruppo_servizio_id' => $gruppoMap[(string) ($territorioNode->legacy_gruppo_id ?? '')] ?? null,
|
||||||
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
|
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
|
||||||
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
|
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
|
||||||
]);
|
]);
|
||||||
@@ -768,6 +808,7 @@ class XmlExchange extends Component
|
|||||||
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
||||||
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
|
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
|
||||||
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 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,
|
'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,
|
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
|
||||||
'note' => (string) ($assegnazioneNode->note ?? ''),
|
'note' => (string) ($assegnazioneNode->note ?? ''),
|
||||||
@@ -824,16 +865,20 @@ class XmlExchange extends Component
|
|||||||
// remember_token no: all'import si azzera comunque, e nel file
|
// remember_token no: all'import si azzera comunque, e nel file
|
||||||
// sarebbe solo una credenziale in più per chi lo tiene.
|
// sarebbe solo una credenziale in più per chi lo tiene.
|
||||||
'roles' => $u->roles->pluck('name')->all(),
|
'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() ?? '',
|
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
||||||
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
||||||
])->all(),
|
])->all(),
|
||||||
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
'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']),
|
'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']),
|
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', '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']),
|
'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']),
|
'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']),
|
'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, 'email', (string) ($user['email'] ?? ''));
|
||||||
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
||||||
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
$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('created_at', (string) ($user['created_at'] ?? ''));
|
||||||
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
||||||
$rolesNode = $node->addChild('roles');
|
$rolesNode = $node->addChild('roles');
|
||||||
@@ -1265,6 +1312,7 @@ class XmlExchange extends Component
|
|||||||
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
|
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
|
||||||
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
|
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
|
||||||
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
|
$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'] ?? ''));
|
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
|
||||||
$this->addTimestampNodes($node, $proclamatore);
|
$this->addTimestampNodes($node, $proclamatore);
|
||||||
}
|
}
|
||||||
@@ -1280,6 +1328,7 @@ class XmlExchange extends Component
|
|||||||
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
|
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
|
||||||
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
|
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
|
||||||
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
|
$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, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
|
||||||
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
|
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
|
||||||
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
|
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
|
||||||
@@ -1296,6 +1345,17 @@ class XmlExchange extends Component
|
|||||||
$this->addTimestampNodes($node, $anno);
|
$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');
|
$campagneNode = $xml->addChild('campagne');
|
||||||
foreach ($dataset['campagne'] as $campagna) {
|
foreach ($dataset['campagne'] as $campagna) {
|
||||||
$node = $campagneNode->addChild('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('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
|
||||||
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 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('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, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
|
||||||
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
|
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
|
||||||
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class TerritorioIndex extends Component
|
|||||||
'tipologia',
|
'tipologia',
|
||||||
'assegnazioneCorrente.proclamatore',
|
'assegnazioneCorrente.proclamatore',
|
||||||
'ultimaAssegnazione',
|
'ultimaAssegnazione',
|
||||||
|
'gruppo',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->search) {
|
if ($this->search) {
|
||||||
@@ -179,6 +180,7 @@ class TerritorioIndex extends Component
|
|||||||
if ($this->filterStato) {
|
if ($this->filterStato) {
|
||||||
match ($this->filterStato) {
|
match ($this->filterStato) {
|
||||||
'in_reparto' => $query->inReparto(),
|
'in_reparto' => $query->inReparto(),
|
||||||
|
'in_gruppo' => $query->inGruppo(),
|
||||||
'assegnato' => $query->assegnato(),
|
'assegnato' => $query->assegnato(),
|
||||||
'da_rientrare' => $query->daRientrare(),
|
'da_rientrare' => $query->daRientrare(),
|
||||||
'inattivo' => $query->where('attivo', false),
|
'inattivo' => $query->where('attivo', false),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class TerritorioShow extends Component
|
|||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
|
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
|
||||||
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa'])
|
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa', 'gruppo'])
|
||||||
->orderByDesc('assigned_at')
|
->orderByDesc('assigned_at')
|
||||||
->get()
|
->get()
|
||||||
->groupBy(fn($a) => $a->annoTeocratico->label);
|
->groupBy(fn($a) => $a->annoTeocratico->label);
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ class Assegnazione extends Model
|
|||||||
'note',
|
'note',
|
||||||
'created_by',
|
'created_by',
|
||||||
'returned_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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -35,6 +42,7 @@ class Assegnazione extends Model
|
|||||||
'counted_in_campaign' => 'boolean',
|
'counted_in_campaign' => 'boolean',
|
||||||
'link_sent' => 'boolean',
|
'link_sent' => 'boolean',
|
||||||
'link_da_inviare' => 'boolean',
|
'link_da_inviare' => 'boolean',
|
||||||
|
'gruppo_servizio_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +64,20 @@ class Assegnazione extends Model
|
|||||||
$territorio->update(['prioritario' => false]);
|
$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
|
// Niente email né telefono (tolti il 19 ottobre 2026): il link della
|
||||||
// piantina lo manda chi assegna, copiandolo o con "Condividi", e il
|
// piantina lo manda chi assegna, copiandolo o con "Condividi", e il
|
||||||
// destinatario lo sceglie dai propri contatti.
|
// 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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
@@ -24,6 +36,7 @@ class Proclamatore extends Model
|
|||||||
'nome' => 'encrypted',
|
'nome' => 'encrypted',
|
||||||
'cognome' => 'encrypted',
|
'cognome' => 'encrypted',
|
||||||
'attivo' => 'boolean',
|
'attivo' => 'boolean',
|
||||||
|
'gruppo_servizio_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +108,22 @@ class Proclamatore extends Model
|
|||||||
$this->nome = 'Anonimo';
|
$this->nome = 'Anonimo';
|
||||||
$this->cognome = 'Proclamatore #' . $this->id;
|
$this->cognome = 'Proclamatore #' . $this->id;
|
||||||
$this->attivo = false;
|
$this->attivo = false;
|
||||||
|
$this->gruppo_servizio_id = null;
|
||||||
$this->save();
|
$this->save();
|
||||||
$this->delete(); // soft delete
|
$this->delete(); // soft delete
|
||||||
|
|
||||||
|
// L'utenza di un ex sorvegliante (già disattivata: il sorvegliante di
|
||||||
|
// un gruppo attivo non si anonimizza) porta nome ed email in chiaro.
|
||||||
|
$utente = $this->utente()->first();
|
||||||
|
if ($utente) {
|
||||||
|
$utente->syncRoles([]);
|
||||||
|
$utente->forceFill([
|
||||||
|
'name' => 'Anonimo',
|
||||||
|
'email' => "utente-{$utente->id}@anonimo.invalid",
|
||||||
|
'password' => \Illuminate\Support\Str::random(40),
|
||||||
|
'remember_token' => null,
|
||||||
|
'disattivato_at' => $utente->disattivato_at ?? now(),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,20 @@ class Territorio extends Model
|
|||||||
'thumbnail_path',
|
'thumbnail_path',
|
||||||
'attivo',
|
'attivo',
|
||||||
'prioritario',
|
'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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'attivo' => 'boolean',
|
'attivo' => 'boolean',
|
||||||
|
'gruppo_servizio_id' => 'integer',
|
||||||
'prioritario' => 'boolean',
|
'prioritario' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -95,9 +103,25 @@ class Territorio extends Model
|
|||||||
return 'assegnato';
|
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';
|
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
|
public function getAssegnatarioAttribute(): ?Proclamatore
|
||||||
{
|
{
|
||||||
return $this->assegnazioneCorrente?->proclamatore;
|
return $this->assegnazioneCorrente?->proclamatore;
|
||||||
@@ -172,12 +196,23 @@ class Territorio extends Model
|
|||||||
|
|
||||||
public function scopeInReparto($query)
|
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()
|
return $query->attivi()
|
||||||
|
->whereNull('gruppo_servizio_id')
|
||||||
->whereDoesntHave('assegnazioni', function ($q) {
|
->whereDoesntHave('assegnazioni', function ($q) {
|
||||||
$q->whereNull('returned_at');
|
$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)
|
public function scopeAssegnato($query)
|
||||||
{
|
{
|
||||||
return $query->attivi()
|
return $query->attivi()
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
'password_change_required',
|
'password_change_required',
|
||||||
|
'proclamatore_id',
|
||||||
|
'disattivato_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -37,9 +39,50 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'password_change_required' => 'boolean',
|
'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
|
public function getActivitylogOptions(): LogOptions
|
||||||
{
|
{
|
||||||
return LogOptions::defaults()
|
return LogOptions::defaults()
|
||||||
@@ -48,6 +91,32 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
->dontSubmitEmptyLogs();
|
->dontSubmitEmptyLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'utenza di un sorvegliante porta nome (ed email, spesso) di un
|
||||||
|
* proclamatore: nel registro delle attività i proclamatori compaiono solo
|
||||||
|
* con l'identificativo, quindi anche qui.
|
||||||
|
*/
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
|
||||||
|
{
|
||||||
|
$proclamatoreId = $this->proclamatore_id ?? $this->getOriginal('proclamatore_id');
|
||||||
|
if (! $proclamatoreId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activity->properties = collect($activity->properties)->map(function ($valori) use ($proclamatoreId) {
|
||||||
|
if (! is_array($valori)) {
|
||||||
|
return $valori;
|
||||||
|
}
|
||||||
|
foreach (['name', 'email'] as $campo) {
|
||||||
|
if (array_key_exists($campo, $valori)) {
|
||||||
|
$valori[$campo] = "proclamatore #{$proclamatoreId}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $valori;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
|
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
|
||||||
@@ -65,6 +134,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
*/
|
*/
|
||||||
public function inviaConfermaEmail(): bool
|
public function inviaConfermaEmail(): bool
|
||||||
{
|
{
|
||||||
|
if ($this->isSorvegliante()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->sendEmailVerificationNotification();
|
$this->sendEmailVerificationNotification();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
272
app/Services/GruppiServizio.php
Normal file
272
app/Services/GruppiServizio.php
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\AnnoTeocratico;
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le regole dei gruppi di servizio, in un posto solo: le usano le pagine
|
||||||
|
* dell'amministratore (Gruppi di servizio) e quella del sorvegliante (Il mio
|
||||||
|
* gruppo). Ogni metodo lancia InvalidArgumentException con un messaggio da
|
||||||
|
* mostrare così com'è quando una regola non è rispettata.
|
||||||
|
*/
|
||||||
|
class GruppiServizio
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Crea il gruppo e l'utenza del sorvegliante: ruolo "sorvegliante",
|
||||||
|
* password temporanea da cambiare al primo accesso, nessuna conferma
|
||||||
|
* dell'email. Restituisce il gruppo e la password temporanea, da mostrare
|
||||||
|
* una volta sola all'amministratore.
|
||||||
|
*
|
||||||
|
* @param int[] $membri id dei proclamatori, oltre al sorvegliante
|
||||||
|
* @return array{0: GruppoServizio, 1: string}
|
||||||
|
*/
|
||||||
|
public function crea(string $nome, Proclamatore $sorvegliante, string $email, array $membri = []): array
|
||||||
|
{
|
||||||
|
$nome = trim($nome);
|
||||||
|
if ($nome === '') {
|
||||||
|
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
|
||||||
|
}
|
||||||
|
if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
|
||||||
|
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
|
||||||
|
}
|
||||||
|
if ($sorvegliante->gruppo_servizio_id) {
|
||||||
|
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
|
||||||
|
}
|
||||||
|
// Chi è stato sorvegliante di un gruppo sciolto ha già un'utenza,
|
||||||
|
// disattivata: si riattiva quella (un proclamatore, un'utenza).
|
||||||
|
$esistente = $sorvegliante->utente()->first();
|
||||||
|
if ($esistente && ! $esistente->isDisattivato()) {
|
||||||
|
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
|
||||||
|
}
|
||||||
|
if (User::where('email', $email)->when($esistente, fn ($q) => $q->whereKeyNot($esistente->id))->exists()) {
|
||||||
|
throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = Str::password(12, symbols: false);
|
||||||
|
|
||||||
|
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password, $esistente) {
|
||||||
|
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
|
||||||
|
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||||
|
$this->aggiungiMembri($gruppo, $membri);
|
||||||
|
|
||||||
|
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
|
||||||
|
$utente->forceFill([
|
||||||
|
'name' => $sorvegliante->nome_completo,
|
||||||
|
'email' => $email,
|
||||||
|
'password' => $password,
|
||||||
|
'password_change_required' => true,
|
||||||
|
'disattivato_at' => null,
|
||||||
|
])->save();
|
||||||
|
$utente->syncRoles(['sorvegliante']);
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||||
|
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
|
||||||
|
->log('gruppo_creato');
|
||||||
|
|
||||||
|
return $gruppo;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [$gruppo, $password];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param int[] $ids */
|
||||||
|
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
|
||||||
|
{
|
||||||
|
$this->attivo($gruppo);
|
||||||
|
|
||||||
|
foreach (Proclamatore::whereIn('id', $ids)->get() as $p) {
|
||||||
|
if ($p->gruppo_servizio_id && $p->gruppo_servizio_id !== $gruppo->id) {
|
||||||
|
throw new InvalidArgumentException("{$p->nome_completo} fa già parte di un altro gruppo.");
|
||||||
|
}
|
||||||
|
if (! $p->attivo) {
|
||||||
|
throw new InvalidArgumentException("{$p->nome_completo} non è attivo.");
|
||||||
|
}
|
||||||
|
$p->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function togliMembro(GruppoServizio $gruppo, Proclamatore $membro): void
|
||||||
|
{
|
||||||
|
$this->attivo($gruppo);
|
||||||
|
|
||||||
|
if ($membro->id === $gruppo->sorvegliante_id) {
|
||||||
|
throw new InvalidArgumentException('Il sorvegliante non si toglie: per cambiarlo si scioglie il gruppo.');
|
||||||
|
}
|
||||||
|
if ($membro->gruppo_servizio_id === $gruppo->id) {
|
||||||
|
$membro->update(['gruppo_servizio_id' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mette nel pacchetto del gruppo dei territori in reparto (attivi, non
|
||||||
|
* assegnati, non già in un altro pacchetto). Restituisce quanti.
|
||||||
|
*
|
||||||
|
* @param int[] $ids
|
||||||
|
*/
|
||||||
|
public function aggiungiAlPacchetto(GruppoServizio $gruppo, array $ids): int
|
||||||
|
{
|
||||||
|
$this->attivo($gruppo);
|
||||||
|
|
||||||
|
$territori = Territorio::whereIn('id', $ids)->get();
|
||||||
|
foreach ($territori as $t) {
|
||||||
|
if (! $t->attivo) {
|
||||||
|
throw new InvalidArgumentException("Il territorio {$t->numero} non è attivo.");
|
||||||
|
}
|
||||||
|
if ($t->gruppo_servizio_id && $t->gruppo_servizio_id !== $gruppo->id) {
|
||||||
|
throw new InvalidArgumentException("Il territorio {$t->numero} è già nel pacchetto di un altro gruppo.");
|
||||||
|
}
|
||||||
|
if ($t->assegnazioneCorrente) {
|
||||||
|
throw new InvalidArgumentException("Il territorio {$t->numero} è assegnato: va prima fatto rientrare.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Territorio::whereKey($territori->pluck('id'))->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||||
|
->withProperties(['territori' => $territori->pluck('numero')->all()])
|
||||||
|
->log('pacchetto_aggiunti');
|
||||||
|
|
||||||
|
return $territori->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rimanda in reparto un territorio del pacchetto non ancora dato a
|
||||||
|
* nessuno (lo fa l'amministratore o il sorvegliante).
|
||||||
|
*/
|
||||||
|
public function rimandaInReparto(GruppoServizio $gruppo, Territorio $territorio): void
|
||||||
|
{
|
||||||
|
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
|
||||||
|
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
|
||||||
|
}
|
||||||
|
if ($territorio->assegnazioneCorrente) {
|
||||||
|
throw new InvalidArgumentException("Il territorio {$territorio->numero} è assegnato: prima va registrato il rientro.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$territorio->update(['gruppo_servizio_id' => null]);
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||||
|
->withProperties(['territorio' => $territorio->numero])
|
||||||
|
->log('pacchetto_rimandato_in_reparto');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il sorvegliante dà un territorio del pacchetto a un membro (o a sé):
|
||||||
|
* nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il
|
||||||
|
* territorio resta nel pacchetto finché non rientra.
|
||||||
|
*/
|
||||||
|
public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data): Assegnazione
|
||||||
|
{
|
||||||
|
$this->attivo($gruppo);
|
||||||
|
|
||||||
|
// Si decide sui dati di adesso, non su quelli con cui è partita la
|
||||||
|
// pagina: nel frattempo l'amministratore può aver cambiato pacchetto
|
||||||
|
// o membri.
|
||||||
|
$territorio->refresh();
|
||||||
|
$membro->refresh();
|
||||||
|
|
||||||
|
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
|
||||||
|
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
|
||||||
|
}
|
||||||
|
if ($membro->gruppo_servizio_id !== $gruppo->id) {
|
||||||
|
throw new InvalidArgumentException("{$membro->nome_completo} non fa parte del gruppo.");
|
||||||
|
}
|
||||||
|
if (! $membro->attivo) {
|
||||||
|
throw new InvalidArgumentException("{$membro->nome_completo} non è attivo.");
|
||||||
|
}
|
||||||
|
if ($data->isFuture()) {
|
||||||
|
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data) {
|
||||||
|
$bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first();
|
||||||
|
if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) {
|
||||||
|
return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$conflitto = Assegnazione::sovrappostaA($territorio->id, $data);
|
||||||
|
if ($conflitto) {
|
||||||
|
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Assegnazione::create([
|
||||||
|
'territorio_id' => $territorio->id,
|
||||||
|
'proclamatore_id' => $membro->id,
|
||||||
|
'anno_teocratico_id' => AnnoTeocratico::perData($data)->id,
|
||||||
|
'assigned_at' => $data,
|
||||||
|
'created_by' => auth()->id(),
|
||||||
|
'gruppo_servizio_id' => $gruppo->id,
|
||||||
|
'link_da_inviare' => (bool) $territorio->pdf_path,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (is_string($esito)) {
|
||||||
|
throw new InvalidArgumentException($esito);
|
||||||
|
}
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($esito)
|
||||||
|
->withProperties([
|
||||||
|
'territorio' => $territorio->numero,
|
||||||
|
'proclamatore_id' => $membro->id,
|
||||||
|
'gruppo_servizio_id' => $gruppo->id,
|
||||||
|
])
|
||||||
|
->log('assigned');
|
||||||
|
|
||||||
|
return $esito;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scioglie il gruppo (si toglie il ruolo di sorvegliante): i territori del
|
||||||
|
* pacchetto non ancora dati tornano in reparto, i membri restano senza
|
||||||
|
* gruppo, l'utenza del sorvegliante si disattiva. Le assegnazioni già
|
||||||
|
* fatte restano com'erano, con il loro riferimento al gruppo, che resta
|
||||||
|
* nello storico come sciolto.
|
||||||
|
*/
|
||||||
|
public function sciogli(GruppoServizio $gruppo): void
|
||||||
|
{
|
||||||
|
if ($gruppo->sciolto_at) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($gruppo) {
|
||||||
|
// Esce dal pacchetto solo quello non ancora dato: un territorio
|
||||||
|
// assegnato resta del suo gruppo finché non rientra (Assegnazione
|
||||||
|
// lo libera al rientro), così non può essere ridato due volte.
|
||||||
|
$nonDati = Territorio::withTrashed()
|
||||||
|
->where('gruppo_servizio_id', $gruppo->id)
|
||||||
|
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
|
||||||
|
$liberati = (clone $nonDati)->pluck('numero');
|
||||||
|
$nonDati->update(['gruppo_servizio_id' => null]);
|
||||||
|
|
||||||
|
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||||
|
|
||||||
|
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
|
||||||
|
if ($utente) {
|
||||||
|
$utente->removeRole('sorvegliante');
|
||||||
|
$utente->forceFill(['disattivato_at' => now(), 'remember_token' => null])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$gruppo->update(['sciolto_at' => now()]);
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||||
|
->withProperties(['territori_liberati' => $liberati->all(), 'utente_disattivato' => $utente?->id])
|
||||||
|
->log('gruppo_sciolto');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attivo(GruppoServizio $gruppo): void
|
||||||
|
{
|
||||||
|
if ($gruppo->sciolto_at) {
|
||||||
|
throw new InvalidArgumentException('Il gruppo è stato sciolto: creane uno nuovo.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
\App\Http\Middleware\RequirePasswordChange::class,
|
\App\Http\Middleware\RequirePasswordChange::class,
|
||||||
|
// Utenze disattivate fuori; il sorvegliante di gruppo solo nel suo gruppo.
|
||||||
|
\App\Http\Middleware\LimitaSorvegliante::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->alias([
|
$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',
|
'territori.return',
|
||||||
'audit.view',
|
'audit.view',
|
||||||
'audit.export',
|
'audit.export',
|
||||||
|
'gruppo.gestisci',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($permissions as $permission) {
|
foreach ($permissions as $permission) {
|
||||||
@@ -43,7 +44,9 @@ class RolesAndPermissionsSeeder extends Seeder
|
|||||||
|
|
||||||
// Amministratore: all permissions
|
// Amministratore: all permissions
|
||||||
$admin = Role::firstOrCreate(['name' => 'amministratore']);
|
$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: proclamatori, campagne, assign, return
|
||||||
$assistente = Role::firstOrCreate(['name' => 'assistente']);
|
$assistente = Role::firstOrCreate(['name' => 'assistente']);
|
||||||
@@ -61,5 +64,10 @@ class RolesAndPermissionsSeeder extends Seeder
|
|||||||
'territori.assign',
|
'territori.assign',
|
||||||
'territori.return',
|
'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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
|
||||||
# imposta solo per provare in locale una versione diversa.
|
|
||||||
args:
|
|
||||||
- PHP_VERSION
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
@@ -50,10 +46,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
|
||||||
# imposta solo per provare in locale una versione diversa.
|
|
||||||
args:
|
|
||||||
- PHP_VERSION
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: www-data
|
user: www-data
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -97,10 +89,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
|
||||||
# imposta solo per provare in locale una versione diversa.
|
|
||||||
args:
|
|
||||||
- PHP_VERSION
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: www-data
|
user: www-data
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -219,10 +207,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
|
||||||
# imposta solo per provare in locale una versione diversa.
|
|
||||||
args:
|
|
||||||
- PHP_VERSION
|
|
||||||
# Senza questo si costruirebbe l'immagine di produzione, che essendo
|
# Senza questo si costruirebbe l'immagine di produzione, che essendo
|
||||||
# installata senza dipendenze di sviluppo non contiene phpunit.
|
# installata senza dipendenze di sviluppo non contiene phpunit.
|
||||||
target: test
|
target: test
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Versione di PHP, fissata alla patch. Va dichiarata prima del primo FROM per
|
|
||||||
# poterla usare nel FROM dello stage `base`.
|
|
||||||
#
|
|
||||||
# Fissata e non "8.3": quel tag si sposta, ma Docker tiene in cache la base già
|
|
||||||
# scaricata, e una ricostruzione senza --pull (anche su Dokploy) poteva uscire
|
|
||||||
# con un PHP vecchio senza che nessuno se ne accorgesse. Con la patch scritta
|
|
||||||
# qui, una build o prende questa versione o fallisce.
|
|
||||||
#
|
|
||||||
# 8.3.36: corregge CVE-2026-109618 e CVE-2026-109619 (esecuzione di codice da
|
|
||||||
# remoto nel core di PHP, CVSS 9.2, rilascio del 21/10/2026). Non scendere
|
|
||||||
# sotto. Aggiornala a ogni rilascio di sicurezza del ramo 8.3.
|
|
||||||
#
|
|
||||||
# Solo per provare in locale prima che l'immagine corretta sia su Docker Hub:
|
|
||||||
# PHP_VERSION=8.3.35 docker compose ... (mai per un deploy).
|
|
||||||
ARG PHP_VERSION=8.3.36
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 1 — PHP dependencies (no dev, production autoloader)
|
# Stage 1 — PHP dependencies (no dev, production autoloader)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -81,7 +65,7 @@ RUN npm run build
|
|||||||
# estensioni e sullo stesso php.ini della produzione: un test che passa qui
|
# estensioni e sullo stesso php.ini della produzione: un test che passa qui
|
||||||
# non puo' passare grazie a un ambiente diverso da quello vero.
|
# non puo' passare grazie a un ambiente diverso da quello vero.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM php:${PHP_VERSION}-fpm AS base
|
FROM php:8.3-fpm AS base
|
||||||
|
|
||||||
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container
|
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container
|
||||||
# would need its own copy of public/ plus the storage volume just to serve
|
# would need its own copy of public/ plus the storage volume just to serve
|
||||||
@@ -92,11 +76,6 @@ FROM php:${PHP_VERSION}-fpm AS base
|
|||||||
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
|
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
|
||||||
# purging means pinning package names that break whenever the php base image
|
# purging means pinning package names that break whenever the php base image
|
||||||
# moves to a new Debian. A few tens of MB is worth not having that breakage.
|
# moves to a new Debian. A few tens of MB is worth not having that breakage.
|
||||||
#
|
|
||||||
# phpredis si scarica da GitHub, versione fissata, e non con `pecl install`:
|
|
||||||
# il 21/10/2026, giorno della patch di sicurezza di PHP, pecl.php.net
|
|
||||||
# rispondeva 504 e la build falliva proprio quando bisognava aggiornare.
|
|
||||||
ARG PHPREDIS_VERSION=6.3.0
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
@@ -122,13 +101,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
intl \
|
intl \
|
||||||
zip \
|
zip \
|
||||||
opcache \
|
opcache \
|
||||||
&& mkdir -p /usr/src/php/ext/redis \
|
&& pecl install redis \
|
||||||
&& curl -fsSL --retry 5 --retry-delay 5 \
|
&& docker-php-ext-enable redis \
|
||||||
"https://github.com/phpredis/phpredis/archive/refs/tags/${PHPREDIS_VERSION}.tar.gz" \
|
|
||||||
| tar xz --strip-components=1 -C /usr/src/php/ext/redis \
|
|
||||||
&& echo redis >> /usr/src/php-available-exts \
|
|
||||||
&& docker-php-ext-install -j"$(nproc)" redis \
|
|
||||||
&& docker-php-source delete \
|
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,6 @@ memory_limit = 256M
|
|||||||
max_execution_time = 120
|
max_execution_time = 120
|
||||||
max_input_vars = 3000
|
max_input_vars = 3000
|
||||||
|
|
||||||
[Session]
|
|
||||||
; Mitigazione di CVE-2026-109618/109619 (RCE nel core di PHP, 21/10/2026),
|
|
||||||
; come indicato da Sansec in attesa della patch, e da tenere anche dopo:
|
|
||||||
; l'avanzamento degli upload nelle sessioni native di PHP non serve
|
|
||||||
; all'applicazione (Laravel ha le sue sessioni, in Redis, e Livewire non lo
|
|
||||||
; usa) ed è il punto da cui passano questi attacchi. Lo strict mode vale solo
|
|
||||||
; per le sessioni native, che qui non si usano: costa niente.
|
|
||||||
session.upload_progress.enabled = 0
|
|
||||||
session.use_strict_mode = 1
|
|
||||||
|
|
||||||
[opcache]
|
[opcache]
|
||||||
opcache.enable = 1
|
opcache.enable = 1
|
||||||
opcache.memory_consumption = 128
|
opcache.memory_consumption = 128
|
||||||
|
|||||||
@@ -79,11 +79,24 @@
|
|||||||
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
<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">
|
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">
|
<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') }}"
|
<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' }}">
|
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>
|
<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
|
Home
|
||||||
</a>
|
</a>
|
||||||
|
@endunless
|
||||||
|
|
||||||
@can('territori.assign')
|
@can('territori.assign')
|
||||||
<a href="{{ route('territori.index') }}"
|
<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>
|
<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
|
Utenti
|
||||||
</a>
|
</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') }}"
|
<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' }}">
|
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>
|
<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>
|
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Invio del link (Assegna::apriInvio): blocca la pagina finché non si
|
@include('livewire.assegnazioni._invio-link')
|
||||||
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
|
|
||||||
|
|
||||||
<div x-data="{ formOpen: true }">
|
<div x-data="{ formOpen: true }">
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<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>
|
<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>
|
</a>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3 pt-4">
|
<div class="flex flex-wrap items-center gap-3 pt-4">
|
||||||
<x-btn variante="primary" type="submit">Registra rientro</x-btn>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
134
resources/views/livewire/gruppi/gruppo-show.blade.php
Normal file
134
resources/views/livewire/gruppi/gruppo-show.blade.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="{{ $ritorno ?? route('gruppi.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
{{ $ritorno ? 'Torna indietro' : 'Gruppi di servizio' }}
|
||||||
|
</a>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong>
|
||||||
|
@if($attivo && $gruppo->sorvegliante?->utente)
|
||||||
|
· accede con <code>{{ $gruppo->sorvegliante->utente->email }}</code>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
@unless($attivo)
|
||||||
|
<p class="mt-2 inline-flex rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700">
|
||||||
|
Sciolto il {{ $gruppo->sciolto_at->format('d/m/Y') }} · {{ $assegnazioniDelGruppo }} assegnazioni nate dal gruppo restano nello storico
|
||||||
|
</p>
|
||||||
|
@endunless
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($passwordTemporanea)
|
||||||
|
<div class="mb-6 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
|
||||||
|
<p>Nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}, da cambiare al primo accesso. Si vede <strong>solo ora</strong>:</p>
|
||||||
|
<p class="mt-2"><code class="select-all text-base">{{ $passwordTemporanea }}</code></p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($attivo)
|
||||||
|
{{-- ─── Pacchetto ─────────────────────────────────────────── --}}
|
||||||
|
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-pacchetto">
|
||||||
|
<h2 id="titolo-pacchetto" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">
|
||||||
|
Pacchetto ({{ $territori->count() }} territori)
|
||||||
|
</h2>
|
||||||
|
<ul class="divide-y divide-gray-100">
|
||||||
|
@forelse($territori as $t)
|
||||||
|
@php($a = $t->assegnazioneCorrente)
|
||||||
|
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-2" wire:key="pacchetto-{{ $t->id }}">
|
||||||
|
<span class="text-sm">
|
||||||
|
<a data-ritorno href="{{ route('territori.show', $t) }}" class="font-semibold text-indigo-600 hover:underline">N° {{ $t->numero }}</a>
|
||||||
|
<span class="text-gray-500">{{ $t->zona?->nome }}</span>
|
||||||
|
—
|
||||||
|
@if($a)
|
||||||
|
<span class="text-gray-700">a {{ $a->proclamatore?->nome_completo }} dal {{ $a->assigned_at->format('d/m/Y') }}</span>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-700">da dare</span>
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@unless($a)
|
||||||
|
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="rimanda({{ $t->id }})"
|
||||||
|
wire:confirm="Rimandare in reparto il territorio {{ $t->numero }}?">Rimanda in reparto</x-btn>
|
||||||
|
@endunless
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="px-4 py-6 text-center text-sm text-gray-500">Il pacchetto è vuoto.</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{{-- Assegnazione in blocco: si spuntano i territori in reparto --}}
|
||||||
|
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi territori al pacchetto</h3>
|
||||||
|
@if($candidatiTerritori->isEmpty())
|
||||||
|
<p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p>
|
||||||
|
@else
|
||||||
|
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i territori in reparto"
|
||||||
|
placeholder="Filtra per numero o zona…"
|
||||||
|
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
|
||||||
|
@foreach($candidatiTerritori as $c)
|
||||||
|
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40"
|
||||||
|
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->numero . ' ' . $c->zona?->nome)) }}"
|
||||||
|
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-t-{{ $c->id }}">
|
||||||
|
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviTerritori" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
|
||||||
|
<span><strong>N° {{ $c->numero }}</strong> <span class="text-gray-500">{{ $c->zona?->nome }}</span></span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<x-btn variante="primary" wire:click="aggiungiTerritori">Aggiungi i territori selezionati</x-btn>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- ─── Membri ───────────────────────────────────────────── --}}
|
||||||
|
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-membri">
|
||||||
|
<h2 id="titolo-membri" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">Membri ({{ $membri->count() }})</h2>
|
||||||
|
<ul class="divide-y divide-gray-100">
|
||||||
|
@foreach($membri as $m)
|
||||||
|
<li class="px-4 py-2.5 flex items-center justify-between gap-2 text-sm" wire:key="membro-{{ $m->id }}">
|
||||||
|
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
|
||||||
|
@if($m->id !== $gruppo->sorvegliante_id)
|
||||||
|
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="togliMembro({{ $m->id }})">Togli</x-btn>
|
||||||
|
@endif
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi membri</h3>
|
||||||
|
@if($candidatiMembri->isEmpty())
|
||||||
|
<p class="text-sm text-gray-500">Tutti i proclamatori attivi fanno già parte di un gruppo.</p>
|
||||||
|
@else
|
||||||
|
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i proclamatori"
|
||||||
|
placeholder="Filtra per nome…"
|
||||||
|
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
|
||||||
|
@foreach($candidatiMembri as $c)
|
||||||
|
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40"
|
||||||
|
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->nome_completo)) }}"
|
||||||
|
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-p-{{ $c->id }}">
|
||||||
|
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviMembri" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
|
||||||
|
<span>{{ $c->nome_completo }}</span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<x-btn variante="primary" wire:click="aggiungiMembri">Aggiungi i membri selezionati</x-btn>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- ─── Sorvegliante ─────────────────────────────────────── --}}
|
||||||
|
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-4" aria-labelledby="titolo-sorvegliante">
|
||||||
|
<h2 id="titolo-sorvegliante" class="text-sm font-semibold text-gray-800 mb-2">Sorvegliante</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-btn variante="secondary" wire:click="nuovaPassword"
|
||||||
|
wire:confirm="Generare una nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
|
||||||
|
<x-btn variante="danger" wire:click="sciogli"
|
||||||
|
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene disattivata. Le assegnazioni già fatte restano.">Togli il ruolo e sciogli il gruppo</x-btn>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
123
resources/views/livewire/gruppi/mio-gruppo.blade.php
Normal file
123
resources/views/livewire/gruppi/mio-gruppo.blade.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<div>
|
||||||
|
{{-- Il mio gruppo (MioGruppo): la sola pagina del sorvegliante di gruppo.
|
||||||
|
Pensata prima per il telefono: schede una sotto l'altra, pulsanti
|
||||||
|
grandi. Copia, Condividi e ricerca vengono da elencoCercabile
|
||||||
|
(resources/js/app.js), l'invio del link dal partial _invio-link. --}}
|
||||||
|
@if(! $gruppo)
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-sm text-gray-600">
|
||||||
|
Non sei il sorvegliante di un gruppo di servizio attivo.
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mb-5">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
|
||||||
|
{{-- In questa vista solo blocchi php…endphp: la forma in linea, se
|
||||||
|
viene prima di un blocco, confonde il compilatore di Blade. --}}
|
||||||
|
@php $daDare = $territori->filter(fn ($t) => ! $t->assegnazioneCorrente)->count(); @endphp
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ $territori->count() }} {{ $territori->count() === 1 ? 'territorio' : 'territori' }} nel pacchetto ·
|
||||||
|
{{ $daDare }} da dare · {{ $membri->count() }} {{ $membri->count() === 1 ? 'membro' : 'membri' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@include('livewire.assegnazioni._invio-link')
|
||||||
|
|
||||||
|
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" x-data="elencoCercabile" aria-labelledby="titolo-pacchetto">
|
||||||
|
<svg aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||||
|
<symbol id="tm-i-copia" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></symbol>
|
||||||
|
<symbol id="tm-i-spunta" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></symbol>
|
||||||
|
<symbol id="tm-i-condividi" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></symbol>
|
||||||
|
<symbol id="tm-i-avviso" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></symbol>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="px-4 py-3 border-b border-gray-100 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 id="titolo-pacchetto" class="text-sm font-semibold text-gray-800">Territori del pacchetto</h2>
|
||||||
|
@if($territori->count() > 5)
|
||||||
|
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto"
|
||||||
|
placeholder="Cerca numero, zona o nome…"
|
||||||
|
class="w-full sm:w-64 rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
@forelse($territori as $t)
|
||||||
|
@php
|
||||||
|
$a = $t->assegnazioneCorrente;
|
||||||
|
$pdfUrl = $a?->shortPdfUrl();
|
||||||
|
$cerca = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii(implode(' ', array_filter([$t->numero, $t->zona?->nome, $a?->proclamatore?->nome_completo]))));
|
||||||
|
@endphp
|
||||||
|
<div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
|
||||||
|
<div class="tm-assegnato-testa">
|
||||||
|
<span><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif</span>
|
||||||
|
@if($a)
|
||||||
|
<span class="tm-assegnato-info"><b>{{ $a->proclamatore?->nome_completo }}</b> · dal {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
|
||||||
|
@else
|
||||||
|
<span class="tm-assegnato-info"><b>Da dare</b></span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($a?->link_in_sospeso)
|
||||||
|
<div class="tm-assegnato-sospeso" role="status"><svg aria-hidden="true"><use href="#tm-i-avviso"/></svg> Link ancora da inviare <x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="apriInvio({{ $a->id }})">Invia link</x-btn></div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($a)
|
||||||
|
{{-- Assegnato: rientro, link (copia / condividi), stato dell'invio --}}
|
||||||
|
<div class="tm-assegnato-link" @if($pdfUrl) data-link data-id="{{ $a->id }}" data-messaggio="{{ $a->messaggioLink($pdfUrl) }}" data-titolo="Territorio N° {{ $t->numero }}" @endif>
|
||||||
|
<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a)">Rientro</x-btn>
|
||||||
|
@if($pdfUrl)
|
||||||
|
<code x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
|
||||||
|
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="tm-copia" x-on:click="copia($el)" title="Copia link" aria-label="Copia il link della piantina"><svg aria-hidden="true"><use href="#tm-i-copia"/></svg><svg aria-hidden="true"><use href="#tm-i-spunta"/></svg></x-btn>
|
||||||
|
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona x-show="condivisibile" x-cloak x-on:click="condividi($el)" title="Condividi il link" aria-label="Condividi il link della piantina"><svg aria-hidden="true"><use href="#tm-i-condividi"/></svg></x-btn>
|
||||||
|
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" wire:click="toggleLinkSent({{ $a->id }})" aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}">{{ $a->link_sent ? 'Link inviato' : 'Non inviato' }}</x-btn>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@elseif($territorioDaAssegnare === $t->id)
|
||||||
|
{{-- Da dare: a chi e da quando --}}
|
||||||
|
<div class="mt-3 rounded-lg border border-indigo-100 bg-indigo-50/50 p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label for="membro-{{ $t->id }}" class="block text-sm font-medium text-gray-700 mb-1">A chi lo dai?</label>
|
||||||
|
<x-scelta id="membro-{{ $t->id }}" wire:model="membroId" :opzioni="$opzioniMembri" segnaposto="Cognome o nome" :vuoto="null" />
|
||||||
|
@error('membroId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="data-{{ $t->id }}" class="block text-sm font-medium text-gray-700 mb-1">Data</label>
|
||||||
|
<input wire:model="dataAssegnazione" type="date" id="data-{{ $t->id }}" max="{{ now()->format('Y-m-d') }}"
|
||||||
|
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
@error('dataAssegnazione') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-btn variante="primary" wire:click="assegna">Assegna</x-btn>
|
||||||
|
<x-btn variante="ghost" wire:click="annullaAssegnazione">Annulla</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="tm-assegnato-link">
|
||||||
|
<x-btn variante="primary" dimensione="sm" wire:click="apriAssegnazione({{ $t->id }})">Assegna</x-btn>
|
||||||
|
@if($t->pdf_path)
|
||||||
|
<x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('mio-gruppo.pdf', $t)" target="_blank" rel="noopener">Piantina</x-btn>
|
||||||
|
@endif
|
||||||
|
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="rimanda({{ $t->id }})"
|
||||||
|
wire:confirm="Rimandare in reparto il territorio {{ $t->numero }}? Uscirà dal pacchetto del gruppo.">Rimanda in reparto</x-btn>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="px-4 py-8 text-center text-sm text-gray-500">Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo.</p>
|
||||||
|
@endforelse
|
||||||
|
<p x-show="nessuno" x-cloak class="px-4 py-8 text-center text-sm text-gray-400">Nessun risultato per «<span x-text="q"></span>».</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-5 bg-white rounded-xl shadow-sm border border-gray-200 p-4" aria-labelledby="titolo-membri">
|
||||||
|
<h2 id="titolo-membri" class="text-sm font-semibold text-gray-800 mb-2">Membri del gruppo</h2>
|
||||||
|
<ul class="text-sm text-gray-700 space-y-1">
|
||||||
|
@foreach($membri as $m)
|
||||||
|
@php $quanti = $territori->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp
|
||||||
|
<li class="flex justify-between gap-3">
|
||||||
|
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
|
||||||
|
<span class="text-gray-500">{{ $quanti }} {{ $quanti === 1 ? 'territorio' : 'territori' }}</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Dati trattati</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-2">Dati trattati</h2>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<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>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>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>
|
<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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
|
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</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>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile.</li>
|
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile, insieme a nome ed email dell'utenza se è stato sorvegliante di un gruppo.</li>
|
||||||
<li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li>
|
<li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li>
|
||||||
<li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li>
|
<li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li>
|
||||||
<li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
|
<li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
|
||||||
<td data-label="Rientrato" class="px-3 py-2">
|
<td data-label="Rientrato" class="px-3 py-2">
|
||||||
@if($a->returned_at)
|
@if($a->returned_at)
|
||||||
|
|||||||
@@ -129,6 +129,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 cell-actions">
|
<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">
|
<div class="flex gap-2">
|
||||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
|
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
|
||||||
@unless($user->hasVerifiedEmail())
|
@unless($user->hasVerifiedEmail())
|
||||||
@@ -145,6 +151,7 @@
|
|||||||
Elimina
|
Elimina
|
||||||
</x-btn>
|
</x-btn>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</td>
|
</td>
|
||||||
@endif
|
@endif
|
||||||
</tr>
|
</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">
|
<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="">Tutti gli stati</option>
|
||||||
<option value="in_reparto">In reparto</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="prioritari">Prioritari</option>
|
||||||
<option value="assegnato">Assegnato</option>
|
<option value="assegnato">Assegnato</option>
|
||||||
<option value="da_rientrare">Da rientrare</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
|
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
{{ match($stato) {
|
{{ match($stato) {
|
||||||
'in_reparto' => 'bg-green-100 text-green-800',
|
'in_reparto' => 'bg-green-100 text-green-800',
|
||||||
|
'in_gruppo' => 'bg-purple-100 text-purple-800',
|
||||||
'assegnato' => 'bg-blue-100 text-blue-800',
|
'assegnato' => 'bg-blue-100 text-blue-800',
|
||||||
'da_rientrare' => 'bg-red-100 text-red-800',
|
'da_rientrare' => 'bg-red-100 text-red-800',
|
||||||
'inattivo' => 'bg-gray-100 text-gray-600',
|
'inattivo' => 'bg-gray-100 text-gray-600',
|
||||||
default => 'bg-gray-100 text-gray-600'
|
default => 'bg-gray-100 text-gray-600'
|
||||||
} }}">
|
} }}">
|
||||||
{{ str_replace('_', ' ', ucfirst($stato)) }}
|
{{ $territorio->stato_etichetta }}
|
||||||
</span>
|
</span>
|
||||||
@if($territorio->is_prioritario)
|
@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">
|
<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
|
<span class="inline-flex items-center mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
{{ match($stato) {
|
{{ match($stato) {
|
||||||
'in_reparto' => 'bg-green-100 text-green-800',
|
'in_reparto' => 'bg-green-100 text-green-800',
|
||||||
|
'in_gruppo' => 'bg-purple-100 text-purple-800',
|
||||||
'assegnato' => 'bg-blue-100 text-blue-800',
|
'assegnato' => 'bg-blue-100 text-blue-800',
|
||||||
'da_rientrare' => 'bg-red-100 text-red-800',
|
'da_rientrare' => 'bg-red-100 text-red-800',
|
||||||
'inattivo' => 'bg-gray-100 text-gray-600',
|
'inattivo' => 'bg-gray-100 text-gray-600',
|
||||||
default => 'bg-gray-100 text-gray-600'
|
default => 'bg-gray-100 text-gray-600'
|
||||||
} }}">
|
} }}">
|
||||||
{{ str_replace('_', ' ', ucfirst($stato)) }}
|
{{ $territorio->stato_etichetta }}
|
||||||
</span>
|
</span>
|
||||||
@if($territorio->is_prioritario)
|
@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>
|
<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">
|
<tbody class="divide-y divide-gray-100">
|
||||||
@foreach($assegnazioni as $assegnazione)
|
@foreach($assegnazioni as $assegnazione)
|
||||||
<tr>
|
<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="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||||
<td data-label="Rientrato" class="px-3 py-2">
|
<td data-label="Rientrato" class="px-3 py-2">
|
||||||
@if($assegnazione->returned_at)
|
@if($assegnazione->returned_at)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ use App\Livewire\Proclamatori\ProclamatoreEdit;
|
|||||||
use App\Livewire\Proclamatori\ProclamatoreCestino;
|
use App\Livewire\Proclamatori\ProclamatoreCestino;
|
||||||
use App\Livewire\Assegnazioni\Assegna;
|
use App\Livewire\Assegnazioni\Assegna;
|
||||||
use App\Livewire\Assegnazioni\Rientra;
|
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\CampagnaIndex;
|
||||||
use App\Livewire\Campagne\CampagnaCreate;
|
use App\Livewire\Campagne\CampagnaCreate;
|
||||||
use App\Livewire\Campagne\CampagnaShow;
|
use App\Livewire\Campagne\CampagnaShow;
|
||||||
@@ -163,10 +166,20 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::middleware('permission:territori.assign')->group(function () {
|
Route::middleware('permission:territori.assign')->group(function () {
|
||||||
Route::get('assegnazioni/assegna/{territorioId?}', Assegna::class)->name('assegnazioni.assegna');
|
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');
|
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
|
// Campagne — visualizzazione per chi ha campagne.manage; sola lettura per i
|
||||||
// non-admin.
|
// non-admin.
|
||||||
Route::middleware('permission:campagne.manage')->group(function () {
|
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::middleware('permission:settings.manage')->group(function () {
|
||||||
Route::get('impostazioni', SettingsEdit::class)->name('settings.edit');
|
Route::get('impostazioni', SettingsEdit::class)->name('settings.edit');
|
||||||
Route::get('utenti', UsersIndex::class)->name('users.index');
|
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('zone', ZoneIndex::class)->name('zone.index');
|
||||||
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
|
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
|
||||||
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');
|
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');
|
||||||
|
|||||||
459
tests/Feature/GruppiServizioTest.php
Normal file
459
tests/Feature/GruppiServizioTest.php
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Assegnazioni\Assegna;
|
||||||
|
use App\Livewire\Assegnazioni\Rientra;
|
||||||
|
use App\Livewire\Gruppi\GruppiIndex;
|
||||||
|
use App\Livewire\Gruppi\GruppoShow;
|
||||||
|
use App\Livewire\Gruppi\MioGruppo;
|
||||||
|
use App\Livewire\Settings\UsersIndex;
|
||||||
|
use App\Livewire\Settings\XmlExchange;
|
||||||
|
use App\Models\AnnoTeocratico;
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\GruppiServizio;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gruppi di servizio (19 ottobre 2026): un sorvegliante con un'utenza
|
||||||
|
* limitata al proprio gruppo distribuisce ai membri i territori del pacchetto.
|
||||||
|
*/
|
||||||
|
class GruppiServizioTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutVite();
|
||||||
|
Storage::fake('public');
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
||||||
|
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
|
||||||
|
|
||||||
|
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
|
||||||
|
$this->admin->assignRole('amministratore');
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function proclamatore(string $cognome, string $nome = 'Test'): Proclamatore
|
||||||
|
{
|
||||||
|
return Proclamatore::create(['nome' => $nome, 'cognome' => $cognome, 'attivo' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function territorio(string $numero, bool $pdf = true): Territorio
|
||||||
|
{
|
||||||
|
if ($pdf) {
|
||||||
|
Storage::disk('public')->put("territori-pdf/t{$numero}.pdf", '%PDF-1.4');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => $pdf ? "territori-pdf/t{$numero}.pdf" : null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gruppo "Zisa": sorvegliante Rossi, membri Verdi e Bianchi, pacchetto 12, 14, 35. */
|
||||||
|
private function gruppo(): array
|
||||||
|
{
|
||||||
|
$rossi = $this->proclamatore('Rossi', 'Mario');
|
||||||
|
$verdi = $this->proclamatore('Verdi', 'Anna');
|
||||||
|
$bianchi = $this->proclamatore('Bianchi', 'Luca');
|
||||||
|
[$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, 'mario@example.test', [$verdi->id, $bianchi->id]);
|
||||||
|
$territori = collect(['12', '14', '35'])->map(fn ($n) => $this->territorio($n));
|
||||||
|
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all());
|
||||||
|
$territori = $territori->map->fresh();
|
||||||
|
|
||||||
|
$sorvegliante = User::where('email', 'mario@example.test')->sole();
|
||||||
|
// Il cambio password al primo accesso ha i suoi test: qui si lavora.
|
||||||
|
// E l'email non confermata, come nella realtà (TestCase le conferma).
|
||||||
|
$sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save();
|
||||||
|
|
||||||
|
return compact('gruppo', 'password', 'rossi', 'verdi', 'bianchi', 'territori', 'sorvegliante');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Creazione ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_creare_un_gruppo_da_un_utenza_limitata_al_sorvegliante(): void
|
||||||
|
{
|
||||||
|
$rossi = $this->proclamatore('Rossi', 'Mario');
|
||||||
|
|
||||||
|
$pagina = Livewire::test(GruppiIndex::class)
|
||||||
|
->set('nome', 'Zisa')
|
||||||
|
->set('sorveglianteId', $rossi->id)
|
||||||
|
->set('email', 'mario@example.test')
|
||||||
|
->call('crea')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$gruppo = GruppoServizio::sole();
|
||||||
|
$utente = User::where('email', 'mario@example.test')->sole();
|
||||||
|
|
||||||
|
$this->assertSame($rossi->id, $gruppo->sorvegliante_id);
|
||||||
|
$this->assertSame($gruppo->id, $rossi->fresh()->gruppo_servizio_id, 'Il sorvegliante è anche membro');
|
||||||
|
$this->assertSame($rossi->id, $utente->proclamatore_id);
|
||||||
|
$this->assertTrue($utente->hasRole('sorvegliante'));
|
||||||
|
$this->assertTrue($utente->password_change_required);
|
||||||
|
// Nessuna conferma dell'email per il sorvegliante (TestCase conferma
|
||||||
|
// gli utenti creati: qui si toglie).
|
||||||
|
$utente->forceFill(['email_verified_at' => null])->save();
|
||||||
|
$this->assertTrue($utente->fresh()->hasVerifiedEmail());
|
||||||
|
|
||||||
|
// La password temporanea si vede una volta, ed è quella vera.
|
||||||
|
$password = $pagina->get('passwordTemporanea');
|
||||||
|
$this->assertNotEmpty($password);
|
||||||
|
$pagina->assertSee($password);
|
||||||
|
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_proclamatore_sta_in_un_solo_gruppo(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo();
|
||||||
|
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'), 'neri@example.test');
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
app(GruppiServizio::class)->aggiungiMembri($noce, [$verdi->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_ruolo_sorvegliante_non_si_da_dalla_pagina_utenti(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $sorvegliante] = $this->gruppo();
|
||||||
|
|
||||||
|
$utenti = Livewire::test(UsersIndex::class);
|
||||||
|
$this->assertNotContains('sorvegliante', $utenti->get('availableRoles'));
|
||||||
|
|
||||||
|
$utenti->call('startEdit', $sorvegliante->id)->assertSet('editingUserId', null);
|
||||||
|
$utenti->call('deleteUser', $sorvegliante->id);
|
||||||
|
$this->assertNotNull($sorvegliante->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pacchetto ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_il_pacchetto_non_e_un_assegnazione_e_non_e_in_reparto(): void
|
||||||
|
{
|
||||||
|
['territori' => $territori] = $this->gruppo();
|
||||||
|
$libero = $this->territorio('99');
|
||||||
|
|
||||||
|
$this->assertSame(0, Assegnazione::count(), 'Il pacchetto non crea assegnazioni: medie e statistiche non cambiano');
|
||||||
|
$this->assertSame([$libero->id], Territorio::inReparto()->pluck('id')->all());
|
||||||
|
$this->assertSame('in_gruppo', $territori[0]->fresh()->stato);
|
||||||
|
$this->assertSame('In gruppo Zisa', $territori[0]->fresh()->stato_etichetta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dalla_pagina_assegna_un_territorio_del_pacchetto_non_si_assegna(): void
|
||||||
|
{
|
||||||
|
['territori' => $territori] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::test(Assegna::class)
|
||||||
|
->set('territorio_id', $territori[0]->id)
|
||||||
|
->set('proclamatore_id', $this->proclamatore('Neri')->id)
|
||||||
|
->call('save')
|
||||||
|
->assertHasErrors('territorio_id');
|
||||||
|
|
||||||
|
$this->assertSame(0, Assegnazione::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_territorio_assegnato_non_entra_nel_pacchetto(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo] = $this->gruppo();
|
||||||
|
$t = $this->territorio('50');
|
||||||
|
Assegnazione::create([
|
||||||
|
'territorio_id' => $t->id, 'proclamatore_id' => $this->proclamatore('Neri')->id,
|
||||||
|
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$t->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Accesso del sorvegliante ───────────────────────────────
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_vede_solo_il_suo_gruppo_e_il_profilo(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $sorvegliante] = $this->gruppo();
|
||||||
|
$this->flushSession();
|
||||||
|
$this->actingAs($sorvegliante);
|
||||||
|
|
||||||
|
$this->get(route('dashboard'))->assertRedirect(route('mio-gruppo'));
|
||||||
|
$this->get(route('mio-gruppo'))->assertOk()->assertSee('Gruppo Zisa')->assertSee('N° 12');
|
||||||
|
$this->get(route('profile'))->assertOk();
|
||||||
|
|
||||||
|
foreach (['territori.index', 'proclamatori.index', 'registro.index', 'campagne.index', 'assegnazioni.assegna', 'users.index', 'gruppi.index', 'privacy'] as $rotta) {
|
||||||
|
$this->get(route($rotta))->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_l_amministratore_non_ha_la_voce_il_mio_gruppo(): void
|
||||||
|
{
|
||||||
|
$this->gruppo();
|
||||||
|
|
||||||
|
// Passa ogni @can (Gate::before), ma un gruppo suo non ce l'ha.
|
||||||
|
$this->get(route('dashboard'))->assertOk()->assertDontSee('Il mio gruppo')->assertSee('Gruppi di servizio');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_entra_senza_conferma_dell_email_e_va_al_suo_gruppo(): void
|
||||||
|
{
|
||||||
|
$this->gruppo();
|
||||||
|
$this->flushSession();
|
||||||
|
auth()->logout();
|
||||||
|
$password = 'una-password-nuova-1';
|
||||||
|
User::where('email', 'mario@example.test')->sole()->forceFill(['password' => $password])->save();
|
||||||
|
|
||||||
|
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password])
|
||||||
|
->assertRedirect(route('mio-gruppo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Il sorvegliante al lavoro ──────────────────────────────
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_assegna_a_un_membro_e_nasce_un_assegnazione_normale(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori, 'gruppo' => $gruppo] = $this->gruppo();
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)
|
||||||
|
->call('apriAssegnazione', $territori[0]->id)
|
||||||
|
->set('membroId', $verdi->id)
|
||||||
|
->call('assegna')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
// Come dalla pagina Assegna: si apre l'invio del link.
|
||||||
|
->assertSee('Hai inviato il link?');
|
||||||
|
|
||||||
|
$a = Assegnazione::sole();
|
||||||
|
$this->assertSame($verdi->id, $a->proclamatore_id);
|
||||||
|
$this->assertSame($gruppo->id, $a->gruppo_servizio_id, 'Tracciato che viene dal gruppo');
|
||||||
|
$this->assertTrue($a->link_da_inviare);
|
||||||
|
$this->assertSame($gruppo->id, $territori[0]->fresh()->gruppo_servizio_id, 'Resta nel pacchetto finché non rientra');
|
||||||
|
$this->assertSame('assegnato', $territori[0]->fresh()->stato);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_puo_tenere_un_territorio_per_se(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'rossi' => $rossi, 'territori' => $territori] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)
|
||||||
|
->call('apriAssegnazione', $territori[1]->id)
|
||||||
|
->set('membroId', $rossi->id)
|
||||||
|
->call('assegna')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSame($rossi->id, Assegnazione::sole()->proclamatore_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_solo_ai_membri_e_solo_i_territori_del_pacchetto(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
|
||||||
|
$estraneo = $this->proclamatore('Neri');
|
||||||
|
$fuori = $this->territorio('77');
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)
|
||||||
|
->call('apriAssegnazione', $territori[0]->id)
|
||||||
|
->set('membroId', $estraneo->id)
|
||||||
|
->call('assegna')
|
||||||
|
->assertHasErrors('membroId');
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)
|
||||||
|
->call('apriAssegnazione', $fuori->id)
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertSame(0, Assegnazione::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_rientro_lo_registra_il_sorvegliante_e_il_territorio_torna_in_reparto(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori, 'gruppo' => $gruppo] = $this->gruppo();
|
||||||
|
$a = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now()->subDays(3));
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
$this->actingAs($s)->get(route('assegnazioni.rientra', $a))->assertOk();
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(Rientra::class, ['assegnazione' => $a])
|
||||||
|
->call('save')
|
||||||
|
->assertRedirect(route('mio-gruppo'));
|
||||||
|
|
||||||
|
$this->assertNotNull($a->fresh()->returned_at);
|
||||||
|
$this->assertNull($territori[0]->fresh()->gruppo_servizio_id, 'Esce dal pacchetto');
|
||||||
|
$this->assertSame('in_reparto', $territori[0]->fresh()->stato);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_non_registra_rientri_di_altri(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s] = $this->gruppo();
|
||||||
|
$altro = Assegnazione::create([
|
||||||
|
'territorio_id' => $this->territorio('80')->id, 'proclamatore_id' => $this->proclamatore('Neri')->id,
|
||||||
|
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
|
||||||
|
]);
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
$this->actingAs($s)->get(route('assegnazioni.rientra', $altro))->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_rimanda_in_reparto_quelli_non_dati(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)->call('rimanda', $territori[2]->id);
|
||||||
|
|
||||||
|
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id);
|
||||||
|
$this->assertSame('in_reparto', $territori[2]->fresh()->stato);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_la_piantina_del_pacchetto_si_apre_solo_per_il_proprio_gruppo(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
|
||||||
|
$fuori = $this->territorio('88');
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
$this->actingAs($s)->get(route('mio-gruppo.pdf', $territori[0]))->assertOk();
|
||||||
|
$this->actingAs($s)->get(route('mio-gruppo.pdf', $fuori))->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scioglimento ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_togliere_il_ruolo_scioglie_il_gruppo(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
|
||||||
|
$data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
|
||||||
|
|
||||||
|
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])->call('sciogli');
|
||||||
|
|
||||||
|
$gruppo->refresh();
|
||||||
|
$this->assertNotNull($gruppo->sciolto_at);
|
||||||
|
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione normale.
|
||||||
|
$this->assertNull($territori[1]->fresh()->gruppo_servizio_id);
|
||||||
|
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id);
|
||||||
|
$this->assertNull($data->fresh()->returned_at);
|
||||||
|
$this->assertSame($gruppo->id, $data->fresh()->gruppo_servizio_id, 'Lo storico continua a dirlo');
|
||||||
|
// Membri liberi, utenza disattivata e senza ruolo.
|
||||||
|
$this->assertSame(0, Proclamatore::where('gruppo_servizio_id', $gruppo->id)->count());
|
||||||
|
$this->assertTrue($s->fresh()->isDisattivato());
|
||||||
|
$this->assertFalse($s->fresh()->hasRole('sorvegliante'));
|
||||||
|
|
||||||
|
// Per ripartire serve un gruppo nuovo.
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$territori[1]->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_utenza_disattivata_non_entra_e_viene_buttata_fuori(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'sorvegliante' => $s] = $this->gruppo();
|
||||||
|
$password = 'una-password-nuova-1';
|
||||||
|
$s->forceFill(['password' => $password])->save();
|
||||||
|
app(GruppiServizio::class)->sciogli($gruppo);
|
||||||
|
$this->flushSession();
|
||||||
|
auth()->logout();
|
||||||
|
|
||||||
|
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password])
|
||||||
|
->assertSessionHasErrors('email');
|
||||||
|
$this->assertGuest();
|
||||||
|
|
||||||
|
$this->actingAs($s->fresh())->get(route('profile'))->assertRedirect(route('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo_con_la_stessa_utenza(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $vecchio, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->sciogli($vecchio);
|
||||||
|
|
||||||
|
$pagina = Livewire::test(GruppiIndex::class);
|
||||||
|
$this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati');
|
||||||
|
|
||||||
|
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa 2', $rossi->fresh(), 'mario.nuovo@example.test');
|
||||||
|
|
||||||
|
$utente = $s->fresh();
|
||||||
|
$this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza');
|
||||||
|
$this->assertFalse($utente->isDisattivato());
|
||||||
|
$this->assertTrue($utente->hasRole('sorvegliante'));
|
||||||
|
$this->assertSame('mario.nuovo@example.test', $utente->email);
|
||||||
|
$this->assertTrue($utente->password_change_required);
|
||||||
|
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
|
||||||
|
$this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Proclamatori: cestino, anonimizzazione, registro ───────
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_di_un_gruppo_attivo_non_si_cestina_ne_si_anonimizza(): void
|
||||||
|
{
|
||||||
|
['rossi' => $rossi, 'verdi' => $verdi] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
|
||||||
|
->call('deleteProclamatore', $rossi->id)
|
||||||
|
->call('anonimizza', $rossi->id);
|
||||||
|
$this->assertFalse($rossi->fresh()->trashed());
|
||||||
|
$this->assertSame('Rossi', $rossi->fresh()->cognome);
|
||||||
|
|
||||||
|
// Un membro qualsiasi sì, e lascia il gruppo.
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('deleteProclamatore', $verdi->id);
|
||||||
|
$this->assertTrue($verdi->fresh()->trashed());
|
||||||
|
$this->assertNull($verdi->fresh()->gruppo_servizio_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_anonimizzare_un_ex_sorvegliante_anonimizza_anche_la_sua_utenza(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->sciogli($gruppo);
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id);
|
||||||
|
|
||||||
|
$utente = $s->fresh();
|
||||||
|
$this->assertSame('Anonimo', $utente->name);
|
||||||
|
$this->assertStringEndsWith('@anonimo.invalid', $utente->email);
|
||||||
|
$this->assertTrue($utente->isDisattivato());
|
||||||
|
$this->assertCount(0, $utente->roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s] = $this->gruppo();
|
||||||
|
$s->update(['email' => 'mario.rossi@example.test']);
|
||||||
|
|
||||||
|
$voci = \Spatie\Activitylog\Models\Activity::where('subject_type', User::class)->where('subject_id', $s->id)->get();
|
||||||
|
$this->assertNotEmpty($voci);
|
||||||
|
foreach ($voci as $voce) {
|
||||||
|
$json = json_encode($voce->properties);
|
||||||
|
$this->assertStringNotContainsString('Rossi', $json);
|
||||||
|
$this->assertStringNotContainsString('mario', $json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Backup e azzeramento ───────────────────────────────────
|
||||||
|
|
||||||
|
public function test_l_azzeramento_toglie_anche_gruppi_e_utenza_del_sorvegliante(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori, 'sorvegliante' => $s] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
|
||||||
|
|
||||||
|
$stato = app(\App\Services\BackupOperationState::class);
|
||||||
|
$stato->initialize('azzeramento-gruppi', 'azzeramento', 'In attesa');
|
||||||
|
(new \App\Jobs\ResetApplication('azzeramento-gruppi', $this->admin->id))->handle($stato, app(\App\Services\BackupArchive::class));
|
||||||
|
|
||||||
|
$this->assertSame('completed', $stato->get('azzeramento-gruppi')['status'] ?? null, json_encode($stato->get('azzeramento-gruppi')));
|
||||||
|
$this->assertSame(0, GruppoServizio::count());
|
||||||
|
$this->assertNull(User::find($s->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
|
||||||
|
|
||||||
|
$xml = (new XmlExchange())->currentXmlBackup();
|
||||||
|
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
|
||||||
|
|
||||||
|
$g = GruppoServizio::sole();
|
||||||
|
$this->assertSame('Zisa', $g->nome);
|
||||||
|
$this->assertSame('Rossi Mario', $g->sorvegliante->nome_completo);
|
||||||
|
$this->assertEqualsCanonicalizing(['Rossi Mario', 'Verdi Anna', 'Bianchi Luca'], $g->membri->map->nome_completo->all());
|
||||||
|
$this->assertEqualsCanonicalizing(['12', '14', '35'], $g->territori->pluck('numero')->all());
|
||||||
|
$this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id);
|
||||||
|
$this->assertSame($g->sorvegliante_id, User::where('email', 'mario@example.test')->sole()->proclamatore_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* L'immagine deve partire da una versione di PHP fissata alla patch, e mai
|
|
||||||
* sotto quella che corregge CVE-2026-109618 e CVE-2026-109619 (esecuzione di
|
|
||||||
* codice da remoto nel core di PHP, 21/10/2026). Con un tag che si sposta
|
|
||||||
* ("8.3-fpm") la cache di Docker poteva riusare una base vulnerabile.
|
|
||||||
*/
|
|
||||||
class VersionePhpTest extends TestCase
|
|
||||||
{
|
|
||||||
private const MINIMA = '8.3.36';
|
|
||||||
|
|
||||||
private function dockerfile(): string
|
|
||||||
{
|
|
||||||
return (string) file_get_contents(dirname(__DIR__, 2) . '/docker/php/Dockerfile');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_la_versione_di_php_e_fissata_e_corretta(): void
|
|
||||||
{
|
|
||||||
// \s* prima di $: nella copia di lavoro su Windows il file ha CRLF.
|
|
||||||
$this->assertMatchesRegularExpression('/^ARG PHP_VERSION=(\d+\.\d+\.\d+)\s*$/m', $this->dockerfile());
|
|
||||||
|
|
||||||
preg_match('/^ARG PHP_VERSION=(\d+\.\d+\.\d+)\s*$/m', $this->dockerfile(), $trovata);
|
|
||||||
|
|
||||||
$this->assertTrue(
|
|
||||||
version_compare($trovata[1], self::MINIMA, '>='),
|
|
||||||
"PHP {$trovata[1]} è vulnerabile a CVE-2026-109618/109619: serve almeno " . self::MINIMA . '.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_la_mitigazione_delle_sessioni_resta_nel_php_ini(): void
|
|
||||||
{
|
|
||||||
// Indicata da Sansec per CVE-2026-109618/109619; all'applicazione non
|
|
||||||
// serve, quindi resta anche dopo la patch.
|
|
||||||
$ini = (string) file_get_contents(dirname(__DIR__, 2) . '/docker/php/php.ini');
|
|
||||||
|
|
||||||
$this->assertMatchesRegularExpression('/^session\.upload_progress\.enabled\s*=\s*0\s*$/m', $ini);
|
|
||||||
$this->assertMatchesRegularExpression('/^session\.use_strict_mode\s*=\s*1\s*$/m', $ini);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_l_immagine_usa_la_versione_fissata_e_non_un_tag_mobile(): void
|
|
||||||
{
|
|
||||||
preg_match_all('/^FROM php:(\S+)/m', $this->dockerfile(), $basi);
|
|
||||||
|
|
||||||
$this->assertNotEmpty($basi[1]);
|
|
||||||
foreach ($basi[1] as $tag) {
|
|
||||||
$this->assertStringStartsWith('${PHP_VERSION}', $tag, "FROM php:{$tag}: usa ARG PHP_VERSION, non un tag che si sposta.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user