Compare commits

..

37 Commits

Author SHA1 Message Date
Francesco Picone
635982bf90 ++ HANDOFF: PHP 8.3.36 in produzione e sulla demo, variabile provvisoria tolta
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:50:17 +02:00
Francesco Picone
726af6d5c3 ++ Update gitignore 2026-10-21 17:55:24 +02:00
Francesco Picone
d198ad0b54 ++ Modulo pubblico: nella riconsegna il territorio si sceglie con il completamento automatico
Elenco con solo numero e zona (territori attivi e quelli ancora fuori),
mostrato solo per la riconsegna; il server accetta solo un numero
dell'elenco. Privacy, README e HANDOFF aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:50:09 +02:00
Francesco Picone
2e1da51080 ++ HANDOFF: 612 test verdi, RichiesteTest nell'elenco
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:37:28 +02:00
Francesco Picone
5b4903d1cb ++ RichiesteTest: sessione svuotata prima di cambiare utente
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:36:32 +02:00
Francesco Picone
1da31ce600 ++ Richieste: ordine dei nomi simili, etichetta cartaceo senza blocco if, dati dei test
- nomiSimili: sortBy con una closure la usa come comparatore, non come
  chiave; ora sort() con confronto esplicito (punteggio, poi alfabetico)
- elenco degli assegnati: l'etichetta "cartaceo" come espressione, senza
  i segnaposto di Livewire attorno a @if (58 byte a riga)
- RichiesteTest: created_by sulle assegnazioni di prova

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:35:31 +02:00
Francesco Picone
7904c96db8 ++ Richieste dal modulo pubblico, territori cartacei, S-13 mensile per email
- /richiesta: modulo pubblico (nuovo territorio o riconsegna) con campo
  trappola, tempo minimo e limite orario; nome e note cifrati, avviso per
  email a chi ha richieste.gestisci (amministratore, assistente)
- Home: riquadro Richieste con i proclamatori dal nome simile; porta su
  Assegna/Rientra già esistenti, che chiudono la richiesta; Scarta
- Cartaceo sull'assegnazione (Assegna e Il mio gruppo), etichetta
  nell'elenco degli assegnati, pagina Cartacei, passa per l'XML
- registro:invia-s13 agli amministratori l'ultima domenica del mese;
  richieste:pulizia dopo 90 giorni dalla gestione
- Privacy, README e HANDOFF aggiornati; test in RichiesteTest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:32:09 +02:00
Francesco Picone
5e40f0fe74 ++ HANDOFF: demo costruita con PHP 8.3.35 in via provvisoria, variabile da togliere
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:41:36 +02:00
Francesco Picone
18fe914da5 ++ Utenti: nuova password temporanea per il sorvegliante di gruppo
Sulla riga del sorvegliante, che prima non aveva azioni: "Nuova password
temporanea" (mostrata una volta, da cambiare al primo accesso) e "Apri il
gruppo". La logica sta in GruppiServizio::nuovaPasswordTemporanea(), usata
anche dalla pagina del gruppo; azzera anche il "ricordami".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:33:40 +02:00
Francesco Picone
2fb9838a0e ++ Il mio gruppo: il numero del territorio apre l'anteprima
Il numero diventa un pulsante che apre una finestra con la miniatura e il
collegamento al PDF. La miniatura passa dalla rotta nuova
mio-gruppo.miniatura, con la stessa regola del PDF: solo i territori del
pacchetto del gruppo sorvegliato.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:27:50 +02:00
Francesco Picone
9e473cd7fd ++ HANDOFF: mitigazione applicata ai container di demo e produzione
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:10:39 +02:00
Francesco Picone
8b6bc0796a ++ HANDOFF: in produzione solo la correzione di PHP (scelta dell'utente)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:03:59 +02:00
Francesco Picone
8ce3d6d10d ++ php.ini: sessioni native chiuse, mitigazione di CVE-2026-109618/109619
session.upload_progress.enabled = 0 e session.use_strict_mode = 1, come
indicato da Sansec in attesa della patch; restano anche dopo, perche'
all'applicazione non servono (Laravel ha le sue sessioni, in Redis).
Test di guardia in VersionePhpTest. HANDOFF: stato della mitigazione e
scelta ancora aperta sul rilascio in produzione. README: sicurezza.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:01:00 +02:00
db5772d7f5 PHP fissato alla 8.3.36 (CVE-2026-109618, CVE-2026-109619)
- Dockerfile: ARG PHP_VERSION=8.3.36 e FROM php:${PHP_VERSION}-fpm al posto
  del tag mobile php:8.3-fpm, che la cache di Docker poteva risolvere sulla
  8.3.35 vulnerabile. Una build ora prende la versione corretta o fallisce.
- phpredis da GitHub (versione fissata) invece di pecl.php.net, che il
  giorno della patch rispondeva 504 e bloccava la build.
- Test contro il ritorno a un tag mobile o a una versione vulnerabile.
- PHP_VERSION passabile dal compose solo per prove locali.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:30:05 +02:00
Francesco Picone
5fb42b05c7 ++ HANDOFF e README allineati all'ultima versione delle funzionalita'
HANDOFF: stato attuale vero (produzione dal 19/10, cosa c'e' solo su
dev e come rilasciarlo), sezione dei gruppi di servizio descritta com'e'
oggi con la storia delle decisioni in coda, lavori recenti in ordine,
cronologia di luglio-agosto in "Da dove veniamo", ambienti con
autodeploy che non parte e test sul server prima del push, copertura dei
test (577 + 13), frasi superate su WhatsApp/email/contatti corrette.
README: impostazioni reali della pagina, due liste della Home, soglia di
priorita' in giorni e ordine di priorita', deploy a mano, struttura del
progetto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:48:18 +02:00
Francesco Picone
7d479a2c34 ++ Assegna aperta da "Assegna ->" della Home: senza l'elenco dei territori assegnati
La richiesta era per la pagina Assegna, non per la scheda del territorio:
annullata la modifica di f1f3408 (storico nascosto nella scheda) e ora il
link "Assegna ->" della Home porta ?dalla_home=1, con cui Assegna non
mostra ne' carica l'elenco "Territori Assegnati".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:28:56 +02:00
Francesco Picone
f1f340803a ++ scheda territorio: aperta dalla Home non mostra lo storico delle assegnazioni
I link della Home portano ?dalla_home=1; la scheda nasconde lo storico
(e non lo carica), con "Mostra lo storico delle assegnazioni" per aprirlo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:52:00 +02:00
Francesco Picone
6f88c65d73 ++ storico assegnazioni: una tabella sola con le colonne in linea, dal piu' recente
Nelle schede di territorio e proclamatore ogni anno teocratico era una
tabella a se', con le colonne dimensionate sul proprio contenuto. Ora una
tabella con un'intestazione, colonne fisse, l'anno come riga di
separazione (tm-riga-gruppo, titolo anche su telefono); ordine esplicito
dal piu' recente. Nella scheda del proclamatore Campagna "Si'/No" e badge
"via gruppo".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:43:15 +02:00
Francesco Picone
ab7d26ae41 ++ test: il pulsante Rientro ha l'indirizzo relativo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:34:24 +02:00
Francesco Picone
cd3569a327 ++ elenco assegnati: il pulsante Rientro resta nel peso massimo della riga
Indirizzo relativo e niente a capo nel ciclo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:33:33 +02:00
Francesco Picone
eda3c3f989 ++ elenco dei territori assegnati: pulsante Rientro su ogni riga
Porta alla registrazione del rientro e torna all'elenco (data-ritorno);
solo con il permesso territori.return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:32:07 +02:00
Francesco Picone
699e2ef173 ++ test: numeri dei territori come stringhe nel test della priorita'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:00:59 +02:00
Francesco Picone
0ce5bd8c89 ++ test: soglia dei prioritari fissata nei test della priorita' del pacchetto
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:00:05 +02:00
Francesco Picone
ce98165667 ++ gruppi: le azioni del sorvegliante non finiscono piu' in 403; priorita' e filtri nel pacchetto; nome del sorvegliante
- LimitaSorvegliante: la rotta delle azioni di Livewire 3.7 si chiama
  "default.livewire.update"; con 'livewire.*' ogni clic era un 403.
- Territorio::ordinaPerPriorita(): la regola di "Da assegnare" della Home in
  un metodo solo, usato dalla Home, dall'aggiunta al pacchetto e da "Il mio
  gruppo".
- Aggiunta al pacchetto: ricerca, zona, tipologia, solo prioritari,
  "Seleziona tutti quelli mostrati", badge e giorni di fermo.
- Il mio gruppo: da dare in ordine di priorita' poi i gia' dati; filtri
  stato, zona, tipologia, solo prioritari; sotto il titolo nome e cognome
  del sorvegliante.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:58:53 +02:00
Francesco Picone
d2d6b1bdce ++ cambio password obbligato: al primo accesso del sorvegliante dice "Password temporanea", non "Ripristino da backup"
users.password_temporanea distingue la password provvisoria data
dall'amministratore (creazione del gruppo, "Nuova password temporanea")
dal ripristino da backup. Migrazione 000040; si spegne a password
cambiata, il ripristino e l'import XML la spengono per tutti.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:52:35 +02:00
Francesco Picone
ec2e7bd7e9 ++ gruppi: email fittizia iniziale+cognome per il sorvegliante; sciolto il gruppo l'utenza si elimina
- L'utenza del sorvegliante ha un'email fittizia creata dall'app:
  iniziale del nome e cognome @termanager.it (ffontana@termanager.it),
  come gli altri utenti. Via il nome utente: login di nuovo per email.
- Sciolto il gruppo l'utenza si elimina, il sorvegliante torna un
  proclamatore come gli altri: assegnazioni a chi scioglie, registro a nome
  "Sorvegliante del gruppo ...". Via le utenze disattivate.
- User::eliminaConservandoStorico(): l'eliminazione della pagina Utenti in
  un metodo solo, usato anche dallo scioglimento.
- Migrazione 000030: elimina le utenze gia' disattivate, toglie
  users.username e users.disattivato_at, email di nuovo obbligatoria.
- XML, informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:09:15 +02:00
Francesco Picone
69250ecb8c ++ HANDOFF: confermata la traccia "via gruppo" sulle assegnazioni; produzione in attesa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:56:46 +02:00
Francesco Picone
aaffeb7de7 ++ gruppi: il gruppo sciolto si elimina; il sorvegliante entra con un nome utente dal nome del gruppo
- Sciolto, il gruppo non resta nello storico: si elimina (via sciolto_at;
  la migrazione elimina anche quelli gia' sciolti). I territori solo nel
  pacchetto tornano in reparto; quelli gia' dati restano ai membri, e
  l'assegnazione conserva il nome del gruppo (assegnazioni.gruppo_nome).
- Creando il gruppo non si chiede l'email: users.username dal nome del
  gruppo ("Gruppo Zisa" -> gruppo.zisa), users.email facoltativa. Login con
  "Email o nome utente". Nome del gruppo unico.
- Sciolto il gruppo il nome utente si libera (l'utenza disattivata prende
  ex.sorvegliante.<id>). XML: nome utente e gruppo_nome.
- Profilo, testata, cambio password e Utenti mostrano il nome utente dove
  manca l'email. Informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:45:00 +02:00
Francesco Picone
a93539242a ++ gruppi: chi fa parte di un gruppo di servizio e' segnalato
Badge "Gruppo ..." / "Sorvegliante del gruppo ..." nell'elenco dei
proclamatori (con il filtro "In un gruppo di servizio") e in testa alla
scheda; nella scelta del proclamatore (Assegna, Registro) la nota
"gruppo ..." accanto al nome, che resta sceglibile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:09:07 +02:00
Francesco Picone
7a54c15852 ++ test: profilo dell'amministratore in una sessione nuova
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:56:23 +02:00
Francesco Picone
4d9e445cba ++ gruppi: sorvegliante non piu' attivo scioglie il gruppo; dettagli del gruppo nella scheda e nel profilo
- Proclamatore::booted: reso non attivo, spostato nel cestino o
  anonimizzato, il sorvegliante scioglie il gruppo (territori non dati in
  reparto, membri liberi, utenza disattivata); il motivo va nel registro.
  L'elenco chiede conferma, la modifica avvisa sotto "Attivo". Un membro
  nel cestino esce dal gruppo.
- Scheda del proclamatore: gruppo guidato con membri, pacchetto, da dare,
  assegnazioni dal gruppo, utenza e ultimo accesso; oppure gruppo di cui
  e' membro; gruppi sciolti guidati in passato.
- Profilo del sorvegliante: il suo gruppo con gli stessi numeri.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:48:37 +02:00
Francesco Picone
9999a3b031 ++ gruppi: l'assegnazione dal pacchetto rilegge territorio e membro, e ricontrolla il pacchetto sotto lock
HANDOFF: trappola di Blade sul @php in linea, test sul server prima del push.

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

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

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

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

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

View File

@@ -30,6 +30,8 @@ public/build
public/storage public/storage
public/hot public/hot
_ARCHIVE
db_data db_data
redis_data redis_data

1
.gitignore vendored
View File

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

View File

@@ -4,22 +4,134 @@ 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: **21 ottobre 2026** — si riprende da [Stato attuale](#stato-attuale).
> **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) ## Stato attuale
**✅ 21 ottobre 2026 — PHP 8.3.36 su produzione e demo** (CVE-2026-109618/109619,
esecuzione di codice da remoto), con la mitigazione nel `php.ini`. Vedi
[Sicurezza: PHP](#sicurezza-php-cve-2026-109618--cve-2026-109619-21-ottobre-2026).
**Produzione** (`main` → compose `termanager2-core-production`) in esercizio
su Dokploy dal 19 ottobre 2026. Ultimo rilascio: `1ddb973` del 21/10, ore
18:48 (solo la correzione di PHP sopra il rilascio del 19/10: proclamatori
senza contatti, il link si copia o si condivide). Backup fatti prima dei
rilasci in `/etc/dokploy/termanager2-backups/` sul server.
**Demo** (`dev` → compose `termanager2-core-demo`,
`demo-termanager.pyconetwork.it`): avanti di una ventina di commit, tutti
provati con la suite sul server e pubblicati sulla demo. **Non ancora in
produzione** — l'utente ha chiesto di aspettare il suo ok:
- [gruppi di servizio](#gruppi-di-servizio-19-20-ottobre-2026-solo-su-dev):
sorvegliante con email fittizia, pacchetto di territori, "Il mio gruppo",
scioglimento; migrazioni `2026_10_20_000010` … `000040`;
- priorità e filtri nel pacchetto, con la regola di priorità della Home in un
metodo solo (`Territorio::ordinaPerPriorita()`);
- pulsante **Rientro** nell'elenco dei territori assegnati;
- storico assegnazioni in una tabella sola, con le colonne in linea;
- *Assegna* aperta da "Assegna →" della Home senza l'elenco dei territori
assegnati;
- password del sorvegliante dal pannello *Utenti*, anteprima dal numero in "Il
mio gruppo";
- [modulo pubblico delle richieste, cartacei, S-13 per email](#richieste-dal-modulo-pubblico-cartacei-s-13-per-email-21-ottobre-2026-solo-su-dev)
(migrazione `2026_10_21_000010`).
**Per portarli in produzione**: backup del database di produzione, merge di
`dev` in `main` (dopo il rilascio di sicurezza del 21/10 non è più un
fast-forward), deploy (l'autodeploy non parte: vedi
[Dove gira davvero](#dove-gira-davvero)), poi controllo di migrazioni e
conteggi. In produzione non ci sono gruppi né richieste: le migrazioni non
hanno dati da convertire.
| Aspetto | Stato |
|-----------------------------|-------|
| Deploy Dokploy (Compose) | ✅ produzione e demo |
| Autodeploy al push | ⚠️ configurato, ma dal 19/10 il push non fa partire il deploy: si avvia a mano (`compose.deploy`) |
| Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
| Redeploy senza interventi | ✅ ~25 secondi |
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
| Backup / ripristino / azzeramento | ✅ verificati end-to-end |
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
| Test automatici | ✅ **613 test PHP + 13 JavaScript, verdi** (21/10/2026); copertura ancora parziale |
| Prove nel browser | ⚠️ le funzioni del 19-21 ottobre sono verificate dai test e sul server; nel browser le usa l'utente sulla demo (richieste, cartacei e S-13 non ancora provati da lui) |
La storia precedente (luglio-agosto: backup e ripristino, sotto-app Piantine,
sistema visivo, prima suite di test) è in [Da dove veniamo](#da-dove-veniamo).
---
## Lavori recenti — ottobre 2026
Dal più recente. Le sezioni del 12-19 ottobre sono in produzione; quelle del
20-21 ottobre e i gruppi di servizio solo sulla demo.
### Richieste dal modulo pubblico, cartacei, S-13 per email (21 ottobre 2026, solo su dev)
Dal documento `_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx`
(attività per la versione 2): analizzato con l'utente, che ha deciso così.
- **Modulo pubblico** `/richiesta` (rotta `richiesta`, fuori dal login,
`throttle:60,1`; componente `Richieste\ModuloRichiesta`, layout guest):
nome e cognome, *nuovo territorio* o *riconsegna* (con numero), nota.
Nella riconsegna il numero si sceglie con `<x-scelta>` (richiesta
dell'utente del 21/10: "il territorio fallo inserire con completamento
automatico"): opzioni con solo numero e zona, valore = numero; i territori
attivi più quelli disattivati ma ancora fuori; l'elenco va nella pagina
solo con *riconsegna* scelta. Il server accetta solo un numero di
quell'elenco (confronto senza maiuscole). Nessun altro dato esistente: né
nomi né stato delle assegnazioni. Anti-spam: campo trappola `sito` (classe
`.tm-trappola`), tempo minimo `SECONDI_MINIMI` = 3 s (in questi due casi
risponde "inviata" senza salvare), `MASSIMO_ORA` = 5 per IP (chiave del
RateLimiter con lo sha1 dell'IP). Il tempo si legge con `now()`, così i
test possono viaggiare nel tempo.
- **Tabella `richieste`** (migrazione `2026_10_21_000010`, che crea anche il
permesso `richieste.gestisci` per amministratore e assistente e la colonna
`assegnazioni.cartaceo`): nome e note cifrati; nel registro delle attività
solo tipo, stato, numero (`richiesta_ricevuta` all'arrivo, poi i cambi di
stato). `RichiesteServizio`: `registra()`, `destinatari()`, `nomiSimili()`,
`assegnazioneAperta()`.
- **Email di avviso** (`Notifications\RichiestaRicevuta`) a chi ha
`richieste.gestisci` con l'email confermata, solo se la posta funziona
(`ConfigurazionePosta::funzionante()`). Non in coda: contiene il nome.
- **Conferma dalla Home** (riquadro *Richieste*, solo con il permesso):
scelta dell'utente, "così non si modifica troppo il codice del form
precompilato". Per un nuovo territorio la Home propone i proclamatori attivi
con un nome simile (punteggio per parola: uguale 3, iniziale 2, una lettera
di differenza 1; serve almeno 2) e ognuno apre *Assegna* con
`?proclamatore=&richiesta=&dalla_home=1`; per una riconsegna, *Rientra* con
`?richiesta=`. I due moduli leggono `richiesta` solo se aperta e se l'utente
ha il permesso (`#[Locked] richiestaId`), la mostrano sopra il form e, fatta
l'operazione, la segnano **evasa** (`Richiesta::evadi()`). Altrimenti
*Scarta*. La Home "messaggi" del documento sono solo queste richieste.
- **Pulizia**: `richieste:pulizia` ogni notte cancella le richieste gestite da
più di `PuliziaRichieste::GIORNI` = 90 giorni. Le richieste non sono
nell'XML né nel `.zip` (un ripristino non le tocca); l'azzeramento le
cancella (passo "Richieste" di `ResetApplication`, ora 12 passi).
- **Cartaceo sull'assegnazione** (scelta dell'utente: "va sull'assegnazione"):
spunta su *Assegna* e in "Il mio gruppo" (`GruppiServizio::assegna(...,
cartaceo:)`), etichetta "cartaceo" nell'elenco degli assegnati e in "Il mio
gruppo". Nell'elenco è un'espressione, non un blocco `@if`: Livewire mette
due commenti segnaposto attorno a ogni `@if`, 58 byte a riga anche quando è
falso, e la riga superava i 2,6 KB del test di peso. Pagina
**Cartacei** (`assegnazioni/cartacei`, `territori.assign`, voce di menu sotto
*Assegnazioni*) con i territori cartacei fuori. Passa per l'XML.
- **S-13 per email**: `registro:invia-s13 {--anno=}` manda
`Mail\ModuloS13Mail` (PDF allegato, non in coda) agli amministratori con
l'email confermata; nello scheduler `weeklyOn(0, '08:00')` con
`when(settimana dopo in un altro mese)` = **ultima domenica del mese**. Senza
posta non parte e lo dice. Nel registro `s13_inviato` con anno e quanti.
- Informativa privacy aggiornata (dati, conteggi, posta, conservazione;
revisione 21 ottobre 2026). Test in `tests/Feature/RichiesteTest.php` e
`GruppiServizioTest` (cartaceo dal sorvegliante, cartaceo nell'XML).
### 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 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**. 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 Il progetto girava su 8.3.35.
sicurezza di `dev` (cherry-pick):
- `docker/php/Dockerfile`: `ARG PHP_VERSION=8.3.36` e `FROM - `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 php:${PHP_VERSION}-fpm`. Prima era `php:8.3-fpm`, un tag che si sposta ma
@@ -29,148 +141,243 @@ sicurezza di `dev` (cherry-pick):
un tag mobile o a una versione inferiore. un tag mobile o a una versione inferiore.
- phpredis da GitHub (`ARG PHPREDIS_VERSION=6.3.0`) invece di `pecl install`: - phpredis da GitHub (`ARG PHPREDIS_VERSION=6.3.0`) invece di `pecl install`:
quel giorno pecl.php.net rispondeva 504 e la build falliva. 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: - Solo per provare in locale prima che l'immagine sia su Docker Hub:
`PHP_VERSION=8.3.35 docker compose ...` (mai per un deploy). `PHP_VERSION=8.3.35 docker compose ...` (mai per un deploy).
- **Mitigazione in attesa della patch** (indicata da Sansec, approvata
dall'utente): `session.upload_progress.enabled = 0` e
`session.use_strict_mode = 1` in `docker/php/php.ini`, da tenere anche dopo
(all'applicazione non servono: Laravel ha le sue sessioni, in Redis).
Nei container già in esecuzione di demo e produzione la stessa cosa va in
`conf.d/zz-mitigazione-cve-2026-109618.ini` con ricarica morbida di
php-fpm (USR2): si perde quando il container viene ricreato, e da lì vale il
`php.ini` dell'immagine. *Stato dell'applicazione ai container: vedi
sotto, aggiornato a ogni passo.*
- **Scelta dell'utente (21/10): in produzione solo la correzione di PHP.** I
due commit di sicurezza sono andati su `main` con un cherry-pick (`eb756ad`
e `1ddb973`, sopra `d3314a9`); gruppi di servizio e lavori dal 20 ottobre
restano sulla demo, da rilasciare un altro giorno. Ora `main` e `dev`
divergono: per portare `dev` in produzione servirà un merge (i cherry-pick
sono già in `dev`, conflitti attesi solo in HANDOFF).
- **Rilascio fatto il 21/10.** 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. Una build prima di quell'ora fallisce con "no
match for platform in manifest": per sapere se si può costruire va
controllata l'architettura, non solo il nome del tag. Poi, nell'ordine:
1. test della versione di produzione su 8.3.36: 524 PHP + 13 JS verdi;
2. backup della produzione (`prod-20261021-164333`: database, 78 MB di
`storage/app`, conteggi);
3. demo: tolta la variabile provvisoria `PHP_VERSION=8.3.35` (vedi sotto) e
ricostruita: PHP 8.3.36, così si è visto che senza variabile la build
prende la versione del Dockerfile;
4. `main` = `1ddb973`, deploy della produzione alle 16:48 UTC (uno solo:
l'autodeploy non è partito, lanciato a mano).
Verifica in produzione: 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.
- Il passaggio di consegne di quella sessione (`HANDOFF-chat.md`) sta nella
cartella del progetto ma non nel repository (escluso in `.git/info/exclude`):
contiene dettagli dell'infrastruttura.
**In produzione dal 21 ottobre 2026, ore 18:48** (16:48 UTC; `main` = **Demo costruita per qualche ora con PHP 8.3.35 (21/10, dalle 14:40 alle
`1ddb973`). Su Docker Hub il tag `php:8.3.36-fpm` è comparso alle 15:38 UTC 16:44 UTC).** L'utente aveva chiesto il deploy della demo prima che uscisse
ma vuoto: l'immagine per amd64 (il server) è arrivata alle 16:41 UTC, e prima `php:8.3.36-fpm`: nelle variabili d'ambiente del compose della demo in
la build falliva con "no match for platform in manifest". Prima del deploy: Dokploy era stata messa `PHP_VERSION=8.3.35`, che il `docker-compose.yml`
test di questa versione su PHP 8.3.36 (524 PHP + 13 JS verdi) e backup passa al Dockerfile. **Tolta alle 16:44 UTC** e demo ricostruita su 8.3.36;
`/etc/dokploy/termanager2-backups/prod-20261021-164333` (database, 78 MB di nell'env della demo e della produzione non c'è più nessuna `PHP_VERSION`. Se
`storage/app`, conteggi). Deploy lanciato a mano (l'autodeploy non è servisse di nuovo, va tolta subito dopo, altrimenti la demo resta indietro.
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`.
--- Prima della patch (21/10, 11:00 UTC circa) la mitigazione era stata
**applicata anche ai container in esecuzione** di demo e produzione, ancora
su 8.3.35 (`conf.d/zz-mitigazione-cve-2026-109618.ini`, php-fpm ricaricato).
Con la ricostruzione quei file sono spariti, come previsto: ora vale il
`php.ini` dell'immagine, con gli stessi valori.
## Stato attuale ### Password del sorvegliante dal pannello Utenti (21 ottobre 2026, solo su dev)
L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è Richiesta dell'utente: poter reimpostare la password del sorvegliante dal
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti, pannello di amministrazione. Prima si poteva solo dalla pagina del gruppo; in
redeploy, login, tutte le pagine, code e scheduler. *Utenti* la sua riga non aveva azioni. Ora ha "Nuova password temporanea" (con
conferma) e "Apri il gruppo"; la password compare una volta sola in cima
all'elenco, con l'email, fino a "Ho annotato la password". La logica è una
sola, `GruppiServizio::nuovaPasswordTemporanea()`, usata anche da
`GruppoShow`; rifiuta gli utenti che non sono sorveglianti (per loro c'è il
modulo di modifica). Test in `GruppiServizioTest`.
| Aspetto | Stato | ### Anteprima dal numero in "Il mio gruppo" (21 ottobre 2026, solo su dev)
|-----------------------------|-------|
| Deploy Dokploy (Compose) | ✅ verificato |
| Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
| Redeploy senza interventi | ✅ ~25 secondi |
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
| Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale |
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
| Test automatici | ⚠️ **impalcatura pronta, 294 test** — copertura ancora parziale |
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto: Richiesta dell'utente: anche il sorvegliante deve poter vedere l'anteprima del
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre territorio, con un popup al clic del numero. Il numero diventa un pulsante
di avanzamento reali e console di log (commit `f909791`, deploy verificato (stesso aspetto, `button.tm-assegnato-numero` in `app.css`) che apre una
confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo** finestra (`tm-modale`, stato Alpine `anteprima` sulla radice della vista) con
su develop; la produzione non è stata toccata e resta sul suo deploy la miniatura e "Apri la piantina (PDF)". La miniatura passa da una rotta
precedente, senza quella variabile. nuova, `mio-gruppo.miniatura` (`TerritorioFileController::miniaturaGruppo`,
`Territorio::miniaturaGruppoUrl()`), con la stessa regola di
`mio-gruppo.pdf`: solo i territori del pacchetto del gruppo che l'utente
sorveglia. La rotta normale delle miniature resta chiusa al sorvegliante.
Senza miniatura né PDF il numero resta testo.
**Il 31/07 è nata la sotto-app "Piantine"** (nuova voce nel menu ### Assegna aperta dalla Home: senza l'elenco dei territori assegnati (20 ottobre 2026)
Amministrazione): KML dell'area città, editor dei poligoni su OpenStreetMap,
immagini HD dal container `map-renderer` e PDF sul modulo S-12 con storico
versioni. Architettura e agganci sono descritti in [Decisioni
architetturali](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
Al primo deploy il compose costruisce anche l'immagine del renderer; non
servono variabili nuove in Dokploy.
**Il 28/08 la sotto-app è stata rifatta e l'interfaccia ripassata.** Le piantine Richiesta dell'utente: "quando provengo da un territorio dalla home, non
si sono staccate dai territori (una piantina vive anche scollegata, il territorio mostrare lo storico assegnazioni". Avevo capito la scheda del territorio e
si crea dall'editor), il disegno è passato su una pagina a schermo intero, la ne avevo nascosto lo storico (commit `f1f3408`, poi annullato con un revert):
cartolina S-12 è stata reimpaginata (confini sulla prima pagina, piantina al 98% intendeva la pagina *Assegna* aperta da "Assegna →" della Home, e come
sulla seconda) e la rigenerazione massiva è stata rimossa. In più il sistema "storico" l'elenco "Territori Assegnati" sotto il modulo. Ora quel link porta
visivo è stato ripassato su tutta l'applicazione, non solo qui: vedi [Il sistema `?dalla_home=1` e `Assegna::$elencoNascosto` toglie `ElencoAssegnati` (che
visivo sta nei token](#il-sistema-visivo-sta-nei-token-non-nelle-viste). così non si carica nemmeno). Dal menu o dalla scheda del territorio l'elenco
c'è come prima. Non si usa il `?da=` del ritorno per capirlo: si trasmette di
pagina in pagina.
Nello stesso giro è emerso e corretto un guasto d'ambiente che bloccava **tutti** ### Storico assegnazioni in linea (20 ottobre 2026)
gli upload in sviluppo: vedi [`HTTP_HOST` a nginx deve conservare la
porta](#http_host-a-nginx-deve-conservare-la-porta).
Il 30/07 sono state fatte due cose. Richiesta dell'utente (screenshot della scheda territorio): le colonne dello
storico cambiavano posizione da un anno all'altro, perché ogni anno era una
tabella a sé dimensionata sul suo contenuto. Ora, nella scheda del territorio
e in quella del proclamatore, è **una tabella sola** con un'intestazione,
colonne a larghezza fissa (`table-fixed`, `w-[..%]`), e l'anno come riga di
separazione `tr.tm-riga-gruppo` (in `app.css`, su telefono resta un titolo e
non diventa una scheda). Ordine esplicito: anni dal più recente
(`sortKeysDesc`), dentro l'anno per data di assegnazione. Nella scheda del
proclamatore la colonna Campagna ora dice "Sì — …" / "No" come in quella del
territorio, e c'è il badge "via gruppo …". Test: `StoricoAssegnazioniTest`.
**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando, ### Rientro dall'elenco dei territori assegnati (20 ottobre 2026)
senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione
della logica di business — vedi *Cosa resta aperto*. Non fanno parte
dell'immagine di produzione e non girano su un deploy: sono uno strumento
locale.
**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con Richiesta dell'utente ("un tasto che va alla registrazione", dallo screenshot
un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con della pagina *Assegna* con l'elenco filtrato per proclamatore): in
gli stati di focus, che prima non c'erano), e **una sola console** per tutte le `ElencoAssegnati` ogni riga ha **Rientro** (solo con `territori.return`), che
operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di apre la registrazione del rientro e, con `data-ritorno`, torna all'elenco.
log sparsi. L'azzeramento è diventato un componente a sé per poter stare in Anche le righe senza PDF lo hanno. L'elenco ha un tetto di peso per riga
fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel (`AssegnaPrestazioniTest`, 2,6 KB) e il pulsante lo sforava di 21 byte: per
browser — apertura automatica della console, scorrimento, lampeggio delle zone — starci l'indirizzo è relativo (`route(..., false)`, `data-ritorno` lo
non è stato provato**, perché richiede un browser: è la prima cosa da guardare gestisce) e il pulsante sta sulla stessa riga del `<div>`, senza a capo né
aprendo la pagina. commenti dentro il ciclo (i commenti Blade spariscono, gli spazi no).
Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché ### Gruppi di servizio (19-20 ottobre 2026, solo su dev)
non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto*
e l'unica che richiede una decisione, non del lavoro.
--- 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. Le decisioni sono cambiate più volte in due giorni:
qui sotto **com'è oggi**, in fondo la storia.
## Lavori in corso — 12 ottobre 2026 (riprendere da qui) **Come funziona**
Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo): - **Chi**: un gruppo ha un nome (unico), un sorvegliante (proclamatore
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in attivo) e dei membri. Un proclamatore sta in **un solo** gruppo; il
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione sorvegliante è anche membro e assegna solo ai membri, a sé compreso.
(integrità dati). Gruppi, membri e pacchetti li gestisce **solo l'amministratore** (menu
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con *Gruppi di servizio*).
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona: - **Utenza del sorvegliante**: nasce con il gruppo. Email **fittizia**
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi). (`GruppiServizio::emailFittizia()`: iniziale del nome e cognome, minuscoli,
- `426a952` — blocchi 2 e 3 della revisione (sotto); le ultime due righe senza accenti, spazi né apostrofi, su `GruppiServizio::DOMINIO_EMAIL` =
della tabella sono nel commit successivo. `termanager.it` — Francesco Fontana → `ffontana@termanager.it`, numero in
coda se già presa), la stessa convenzione degli altri utenti. Password
temporanea, mostrata una volta: al primo accesso la pagina del cambio dice
"Password temporanea" (`users.password_temporanea`, che il ripristino e
l'import XML spengono, perché lì il motivo è il ripristino). Nessuna
conferma dell'email (`User::hasVerifiedEmail()` vero per il sorvegliante),
e dal profilo l'email non si cambia. Ruolo `sorvegliante`, con il solo
permesso `gruppo.gestisci`; non compare nella pagina *Utenti*, dove le
utenze legate a un proclamatore non si modificano né si eliminano.
"Nuova password temporanea" dalla pagina del gruppo o dalla riga del
sorvegliante nel pannello *Utenti* (stessa logica,
`GruppiServizio::nuovaPasswordTemporanea()`: password provvisoria, cambio
obbligato, "ricordami" azzerato, log `password_reimpostata`; le sessioni
aperte si chiudono perché cambia l'hash). In *Utenti* è l'unica azione su
quell'utenza, con "Apri il gruppo".
- **Cosa vede**: `LimitaSorvegliante` (gruppo `web`) lascia a chi ha **solo**
quel ruolo `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo:
`Rientra::autorizza`), profilo, cambio password e le azioni Livewire —
`*livewire.*`, perché in Livewire 3.7 la rotta si chiama
`default.livewire.update` (con `livewire.*` ogni clic era un 403, e
`Livewire::test()` non passa dai middleware: un test chiama la rotta vera).
La Home lo rimanda al gruppo, il resto è 403. La voce di menu "Il mio
gruppo" guarda il **ruolo**, non `@can`: l'amministratore passa ogni `@can`
(`Gate::before`).
- **Pacchetto**: **non è un'assegnazione** ("la media non deve essere
alterata"): i territori sono "In gruppo …" (stato `in_gruppo`), fuori da
"Da assegnare" e dalla pagina *Assegna* (`Territorio::scopeInReparto`
esclude `gruppo_servizio_id`). Si aggiungono in blocco dai territori in
reparto in ordine di priorità (`Territorio::ordinaPerPriorita()`, la regola
della Home), con ricerca, zona, tipologia, "solo prioritari" e "Seleziona
tutti quelli mostrati" (`GruppoShow`).
- **"Il mio gruppo"** (`MioGruppo`): sotto il titolo nome e cognome del
sorvegliante; prima i territori da dare in ordine di priorità, poi i già
dati dal più vecchio; filtri stato, zona, tipologia, "solo prioritari"
(nell'indirizzo). Il sorvegliante assegna, manda il link (copia o
condividi: trait `Concerns\InviaLink` + partial `assegnazioni/_invio-link`,
gli stessi di *Assegna*), registra i rientri, apre la piantina, rimanda in
reparto quelli non dati. Il numero del territorio apre l'anteprima (finestra
Alpine nella vista, miniatura e collegamento al PDF).
- **Assegnazione dal pacchetto**: un'**assegnazione normale**, con
`gruppo_servizio_id` e `gruppo_nome` ("via gruppo …" in registro e schede).
`GruppiServizio::assegna()` rilegge territorio e membro e ricontrolla il
pacchetto sotto lock. Al **rientro** il territorio esce dal pacchetto e
torna in reparto (hook `updated` in `Assegnazione`, da qualunque pagina).
- **Scioglimento**: "Togli il ruolo e sciogli il gruppo", oppure il
sorvegliante non è più attivo — reso non attivo, nel cestino o anonimizzato
(`Proclamatore::booted`, eventi `updated` su `attivo` e `deleted`; l'elenco
chiede conferma, la modifica avvisa sotto "Attivo"). `GruppiServizio::sciogli()`
**elimina il gruppo**: i territori solo nel pacchetto tornano in reparto;
quelli già dati restano ai membri e l'assegnazione ne conserva il nome
(confermato dall'utente); i membri restano senza gruppo; l'**utenza del
sorvegliante si elimina** con `User::eliminaConservandoStorico()` — la
stessa eliminazione della pagina *Utenti* — che passa le sue assegnazioni a
chi scioglie (o al primo amministratore) e lascia le sue voci nel registro
a nome "Sorvegliante del gruppo …". Il motivo va nel registro
(`gruppo_sciolto`, `motivo`). Un membro non attivo resta nel gruppo ma non
riceve territori; nel cestino ne esce.
- **Segnalazioni**: `Proclamatore::gruppo_etichetta` ("Gruppo Zisa" /
"Sorvegliante del gruppo Zisa") come badge nell'elenco dei proclamatori
(filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa alla
scheda; nelle opzioni di `<x-scelta>` come `dettaglio` (Assegna, Registro),
non come `motivo`: assegnare direttamente a un membro resta possibile. La
scheda del proclamatore mostra il gruppo che guida
(`GruppoServizio::riepilogo()`, email, password temporanea o no, ultimo
accesso) o quello di cui è membro; il *Profilo* del sorvegliante il suo
gruppo.
- **Dati personali**: nel registro delle attività `User::tapActivity()`
sostituisce nome ed email dell'utenza del sorvegliante con
"proclamatore #id", come per tutti i proclamatori; nella tabella `users`
restano in chiaro, come per ogni utente. Informativa privacy aggiornata.
- **Backup**: l'XML porta gruppi, membri, pacchetto, provenienza delle
assegnazioni (id e nome del gruppo) e il legame utenza ↔ proclamatore; da
un file del 19-20 ottobre non si importano i gruppi con `<sciolto_at>` né
le utenze con `<disattivato_at>`. L'azzeramento (*Zona pericolosa*)
cancella anche i gruppi.
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di **Dove sta**
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026) - Migrazioni: `2026_10_20_000010_gruppi_di_servizio` (tabella, colonne
`gruppo_servizio_id` su proclamatori, territori e assegnazioni,
`users.proclamatore_id` unico, permesso e ruolo; il seeder dà
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`),
`000020` (`assegnazioni.gruppo_nome`, via `sciolto_at` e i gruppi sciolti),
`000030` (via `users.username` e `users.disattivato_at` e le utenze
disattivate, email fittizia a chi non l'aveva, email di nuovo obbligatoria),
`000040` (`users.password_temporanea`).
- Regole in `App\Services\GruppiServizio`; i componenti `Gruppi\GruppiIndex`,
`Gruppi\GruppoShow` (amministratore) e `Gruppi\MioGruppo` (sorvegliante) le
chiamano soltanto.
- Test: `tests/Feature/GruppiServizioTest.php`.
Richieste dell'utente, con le sue scelte: **Com'è cambiato** (decisioni dell'utente, in ordine)
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta 1. 19/10 — gruppo con membri; il pacchetto non conta nelle statistiche;
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura utenza con email e password, senza conferma; togliere il ruolo scioglie il
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante gruppo, che restava archiviato (`sciolto_at`) con l'utenza disattivata.
"Invia email di prova". Se compilato prende il posto del `.env` 2. 20/10 — sorvegliante non più attivo ⇒ gruppo sciolto; dettagli nella
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail scheda e nel profilo; anche i membri segnalati.
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la 3. 20/10 — il gruppo sciolto non resta nello storico (si elimina); traccia
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era "via gruppo" sulle assegnazioni confermata. Per qualche ora il sorvegliante
un valore non valido per Laravel 11: ora vuoto. è entrato con un nome utente ricavato dal nome del gruppo.
- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome, 4. 20/10 — al posto del nome utente l'email fittizia iniziale+cognome; sciolto
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre il gruppo l'utenza si elimina ("torna proclamatore normale, non tenere
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si traccia").
considera italiano). 5. 20/10 — "Password temporanea" al primo accesso; il 403 sulle azioni;
- **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link priorità e filtri; nome del sorvegliante sotto il titolo.
dopo l'assegnazione" qui sotto*. Il riquadro in sessione
(`Assegna::DA_INVIARE`) non esiste più.
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`).
L'admin crea l'utente con la password come prima; parte l'email di conferma
(in italiano, `VerifyEmail::toMailUsing`); il link funziona anche senza
login. Cambiare email (profilo o admin) la rende da confermare e rimanda il
link. In Utenti: stato "da confermare" e "Reinvia conferma".
Raggiungibili senza conferma: profilo, cambio password, pagina di conferma
e — per l'amministratore — il server di posta.
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
creato da riga di comando nasce confermato.
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
`$confermaUtentiCreati`).
Provato nel browser in locale con mailpit come server configurato dall'app:
email di prova e di conferma arrivate, link aperto senza login, account
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
e dati di prova rimossi dopo.
### 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)
@@ -271,15 +478,17 @@ Proclamatore Semplificato" che non esistono).
non è HTTPS), i ruoli con i permessi veri (`Privacy::PERMESSI` li spiega) e non è HTTPS), i ruoli con i permessi veri (`Privacy::PERMESSI` li spiega) e
quanti dati sono conservati (contati in SQL: i campi cifrati vuoti restano quanti dati sono conservati (contati in SQL: i campi cifrati vuoti restano
NULL, non serve decifrare). `Privacy::REVISIONE` è la data del testo. NULL, non serve decifrare). `Privacy::REVISIONE` è la data del testo.
- Aggiunte: email/telefono cifrati, link alla piantina, invio via WhatsApp - Aggiunte: link alla piantina, invio dal telefono di chi assegna, mappe
(parte dal telefono di chi assegna) ed email (server di posta configurato), OpenStreetMap, conferma email, blocco dopo troppi tentativi. (Email e
mappe OpenStreetMap, conferma email, blocco dopo troppi tentativi. telefono dei proclamatori, che il primo testo citava, sono stati tolti la
sera stessa; dal 20 ottobre il testo parla anche del gruppo di servizio e
dell'utenza del sorvegliante.)
- Titolare e base giuridica sono rimasti com'erano: sono valutazioni legali, - Titolare e base giuridica sono rimasti com'erano: sono valutazioni legali,
da far rivedere a chi ne ha la competenza. da far rivedere a chi ne ha la competenza.
- **Aperto — backup in chiaro**: il pacchetto di backup usa l'export XML, che - **Aperto — backup in chiaro**: il pacchetto di backup usa l'export XML, che
legge i proclamatori dal modello e quindi esporta nome, cognome, email e legge i proclamatori dal modello e quindi esporta nome e cognome
telefono **decifrati**. L'informativa ora lo dice; valutare se cifrare **decifrati**. L'informativa lo dice; valutare se cifrare l'archivio (per
l'archivio (per esempio con una password scelta al download). esempio con una password scelta al download).
- `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli, - `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli,
i conteggi senza nomi e l'accesso solo amministratore. i conteggi senza nomi e l'accesso solo amministratore.
@@ -373,14 +582,14 @@ all'**elenco filtrato**, non alla scheda (scelta esplicita).
Richieste dell'utente: Richieste dell'utente:
- **Dopo l'assegnazione non si va avanti** finché non si è deciso del link: si - **Dopo l'assegnazione non si va avanti** finché non si è deciso del link: si
apre una finestra che blocca la pagina (`Assegna::apriInvio`) con i canali apre una finestra che blocca la pagina (oggi trait `Concerns\InviaLink`,
usato anche da "Il mio gruppo") con i canali. *Superato la sera stessa:
WhatsApp ed email sono stati tolti, restano Copia link e Condividi… — vedi
"Proclamatori senza contatti".* Com'era il primo giorno:
- *Copia link*: sempre; - *Copia link*: sempre;
- *WhatsApp*: solo se il proclamatore ha un numero utilizzabile; - *WhatsApp*: solo se il proclamatore ha un numero utilizzabile;
- *Email*: solo se ha un indirizzo **e** la posta funziona - *Email*: solo se ha un indirizzo **e** la posta funziona
(`ConfigurazionePosta::funzionante`). La manda l'app con (`ConfigurazionePosta::funzionante`), inviata subito e mai in coda.
`Notification::route('mail', …)->notifyNow(new LinkTerritorio)`: **mai in
coda**, perché il job salverebbe in chiaro su Redis/`failed_jobs`
l'indirizzo che nel database è cifrato.
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
possibile. Solo dopo la risposta si ricarica la pagina. possibile. Solo dopo la risposta si ricarica la pagina.
@@ -392,17 +601,62 @@ Richieste dell'utente:
"Non inviato / Link inviato" dell'elenco. "Non inviato / Link inviato" dell'elenco.
- Colonne nuove (migrazione `2026_10_19_000010`): `link_da_inviare` (le - Colonne nuove (migrazione `2026_10_19_000010`): `link_da_inviare` (le
assegnazioni già esistenti restano false: niente avvisi a posteriori su assegnazioni già esistenti restano false: niente avvisi a posteriori su
link di cui non si sa niente) e `link_canale` (`copia`/`whatsapp`/`email`, link di cui non si sa niente) e `link_canale` (oggi `copia`/`condividi`,
`Assegnazione::CANALI_LINK`). Entrambe nell'XML. `Assegnazione::CANALI_ATTIVI`; `whatsapp`/`email` restano in
`CANALI_LINK` solo per le assegnazioni già inviate così). Entrambe nell'XML.
- Nel log `link_inviato` / `link_non_inviato` con canale e `proclamatore_id`: - Nel log `link_inviato` / `link_non_inviato` con canale e `proclamatore_id`:
mai nome, numero o email. Email e telefono restano solo, cifrati, nel mai nomi.
proclamatore.
- **"Nuovo territorio"** (elenco territori) apre il designer delle piantine - **"Nuovo territorio"** (elenco territori) apre il designer delle piantine
con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in
disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto. disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto.
L'editor è solo dell'amministratore: chi ha `territori.manage` ma non L'editor è solo dell'amministratore: chi ha `territori.manage` ma non
`settings.manage` resta sul form `territori.create`, che esiste ancora. `settings.manage` resta sul form `territori.create`, che esiste ancora.
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026)
Richieste dell'utente, con le sue scelte:
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante
"Invia email di prova". Se compilato prende il posto del `.env`
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era
un valore non valido per Laravel 11: ora vuoto.
- ~~Proclamatori: email e telefono facoltativi~~ — *tolti il 19 ottobre 2026,
vedi "Proclamatori senza contatti" qui sotto*.
- **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
dopo l'assegnazione" qui sotto*. Il riquadro in sessione
(`Assegna::DA_INVIARE`) non esiste più.
- **Conferma email obbligatoria** per tutti gli utenti (tranne il sorvegliante
di gruppo), anche quelli già esistenti (la migrazione `2026_10_13_000010`
azzera `email_verified_at`). L'admin crea l'utente con la password come
prima; parte l'email di conferma (in italiano, `VerifyEmail::toMailUsing`);
il link funziona anche senza login. Cambiare email (profilo o admin) la
rende da confermare e rimanda il link. In Utenti: stato "da confermare" e
"Reinvia conferma". Raggiungibili senza conferma: profilo, cambio password,
pagina di conferma e — per l'amministratore — il server di posta.
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
creato da riga di comando nasce confermato.
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
`$confermaUtentiCreati`).
Provato nel browser in locale con mailpit come server configurato dall'app:
email di prova e di conferma arrivate, link aperto senza login, account
sbloccato. Configurazione e dati di prova rimossi dopo.
### Modulo S-13, editor piantine, revisione (12 ottobre 2026)
In produzione dal 19 ottobre 2026 (erano su `dev` dal 12):
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione
(integrità dati).
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
- `426a952` — blocchi 2 e 3 della revisione (sotto).
### Revisione profonda: blocchi 2 e 3 chiusi ### Revisione profonda: blocchi 2 e 3 chiusi
| Problema | Correzione | | Problema | Correzione |
@@ -457,20 +711,92 @@ a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione.
Da qui la ristrutturazione descritta sotto. Da qui la ristrutturazione descritta sotto.
### Luglio-agosto 2026
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto:
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre
di avanzamento reali e console di log (commit `f909791`, deploy verificato
confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo**
su develop; la produzione non è stata toccata e resta sul suo deploy
precedente, senza quella variabile.
**Il 31/07 è nata la sotto-app "Piantine"** (nuova voce nel menu
Amministrazione): KML dell'area città, editor dei poligoni su OpenStreetMap,
immagini HD dal container `map-renderer` e PDF sul modulo S-12 con storico
versioni. Architettura e agganci sono descritti in [Decisioni
architetturali](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
Al primo deploy il compose costruisce anche l'immagine del renderer; non
servono variabili nuove in Dokploy.
**Il 28/08 la sotto-app è stata rifatta e l'interfaccia ripassata.** Le piantine
si sono staccate dai territori (una piantina vive anche scollegata, il territorio
si crea dall'editor), il disegno è passato su una pagina a schermo intero, la
cartolina S-12 è stata reimpaginata (confini sulla prima pagina, piantina al 98%
sulla seconda) e la rigenerazione massiva è stata rimossa. In più il sistema
visivo è stato ripassato su tutta l'applicazione, non solo qui: vedi [Il sistema
visivo sta nei token](#il-sistema-visivo-sta-nei-token-non-nelle-viste).
Nello stesso giro è emerso e corretto un guasto d'ambiente che bloccava **tutti**
gli upload in sviluppo: vedi [`HTTP_HOST` a nginx deve conservare la
porta](#http_host-a-nginx-deve-conservare-la-porta).
Il 30/07 sono state fatte due cose.
**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando,
senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione
della logica di business — vedi *Cosa resta aperto*. Non fanno parte
dell'immagine di produzione e non girano su un deploy: sono uno strumento
locale.
**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con
un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con
gli stati di focus, che prima non c'erano), e **una sola console** per tutte le
operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di
log sparsi. L'azzeramento è diventato un componente a sé per poter stare in
fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel
browser — apertura automatica della console, scorrimento, lampeggio delle zone —
non è stato provato**, perché richiede un browser: è la prima cosa da guardare
aprendo la pagina.
Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché
non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto*
e l'unica che richiede una decisione, non del lavoro.
--- ---
## Dove gira davvero ## Dove gira davvero
Due ambienti su Dokploy, stesso progetto `TerManager2`, entrambi alimentati dal Due ambienti su Dokploy, stesso progetto `TerManager2`, entrambi alimentati dal
repository Gitea `francesco/termanager2` con **autoDeploy attivo**. repository Gitea `francesco/termanager2` su `repository.pyconetwork.it` (il
vecchio `git.pyconetwork.it` non è più la sorgente).
| Ambiente | Branch | Dominio | Compose (`appName`) | | Ambiente | Branch | Dominio | Compose (nome / `appName` / `composeId`) |
|--------------|--------|-------------------------------------|----------------------------| |--------------|--------|-------------------------------------|------------------------------------------|
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-vizcxr` | | `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-production` / `termanager2-core-vizcxr` / `vh_NtgaPyfQintCbcB3p6` |
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-vbekis` | | `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-demo` / `termanager2-core-vbekis` / `20DoOKciTzmhlPWdcqcxz` |
Conseguenze pratiche, tutte imparate sul campo: Conseguenze pratiche, tutte imparate sul campo:
- **L'autodeploy non parte.** È attivo in Dokploy, ma dal 19 ottobre 2026
nessun push ha avviato un deploy (il webhook di Gitea è da verificare):
dopo un push si aspettano un paio di minuti e, se in `deployment` non
compare niente di nuovo, si avvia a mano (`compose.deploy` via API, o
*Deploy* nell'interfaccia). Controllare prima di avviare, per non fare
deploy doppi.
- **Test sul server, prima del push.** In locale (Windows) non ci sono PHP,
Docker né Node: la suite gira sul server Dokploy con una *schedule* di tipo
`dokploy-server` usa e getta, che costruisce lo stage `test` del Dockerfile
e lancia anche `node --test tests/js/`. Per provare un commit non ancora
pubblicato (il push su `dev` lo pubblicherebbe): `git bundle create x
<commit della checkout>..dev`, bundle in base64 dentro lo script, `git
clone` della checkout di Dokploy in `/tmp` e `git fetch` del bundle. La
checkout di Dokploy (`/etc/dokploy/compose/<appName>/code`) è **shallow** e
può essere indietro rispetto a `origin/dev` (i commit di sola
documentazione non la aggiornano): il bundle parte dal suo `git log -1`. La
schedule gira dentro il container di Dokploy: `docker run -v /tmp/...` non
vede quei file, si passano con `tar | docker run -i`.
- **Il branch decide l'ambiente.** Pubblicare su `dev` non tocca la produzione, - **Il branch decide l'ambiente.** Pubblicare su `dev` non tocca la produzione,
che segue `main`. È la separazione su cui contare quando si vuole provare che segue `main`. È la separazione su cui contare quando si vuole provare
qualcosa senza rischi. qualcosa senza rischi.
@@ -1445,7 +1771,9 @@ blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
docker compose --profile test run --build --rm test docker compose --profile test run --build --rm test
``` ```
**Coperto oggi — 294 test, tutti verdi:** **Coperto oggi — 613 test PHP e 13 JavaScript, tutti verdi (21/10/2026).**
Senza Docker in locale si fanno girare sul server: vedi [Dove gira
davvero](#dove-gira-davvero). I file storici, con cosa proteggono:
| File | Cosa protegge | | File | Cosa protegge |
|------|---------------| |------|---------------|
@@ -1462,6 +1790,23 @@ docker compose --profile test run --build --rm test
| `tests/Unit/PiantinaRendererTest.php` | Il client del renderer: formato, qualita' e lato massimo davvero spediti, JPEG salvato con l'estensione giusta, il file di un formato precedente rimosso invece che lasciato orfano, errori e risposte non-immagine trasformati in messaggi leggibili, geometria corrotta bloccata prima della chiamata | | `tests/Unit/PiantinaRendererTest.php` | Il client del renderer: formato, qualita' e lato massimo davvero spediti, JPEG salvato con l'estensione giusta, il file di un formato precedente rimosso invece che lasciato orfano, errori e risposte non-immagine trasformati in messaggi leggibili, geometria corrotta bloccata prima della chiamata |
| `tests/Feature/HomeMediaDurataTest.php` | La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio | | `tests/Feature/HomeMediaDurataTest.php` | La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio |
Arrivati dopo (ottobre 2026), uno per funzione: `GruppiServizioTest` (gruppi
di servizio, compresa la rotta vera delle azioni Livewire del sorvegliante,
priorità e filtri), `RichiesteTest` (modulo pubblico e anti-spam, nomi
simili, Home, richiesta evasa da Assegna e Rientra, pulizia, cartacei, S-13
per email e la regola dell'ultima domenica), `InvioLinkTest` (invio del link,
pulsante Rientro, Assegna dalla Home), `StoricoAssegnazioniTest`, `SceltaRicercaTest` e
`tests/js/scelta.test.mjs` (scelta con ricerca), `RitornoTest`,
`AssegnaPrestazioniTest` (peso della pagina Assegna e delle righe
dell'elenco), `AssegnazioneSovrapposizioneTest`, `CampagnaCalcoliTest`,
`CambioPasswordTest`, `ConfermaEmailTest`, `PostaElettronicaTest`,
`PrivacyTest`, `LogSenzaNomiTest`, `AvvisiTest`, `CodaTest`,
`UltimoAmministratoreTest`, i test di import (`XmlExchangeImportTest`,
`ImportXmlFileOrfaniTest`, `ImportPdfTerritoriTest`, `PostoUnicoImportTest`,
`RipristinoBackupTest`) e dei file (`TerritorioFileTest`,
`TerritoriFileOrfaniTest`, `PubblicazionePiantinaTest`,
`AzzeramentoPiantineTest`, `PiantineAreaTest`).
Il primo giro ha già trovato tre difetti reali, tutti corretti: il warning di Il primo giro ha già trovato tre difetti reali, tutti corretti: il warning di
`ZipArchive::addFile()` che copriva il messaggio d'errore vero, il `GREATEST()` `ZipArchive::addFile()` che copriva il messaggio d'errore vero, il `GREATEST()`
in migrazione, e `@disabled` dentro un tag di componente (vedi *Bug corretti*). in migrazione, e `@disabled` dentro un tag di componente (vedi *Bug corretti*).
@@ -1476,22 +1821,25 @@ nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
entry, dimensione della singola entry, totale decompresso. Sono tre `if` che entry, dimensione della singola entry, totale decompresso. Sono tre `if` che
nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio. nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio.
- `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**: - `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**:
l'ordine staging → database → file, il `failed()` che libera il posto, la in parte coperti (`RipristinoBackupTest`, `ZonaPericolosaTest`,
sopravvivenza dell'amministratore che ha chiesto l'azzeramento, il `AzzeramentoPiantineTest`, l'azzeramento in `GruppiServizioTest`); restano
`password_change_required` acceso su tutte le righe. È la logica il cui da verificare l'ordine staging → database → file e il `failed()` che libera
errore si scopre solo quando è troppo tardi. il posto. È la logica il cui errore si scopre solo quando è troppo tardi.
- `Livewire/Settings/XmlExchange`: il **round-trip** export → import senza - `Livewire/Settings/XmlExchange`: il round-trip export → import è coperto per
perdite, che è il presidio contro il tipo di bug già visto con `legacy_id`. le parti nuove (gruppi, invio del link) e da `XmlExchangeImportTest`, ma
non campo per campo su tutto il dataset.
- Le regole di business: anno teocratico (`AnnoTeocratico::perData` a cavallo - Le regole di business: anno teocratico (`AnnoTeocratico::perData` a cavallo
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`, del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`.
priorità automatica, percentuale di percorrenza delle campagne. La priorità (`Territorio::ordinaPerPriorita`) e la percorrenza delle
campagne ora hanno i loro test.
- Il permesso come contratto: che un `assistente` **non** possa modificare o - Il permesso come contratto: che un `assistente` **non** possa modificare o
eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È
scritto in tre posti (rotta, componente, vista) e un test di richiesta lo scritto in tre posti (rotta, componente, vista) e un test di richiesta lo
verificherebbe tutto insieme. verificherebbe tutto insieme.
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard Attenzione a non scrivere test su ciò che SQLite non sa eseguire: resta solo
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL. l'ordinamento di `Registro::render()`, che usa SQL specifico di MySQL (la Home
non più: vedi *La suite di test ha un'immagine sua*).
### 2. Race su doppia assegnazione — chiusa il 09/10/2026 ### 2. Race su doppia assegnazione — chiusa il 09/10/2026
@@ -1558,6 +1906,18 @@ e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e
la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina
lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca. lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca.
### 8. L'autodeploy non parte
Dal 19 ottobre 2026 nessun push su `dev` o `main` ha avviato un deploy, pur
con autoDeploy attivo in Dokploy. Da verificare il webhook del repository in
Gitea (`repository.pyconetwork.it`). Intanto ogni deploy si avvia a mano:
vedi [Dove gira davvero](#dove-gira-davvero).
### 9. Gruppi di servizio e lavori del 20 ottobre non ancora in produzione
Sono sulla demo e l'utente li sta provando lì; per il rilascio serve il suo
ok. Come portarli in produzione: [Stato attuale](#stato-attuale).
--- ---
## Trappole da conoscere ## Trappole da conoscere
@@ -1601,6 +1961,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.
@@ -1644,7 +2012,8 @@ cp .env.example .env
# SESSION_SECURE_COOKIE=false, e le tre password # SESSION_SECURE_COOKIE=false, e le tre password
docker network create dokploy-network docker network create dokploy-network
# 0. La suite passa (non serve nient'altro acceso, ~15 s) # 0. La suite passa (non serve nient'altro acceso). Senza Docker in locale:
# sul server Dokploy, vedi "Dove gira davvero"
docker compose --profile test run --build --rm test docker compose --profile test run --build --rm test
# 1. L'immagine builda # 1. L'immagine builda

263
README.md
View File

@@ -1,8 +1,11 @@
# TerManager2 # TerManager2
Applicazione web per la gestione dell'assegnazione e del rientro dei territori Applicazione web per la gestione dell'assegnazione e del rientro dei territori
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali (cartoline) di congregazione, con statistiche di percorrenza, campagne speciali,
e registro storico completo. gruppi di servizio (un sorvegliante distribuisce ai membri un pacchetto di
territori), un modulo pubblico con cui i proclamatori chiedono un territorio o
ne annunciano la riconsegna, l'elenco delle cartoline cartacee fuori, il
modulo S-13 per email ogni mese e registro storico completo.
I dati personali dei proclamatori sono **cifrati nel database**, e ogni I dati personali dei proclamatori sono **cifrati nel database**, e ogni
operazione è tracciata in un **audit log** consultabile. operazione è tracciata in un **audit log** consultabile.
@@ -49,7 +52,7 @@ eseguito né `composer install` né `npm build` all'avvio.
|----------------|------------------------------------------------|---------| |----------------|------------------------------------------------|---------|
| `app` | nginx + php-fpm — l'applicazione | via Traefik | | `app` | nginx + php-fpm — l'applicazione | via Traefik |
| `queue-worker` | Job in coda (import PDF massivi) | no | | `queue-worker` | Job in coda (import PDF massivi) | no |
| `scheduler` | Task pianificati (pulizia audit log) | no | | `scheduler` | Task pianificati (pulizie, S-13 mensile) | no |
| `mariadb` | Database | no | | `mariadb` | Database | no |
| `redis` | Cache, sessioni, code | no | | `redis` | Cache, sessioni, code | no |
@@ -410,24 +413,27 @@ Redis, quindi puntarli su un driver diverso interrompe l'elaborazione dei job.
Configurabili dall'interfaccia (**Impostazioni**, permesso `settings.manage`), Configurabili dall'interfaccia (**Impostazioni**, permesso `settings.manage`),
non da variabili d'ambiente: non da variabili d'ambiente:
| Impostazione | Descrizione | | Impostazione | Descrizione |
|--------------------------------|------------------------------------------------------| |------------------------------------|------------------------------------------------------|
| Nome congregazione | Mostrato nell'intestazione e nei PDF | | Nome congregazione | Mostrato nell'intestazione, nei PDF e nell'informativa |
| Logo | Immagine nell'intestazione | | Soglia da assegnare (giorni) | Dopo quanti giorni dal rientro un territorio è "da assegnare" |
| URL pubblico base | Base dei link PDF condivisibili | | Soglia priorità (giorni) | Dopo quanti giorni dal rientro un territorio diventa prioritario |
| Giorni giacenza "da assegnare" | Soglia oltre cui un territorio è da assegnare | | Soglia rientro (giorni) | Oltre questa un'assegnazione è "da rientrare"; è anche la durata del link alla piantina |
| Mesi giacenza "prioritari" | Soglia oltre cui un territorio diventa prioritario | | Limite liste Home | Quanti territori mostrare nelle due liste della Home |
| Giorni per "smarrito" | Soglia oltre cui un'assegnazione è da rientrare | | "Scarica PDF" nel viewer | Mostra o nasconde il pulsante di download a chi apre il link |
| Elementi nelle liste della home| Quanti record mostrare nelle tre liste rapide | | Conservazione audit (giorni) | Dopo quanti giorni le voci del registro delle attività si cancellano |
| Validità link PDF (mesi) | Scadenza dei link pubblici alle cartoline |
| Download PDF visibile | Mostra o nasconde il pulsante di download | Il server di posta ha una pagina sua (*Impostazioni → Server di posta*). Il
| Retention audit log (giorni) | Dopo quanti giorni i log vengono cancellati | logo nell'intestazione, se c'è, arriva da un backup: la pagina non lo carica.
--- ---
## Aggiornamenti ## Aggiornamenti
Push su `main` → **Deploy** su Dokploy. Ad ogni avvio il container: Push su `main` → **Deploy** su Dokploy. Se l'autodeploy non parte entro un
paio di minuti (succede: vedi HANDOFF, *Dove gira davvero*), il deploy si
avvia a mano dall'interfaccia o con `compose.deploy`. Ad ogni avvio il
container:
1. ricrea le directory di storage e risolve `APP_KEY` 1. ricrea le directory di storage e risolve `APP_KEY`
2. attende il database 2. attende il database
@@ -626,12 +632,21 @@ docker exec -it <app> php artisan audit:cleanup
docker exec -it <app> php artisan backup:cleanup docker exec -it <app> php artisan backup:cleanup
docker exec -it <app> php artisan backup:cleanup --ore=0 # tutti docker exec -it <app> php artisan backup:cleanup --ore=0 # tutti
# Pulizia manuale delle richieste dal modulo pubblico gestite da più di 90 giorni
docker exec -it <app> php artisan richieste:pulizia
# Mandare subito il modulo S-13 agli amministratori (anno in corso, o un altro)
docker exec -it <app> php artisan registro:invia-s13
docker exec -it <app> php artisan registro:invia-s13 --anno=2025-2026
# Svuotare le cache # Svuotare le cache
docker exec -it <app> php artisan optimize:clear docker exec -it <app> php artisan optimize:clear
``` ```
Entrambe le pulizie girano già da sole ogni notte (container `scheduler`); i Le pulizie girano già da sole ogni notte e il modulo S-13 parte da solo
comandi qui sopra servono solo per anticiparle. l'ultima domenica del mese alle 8 (container `scheduler`); i comandi qui sopra
servono solo per anticiparli. Senza un server di posta funzionante il modulo
S-13 non parte (il comando lo dice).
--- ---
@@ -687,9 +702,13 @@ docker compose --profile dev up -d --build
- Anno teocratico corrente (creato automaticamente, settembre → agosto) - Anno teocratico corrente (creato automaticamente, settembre → agosto)
- Media mensile di percorrenza e durata media delle assegnazioni - Media mensile di percorrenza e durata media delle assegnazioni
- Campagna attiva con barra di progresso - Campagna attiva con barra di progresso
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**, - Due liste rapide con azione diretta: **da assegnare** (prima i prioritari,
**da rientrare**; quando l'elenco è troncato, **"Vedi tutti"** è un pulsante con il badge ★, poi quelli fermi da più tempo) e **da rientrare**; quando
in testata che apre l'elenco territori già filtrato l'elenco è troncato, **"Vedi tutti"** è un pulsante in testata che apre
l'elenco territori già filtrato. "Assegna →" apre la pagina *Assegna* con
il territorio scelto e senza l'elenco dei territori assegnati
- **Richieste** dal modulo pubblico (amministratore e assistenti): vedi
[Richieste dal modulo pubblico](#richieste-dal-modulo-pubblico)
### Territori ### Territori
- CRUD completo con upload PDF e miniatura generata automaticamente - CRUD completo con upload PDF e miniatura generata automaticamente
@@ -698,7 +717,9 @@ docker compose --profile dev up -d --build
il form) il form)
- Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina - Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina
- Badge di stato colorati e indicatore di priorità (manuale o automatica) - Badge di stato colorati e indicatore di priorità (manuale o automatica)
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico - Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico:
una tabella sola con le colonne in linea, l'anno come riga di separazione,
dal più recente (lo stesso nella scheda del proclamatore)
- Link PDF pubblico condivisibile, a scadenza configurabile - Link PDF pubblico condivisibile, a scadenza configurabile
- Soft delete con cestino (ripristino o eliminazione definitiva) - Soft delete con cestino (ripristino o eliminazione definitiva)
- Import massivo di PDF da cartella o archivio ZIP, elaborato in background - Import massivo di PDF da cartella o archivio ZIP, elaborato in background
@@ -728,13 +749,106 @@ docker compose --profile dev up -d --build
"Invia link" per riprendere. Anche dall'elenco dei territori assegnati si "Invia link" per riprendere. Anche dall'elenco dei territori assegnati si
può copiare o condividere il link può copiare o condividere il link
- L'elenco dei territori assegnati si carica subito dopo la pagina e si filtra - L'elenco dei territori assegnati si carica subito dopo la pagina e si filtra
nel browser, senza attese nel browser, senza attese; ogni territorio ha il pulsante **Rientro**, che
porta alla registrazione del rientro e poi torna all'elenco. Aperta da
"Assegna →" della Home, la pagina mostra solo il modulo, senza l'elenco
- Rientro con calcolo automatico dei giorni - Rientro con calcolo automatico dei giorni
- **Prompt campagna**: se il territorio era fuori durante una campagna viene - **Prompt campagna**: se il territorio era fuori durante una campagna viene
chiesto se è stato percorso per la campagna; oltre la soglia "da rientrare" chiesto se è stato percorso per la campagna; oltre la soglia "da rientrare"
compare un avviso di tempo di percorrenza molto alto compare un avviso di tempo di percorrenza molto alto
- **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)
- **Cartolina cartacea**: assegnando (anche il sorvegliante di gruppo) si può
spuntare *Ha chiesto la cartolina cartacea*. Il segno sta sull'assegnazione:
nell'elenco degli assegnati compare l'etichetta "cartaceo" e la pagina
**Cartacei** (menu, sotto *Assegnazioni*) elenca i territori cartacei fuori
e a chi, con i giorni e il pulsante Rientro. Al rientro escono dall'elenco
### Richieste dal modulo pubblico
- **Modulo pubblico** all'indirizzo `/richiesta`, senza login (l'indirizzo
completo è scritto nella Home, da passare ai proclamatori): nome e cognome,
*nuovo territorio* o *riconsegna*, una nota. Nella riconsegna il territorio
si sceglie con il **completamento automatico**: si scrive il numero o la
zona e compaiono i territori (attivi, o disattivati ma ancora fuori), con
numero e zona soltanto; si accetta solo un territorio dell'elenco. A parte
questo elenco il modulo non mostra nessun dato già presente: né nomi, né a
chi è assegnato un territorio o se lo è
- **Contro lo spam**: un campo trappola nascosto, un tempo minimo fra apertura
e invio (in questi due casi risponde "inviata" senza salvare) e al massimo 5
richieste all'ora dallo stesso indirizzo
- **Avviso per email** ad amministratori e assistenti (permesso
`richieste.gestisci`) con l'email confermata, se il server di posta
funziona; la richiesta resta comunque nella Home
- **Nella Home**, riquadro *Richieste*: per un nuovo territorio i proclamatori
attivi con un nome simile a quello scritto (uguale, iniziale, una lettera
sbagliata, in qualsiasi ordine e senza accenti), ognuno porta al modulo
*Assegna* già con quel proclamatore; *Nessuno di questi* apre il modulo
vuoto. Per una riconsegna, a chi risulta assegnato il territorio e *Registra
rientro →*. Fatta l'assegnazione o il rientro, la richiesta risulta evasa;
altrimenti si può **scartare**
- Nome e nota sono cifrati nel database e non vanno nel registro delle
attività; le richieste gestite si cancellano da sole dopo 90 giorni
(`richieste:pulizia`, ogni notte)
### 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** con un'**email
fittizia** creata dall'applicazione: iniziale del nome e cognome sul
dominio `termanager.it` (Francesco Fontana → `ffontana@termanager.it`; se
è già presa, un numero in coda), e una password temporanea. Si vedono una
volta sola; la password va cambiata al primo accesso (la pagina dice
"Password temporanea", non "Ripristino da backup"). L'email non si
chiede, non riceve posta e non va confermata. Due gruppi non possono avere
lo stesso nome. Se perde la password, l'amministratore ne genera un'altra
temporanea dalla pagina del gruppo o dal pannello *Utenti* ("Nuova password
temporanea" sulla riga del sorvegliante): si vede una volta sola, va
cambiata al primo accesso, e le sessioni aperte con quella vecchia si
chiudono
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto,
**in ordine di priorità** come "Da assegnare" nella Home (prima i
prioritari, poi quelli fermi da più tempo), con ricerca, filtri per zona,
tipologia e "solo prioritari", e "Seleziona tutti quelli mostrati".
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. Il suo
pacchetto è in ordine di priorità (prima i da dare, prioritari in testa,
poi quelli già dati) e si filtra per stato, zona, tipologia e "solo
prioritari"; sotto il nome del gruppo compaiono nome e cognome del
sorvegliante. Toccando il numero di un territorio si apre l'**anteprima**
(la miniatura della piantina) con il collegamento al PDF
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
gruppo**, che non resta nello storico: i territori solo nel pacchetto
tornano in reparto, i membri si liberano e l'**utenza del sorvegliante si
elimina** (anche una sessione aperta si chiude): torna un proclamatore come
gli altri. Le assegnazioni che aveva fatto passano a chi scioglie il
gruppo; nel registro delle attività le sue voci restano a nome
"Sorvegliante del gruppo …". I territori già dati restano ai membri come
assegnazioni qualsiasi, che del gruppo conservano solo il nome ("via
gruppo …"). Per ripartire si crea un gruppo nuovo
- **Un sorvegliante che non è più attivo scioglie il gruppo**, come togliergli
il ruolo: succede se lo si rende non attivo (dall'elenco o dalla modifica),
se lo si sposta nel cestino o lo si anonimizza. Prima di disattivarlo o
cestinarlo dall'elenco si chiede conferma; riattivarlo non ricostruisce il
gruppo. Un membro non attivo resta nel gruppo ma non riceve territori; nel
cestino esce dal gruppo
- **Chi fa parte di un gruppo è segnalato**: badge "Gruppo …" o
"Sorvegliante del gruppo …" nell'elenco dei proclamatori (con il filtro
*In un gruppo di servizio*) e in testa alla scheda; nella scelta del
proclamatore (Assegna, Registro) la nota "gruppo …" accanto al nome, che
resta comunque sceglibile
- **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante
(gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal
gruppo, email per entrare, stato della password e ultimo accesso) o membro; il
sorvegliante ritrova gli stessi numeri nel suo
*Profilo*
### Campagne ### Campagne
- CRUD con date di inizio e fine - CRUD con date di inizio e fine
@@ -745,6 +859,10 @@ docker compose --profile dev up -d --build
### Registro assegnazioni ### Registro assegnazioni
- Filtri per anno teocratico, zona, tipologia, stato e ricerca testuale - Filtri per anno teocratico, zona, tipologia, stato e ricerca testuale
- Colonne: territorio, proclamatore, date, giorni, campagna - Colonne: territorio, proclamatore, date, giorni, campagna
- **Modulo S-13 per email**: l'ultima domenica di ogni mese alle 8 il modulo
S-13 dell'anno di servizio in corso parte in PDF agli amministratori,
all'email della loro utenza se confermata (`registro:invia-s13`, anche a
mano). Non passa dalla coda: il PDF contiene i nomi dei proclamatori
### Audit log ### Audit log
- Tracciamento automatico su tutti i modelli - Tracciamento automatico su tutti i modelli
@@ -771,24 +889,33 @@ 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` | ✅ | | | |
| `richieste.gestisci` | ✅ | ✅ | | |
| `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. Della sua utenza, in *Utenti*,
si può solo generare una nuova password temporanea (e aprire il gruppo). 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.
@@ -803,7 +930,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
- **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle - **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle
richieste Livewire richieste Livewire
- **CSRF**: token su tutti i form - **CSRF**: token su tutti i form
- **Rate limiting**: sul login - **Rate limiting**: sul login e sul modulo pubblico delle richieste (5
all'ora per indirizzo, più campo trappola e tempo minimo contro lo spam)
- **Host header**: accettate solo richieste il cui `Host` corrisponde a `APP_URL` - **Host header**: accettate solo richieste il cui `Host` corrisponde a `APP_URL`
- **Scheme forwarding**: lo schema è deciso dal server, non dall'header del - **Scheme forwarding**: lo schema è deciso dal server, non dall'header del
client, per non falsare la validazione dei link firmati client, per non falsare la validazione dei link firmati
@@ -834,7 +962,16 @@ 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)
- **Richieste dal modulo pubblico**: nome e nota cifrati come i dati dei
proclamatori, fuori dal registro delle attività e dalla coda (l'email di
avviso parte subito); cancellate 90 giorni dopo essere state gestite. Non
sono nei backup (né XML né `.zip`, che contiene lo stesso XML): sono
messaggi in arrivo, non dati gestionali, e un ripristino non le tocca.
L'azzeramento (*Zona pericolosa*) le cancella
- **Email**: il modulo S-13 (con i nomi) va solo agli amministratori con
l'email confermata, l'avviso delle richieste solo a chi le gestisce
- **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 +982,17 @@ 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 (`password_temporanea` dice se il motivo è una password provvisoria data dall'amministratore invece di un ripristino); `proclamatore_id` lega l'utenza del sorvegliante (email fittizia) al suo proclamatore; sciolto il gruppo l'utenza si elimina |
| `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 attivi: nome (unico), sorvegliante. Sciolto, il gruppo si elimina |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo |
| `anni_teocratici` | Periodi settembre → agosto | | `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 ancora attivo, `gruppo_nome` che resta anche dopo lo scioglimento; `cartaceo` se il proclamatore ha chiesto la cartolina cartacea |
| `richieste` | Richieste dal modulo pubblico: tipo (`nuovo`/`riconsegna`), nome e nota cifrati, numero del territorio, stato (`aperta`/`evasa`/`scartata`), l'assegnazione che l'ha evasa, chi e quando l'ha gestita |
| `activity_log` | Audit trail | | `activity_log` | Audit trail |
**Vincoli e indici principali** **Vincoli e indici principali**
@@ -880,16 +1019,23 @@ 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 |
### Priorità ### Priorità
Un territorio è prioritario se lo è per **flag manuale** o perché non viene Un territorio è prioritario se lo è per **flag manuale** o perché è fermo
assegnato da più mesi della soglia configurata. La soglia automatica **vince (dall'ultimo rientro, o dalla creazione se mai assegnato) da più giorni della
sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla soglia configurata. La soglia automatica **vince sempre** sul flag:
quando la soglia è scattata. l'amministratore può aggiungere priorità, non toglierla quando la soglia è
scattata. Assegnare il territorio azzera il flag.
Dove si sceglie un territorio da dare — "Da assegnare" nella Home, l'aggiunta
al pacchetto di un gruppo, "Il mio gruppo" — l'ordine è lo stesso: prima i
prioritari, poi quelli fermi da più tempo, poi il numero
(`Territorio::ordinaPerPriorita()`).
### Percentuale di percorrenza delle campagne ### Percentuale di percorrenza delle campagne
- **Denominatore**: tutti i territori attivi - **Denominatore**: tutti i territori attivi
@@ -904,6 +1050,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
@@ -928,14 +1083,16 @@ ancora rientrati compaiono nel dettaglio come "in corso".
``` ```
TerManager2/ TerManager2/
├── app/ ├── app/
│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin │ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin,
│ │ # utenti:conferma-email, territori:file-orfani
│ ├── Http/Controllers/ # PDF pubblici, import, backup, login, cambio password │ ├── Http/Controllers/ # PDF pubblici, import, backup, login, cambio password
│ ├── Http/Middleware/ # RequirePasswordChange │ ├── Http/Middleware/ # RequirePasswordChange, LimitaSorvegliante
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda) │ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
│ ├── Livewire/ # Tutti i componenti di interfaccia │ ├── Livewire/ # Tutti i componenti di interfaccia (Gruppi/: gruppi di servizio)
│ ├── Models/ # 9 modelli Eloquent │ ├── Models/ # 12 modelli Eloquent
│ ├── Providers/ # AppServiceProvider │ ├── Providers/ # AppServiceProvider
│ └── Services/ # Miniature PDF, stato import, archivio backup │ └── Services/ # Miniature PDF, import, archivio backup, posta,
│ # piantine, modulo S-13, gruppi di servizio
├── bootstrap/app.php # Middleware, proxy e host fidati ├── bootstrap/app.php # Middleware, proxy e host fidati
├── config/ # Configurazione Laravel ├── config/ # Configurazione Laravel
├── database/ ├── database/
@@ -957,7 +1114,7 @@ TerManager2/
│ ├── components/btn.blade.php # L'unico pulsante dell'applicazione │ ├── components/btn.blade.php # L'unico pulsante dell'applicazione
│ └── livewire/ # Una vista per componente │ └── livewire/ # Una vista per componente
├── routes/web.php # Rotte con i relativi permessi ├── routes/web.php # Rotte con i relativi permessi
├── tests/ # Suite PHPUnit (profilo compose "test") ├── tests/ # Suite PHPUnit (profilo compose "test") e tests/js (Node)
├── phpunit.xml # Configurazione e ambiente dei test ├── phpunit.xml # Configurazione e ambiente dei test
├── docker-compose.yml # Unico, produzione e sviluppo ├── docker-compose.yml # Unico, produzione e sviluppo
├── .env.example # Tutta la configurazione ├── .env.example # Tutta la configurazione

Binary file not shown.

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Mail\ModuloS13Mail;
use App\Models\AnnoTeocratico;
use App\Models\User;
use App\Services\ConfigurazionePosta;
use App\Services\Registro\ModuloS13;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
/**
* Il modulo S-13 dell'anno di servizio in corso, per email agli
* amministratori (l'indirizzo della loro utenza, se confermato). Lo lancia lo
* scheduler l'ultima domenica di ogni mese (routes/console.php); a mano si
* può scegliere l'anno con --anno=2025-2026.
*
* Parte subito, non in coda: il PDF contiene i nomi dei proclamatori, che in
* coda starebbero in chiaro. Senza un server di posta funzionante non manda
* niente e lo dice.
*/
class InviaModuloS13 extends Command
{
protected $signature = 'registro:invia-s13 {--anno= : etichetta dell\'anno di servizio, es. 2025-2026 (predefinito: quello in corso)}';
protected $description = 'Manda per email agli amministratori il modulo S-13 dell\'anno di servizio';
public function handle(ModuloS13 $modulo, ConfigurazionePosta $posta): int
{
if (! $posta->funzionante()) {
$this->warn('Nessun server di posta funzionante: modulo S-13 non inviato.');
return self::SUCCESS;
}
$anno = $this->option('anno')
? AnnoTeocratico::where('label', $this->option('anno'))->first()
: AnnoTeocratico::corrente();
if (! $anno) {
$this->error("Anno di servizio {$this->option('anno')} non trovato.");
return self::FAILURE;
}
$destinatari = User::role('amministratore')->get()
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
->values();
if ($destinatari->isEmpty()) {
$this->warn('Nessun amministratore con un\'email confermata: modulo S-13 non inviato.');
return self::SUCCESS;
}
$pdf = $modulo->genera($anno);
// Uno per uno: un indirizzo rifiutato non ferma gli altri.
$inviati = 0;
foreach ($destinatari as $utente) {
try {
Mail::to($utente->email)->send(new ModuloS13Mail($utente, $anno, $pdf));
$inviati++;
} catch (\Throwable $e) {
report($e);
$this->error("Invio a {$utente->email} non riuscito: {$e->getMessage()}");
}
}
// Nel registro quanti, non chi: gli indirizzi stanno nelle utenze.
activity()->withProperties(['anno' => $anno->label, 'destinatari' => $inviati])->log('s13_inviato');
$this->info("Modulo S-13 {$anno->label} inviato a {$inviati} amministratori su {$destinatari->count()}.");
return $inviati === $destinatari->count() ? self::SUCCESS : self::FAILURE;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Models\Richiesta;
use Illuminate\Console\Command;
/**
* Le richieste dal modulo pubblico portano nome e note scritti da chi non ha
* un account: gestite (evase o scartate) servono ancora un po' per
* riferimento, poi si cancellano. Quelle aperte restano finché qualcuno non
* le gestisce.
*/
class PuliziaRichieste extends Command
{
/** Dopo quanti giorni dalla gestione una richiesta si cancella. */
public const GIORNI = 90;
protected $signature = 'richieste:pulizia';
protected $description = 'Cancella le richieste del modulo pubblico gestite da più di ' . self::GIORNI . ' giorni';
public function handle(): int
{
$cancellate = Richiesta::query()
->where('stato', '!=', Richiesta::APERTA)
->where('gestita_at', '<', now()->subDays(self::GIORNI))
->delete();
$this->info("Richieste cancellate: {$cancellate}");
return self::SUCCESS;
}
}

View File

@@ -44,6 +44,11 @@ class LoginController extends Controller
$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'));
} }
} }

View File

@@ -9,7 +9,9 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
/** /**
* Cambio password obbligatorio, imposto dopo il ripristino di un backup. * Cambio password obbligatorio, imposto dopo il ripristino di un backup o al
* primo accesso con una password temporanea data dall'amministratore
* (sorvegliante di gruppo): `password_temporanea` dice quale dei due.
* *
* Volutamente NON e' un componente Livewire: la pagina deve restare * Volutamente NON e' un componente Livewire: la pagina deve restare
* raggiungibile mentre RequirePasswordChange respinge tutto il resto, incluse * raggiungibile mentre RequirePasswordChange respinge tutto il resto, incluse
@@ -27,6 +29,8 @@ class PasswordChangeController extends Controller
// Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro; // Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro;
// chi e' stato dirottato dal middleware no. // chi e' stato dirottato dal middleware no.
'forced' => (bool) $user->password_change_required, 'forced' => (bool) $user->password_change_required,
// Perché: una password temporanea, oppure un ripristino da backup.
'temporanea' => (bool) $user->password_temporanea,
// Il ripristino gira in coda e non ha una sessione in cui lasciare // Il ripristino gira in coda e non ha una sessione in cui lasciare
// un flash; deposita l'esito in cache a nome di chi l'ha avviato e // un flash; deposita l'esito in cache a nome di chi l'ha avviato e
// lo si legge qui, che e' la prima pagina che quella persona vede. // lo si legge qui, che e' la prima pagina che quella persona vede.
@@ -71,6 +75,7 @@ class PasswordChangeController extends Controller
$user->update([ $user->update([
'password' => $validated['password'], 'password' => $validated['password'],
'password_change_required' => false, 'password_change_required' => false,
'password_temporanea' => false,
]); ]);
// La sessione corrente sopravvive al cambio, le altre no: dopo un // La sessione corrente sopravvive al cambio, le altre no: dopo un

View File

@@ -25,11 +25,37 @@ 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
{
$this->delPacchetto($territorio);
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);
} }
/** La miniatura per l'anteprima in "Il mio gruppo", con la stessa regola del PDF. */
public function miniaturaGruppo(Territorio $territorio): Response
{
$this->delPacchetto($territorio);
return $this->miniatura($territorio);
}
/** Il territorio deve stare nel pacchetto del gruppo che l'utente sorveglia. */
private function delPacchetto(Territorio $territorio): void
{
$gruppo = auth()->user()->gruppoSorvegliato();
abort_unless($gruppo && $territorio->gruppo_servizio_id === $gruppo->id, 403);
}
public function versione(TerritorioPdfVersione $versione): Response public function versione(TerritorioPdfVersione $versione): Response
{ {
$numero = $versione->territorio?->numero ?? 'senza-territorio'; $numero = $versione->territorio?->numero ?? 'senza-territorio';

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 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. Sciolto il
* gruppo l'utenza si elimina: una sessione ancora aperta non ha più utente.
*/
class LimitaSorvegliante
{
private const ROTTE_AMMESSE = [
'mio-gruppo',
'mio-gruppo.*',
'assegnazioni.rientra',
'profile',
'password.change',
'password.change.store',
'logout',
'verification.*',
// Le azioni Livewire: la rotta si chiama "default.livewire.update"
// (Livewire 3.7), non "livewire.update". Con 'livewire.*' ogni clic
// del sorvegliante finiva in 403.
'*livewire.*',
];
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
if (! $user->isSorvegliante() || $user->roles->count() > 1) {
return $next($request);
}
if ($request->routeIs(self::ROTTE_AMMESSE)) {
return $next($request);
}
if ($request->routeIs('dashboard')) {
return redirect()->route('mio-gruppo');
}
abort(403, 'Il sorvegliante di gruppo può usare solo la pagina del proprio gruppo.');
}
}

View File

@@ -5,7 +5,9 @@ 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\Richiesta;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\TerritorioMappa; use App\Models\TerritorioMappa;
@@ -75,10 +77,12 @@ 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', 12);
DB::transaction(function () use ($stato) { DB::transaction(function () use ($stato) {
$passi = [ $passi = [
// Le richieste dal modulo pubblico, prima delle assegnazioni che evadono.
'Richieste' => fn () => Richiesta::query()->delete(),
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(), 'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
'Campagne' => fn () => Campagna::query()->delete(), 'Campagne' => fn () => Campagna::query()->delete(),
// Prima dei territori, e per esteso: le piantine hanno la // Prima dei territori, e per esteso: le piantine hanno la
@@ -87,6 +91,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(),

View File

@@ -250,6 +250,9 @@ class RestoreBackupArchive implements ShouldQueue
{ {
DB::table('users')->update([ DB::table('users')->update([
'password_change_required' => true, 'password_change_required' => true,
// Il motivo ora è il ripristino, anche per chi aveva una
// password temporanea (pagina del cambio password).
'password_temporanea' => false,
'remember_token' => null, 'remember_token' => null,
]); ]);

View File

@@ -2,51 +2,64 @@
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;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Richiesta;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
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;
public string $assigned_at = ''; public string $assigned_at = '';
/** Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). */
public bool $cartaceo = false;
/**
* La richiesta dal modulo pubblico da cui si è partiti (?richiesta=,
* dalla Home): si mostra sopra il modulo e, fatta l'assegnazione, risulta
* evasa. Locked: la imposta solo mount().
*/
#[Locked]
public ?int $richiestaId = null;
// Optional pre-selection from parent context // Optional pre-selection from parent context
public ?int $preselectedTerritorioId = null; public ?int $preselectedTerritorioId = null;
/**
* Aperta da "Assegna →" della Home (?dalla_home=1, lo mette solo la Home)
* la pagina serve ad assegnare quel territorio: l'elenco dei territori
* assegnati sotto il modulo non si mostra (né si carica).
*/
public bool $elencoNascosto = false;
public function mount(?int $territorioId = null) public function mount(?int $territorioId = null)
{ {
$this->preselectedTerritorioId = $territorioId; $this->preselectedTerritorioId = $territorioId;
$this->territorio_id = $territorioId; $this->territorio_id = $territorioId;
$this->assigned_at = now()->format('Y-m-d'); $this->assigned_at = now()->format('Y-m-d');
$this->elencoNascosto = request()->boolean('dalla_home');
// Dalla Home, da una richiesta: il proclamatore scelto fra i nomi
// simili arriva già impostato (?proclamatore=), il resto del modulo è
// quello di sempre.
if (($id = request()->integer('proclamatore')) && Proclamatore::attivi()->whereKey($id)->exists()) {
$this->proclamatore_id = $id;
}
if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci')
&& Richiesta::aperte()->whereKey($id)->exists()) {
$this->richiestaId = $id;
}
// ?invia=<id>: riprende l'invio di un link rimasto in sospeso (dalla // ?invia=<id>: riprende l'invio di un link rimasto in sospeso (dalla
// scheda del territorio). // scheda del territorio).
@@ -83,6 +96,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.");
@@ -114,6 +133,7 @@ class Assegna extends Component
'created_by' => auth()->id(), 'created_by' => auth()->id(),
// C'è un link da mandare solo se il territorio ha il PDF. // C'è un link da mandare solo se il territorio ha il PDF.
'link_da_inviare' => (bool) $territorio->pdf_path, 'link_da_inviare' => (bool) $territorio->pdf_path,
'cartaceo' => $this->cartaceo,
]); ]);
}); });
@@ -136,95 +156,27 @@ class Assegna extends Component
]) ])
->log('assigned'); ->log('assigned');
// Partita da una richiesta del modulo pubblico: ora è evasa.
if ($this->richiestaId) {
Richiesta::find($this->richiestaId)?->evadi($assegnazione, auth()->user());
$this->richiestaId = null;
}
$this->cartaceo = false;
// Non si va avanti: si apre l'invio del link (vedi in cima). // Non si va avanti: si apre l'invio del link (vedi in cima).
$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,33 +185,31 @@ 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,
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
]); ]);
} }
} }

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Assegnazioni;
use App\Models\Assegnazione;
use Livewire\Component;
/**
* I territori fuori in cartaceo, e a chi: le assegnazioni aperte per cui il
* proclamatore ha chiesto la cartolina (spunta "cartaceo" su Assegna o in "Il
* mio gruppo"). Al rientro escono dall'elenco.
*/
class Cartacei extends Component
{
public function render()
{
return view('livewire.assegnazioni.cartacei', [
'assegnazioni' => Assegnazione::aperte()
->where('cartaceo', true)
->with(['territorio.zona', 'proclamatore'])
->orderBy('assigned_at')
->get(),
]);
}
}

View File

@@ -7,6 +7,7 @@ use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\Richiesta;
class Rientra extends Component class Rientra extends Component
{ {
@@ -25,14 +26,27 @@ class Rientra extends Component
public bool $counted_in_campaign = false; public bool $counted_in_campaign = false;
/**
* La richiesta di riconsegna dal modulo pubblico da cui si è partiti
* (?richiesta=, dalla Home): registrato il rientro, risulta evasa.
*/
#[Locked]
public ?int $richiestaId = null;
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) {
abort(404, 'Assegnazione già rientrata.'); abort(404, 'Assegnazione già rientrata.');
} }
if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci')
&& Richiesta::aperte()->whereKey($id)->exists()) {
$this->richiestaId = $id;
}
$this->returned_at = now()->format('Y-m-d'); $this->returned_at = now()->format('Y-m-d');
$this->checkCampaign(); $this->checkCampaign();
} }
@@ -69,8 +83,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
@@ -111,8 +145,24 @@ class Rientra extends Component
); );
} }
// Partito da una richiesta di riconsegna (Home): ora è evasa.
if ($this->richiestaId) {
Richiesta::find($this->richiestaId)?->evadi($this->assegnazione, auth()->user());
}
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,6 +182,7 @@ class Rientra extends Component
'campagna' => $campagna, 'campagna' => $campagna,
'giorniPercorrenza' => $giorniPercorrenza, 'giorniPercorrenza' => $giorniPercorrenza,
'sogliaPercorrenza' => $sogliaPercorrenza, 'sogliaPercorrenza' => $sogliaPercorrenza,
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
]); ]);
} }
} }

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Assegnazione;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
/**
* Invio del link della piantina dopo un'assegnazione, condiviso dalla pagina
* Assegna e da "Il mio gruppo" del sorvegliante. La vista è il partial
* livewire/assegnazioni/_invio-link.
*
* Dopo l'assegnazione non si va avanti: si sceglie il canale (copia link o
* Condividi: il destinatario lo sceglie chi usa l'app dai suoi contatti, i
* proclamatori non hanno numero né email), si dice se il link è partito, e
* solo allora si va avanti. Se la procedura si interrompe, l'assegnazione
* resta con link_da_inviare e lo si segnala dove compare.
*
* Chi usa il trait dice chi può mandare il link di un'assegnazione
* (puoInviare) e dove si va dopo (paginaDopoInvio).
*/
trait InviaLink
{
/** L'assegnazione di cui si sta mandando il link. */
#[Locked]
public ?int $invioId = null;
/** Il canale usato in questo invio (Assegnazione::CANALI_LINK). */
#[Locked]
public ?string $canaleUsato = null;
public string $erroreInvio = '';
abstract protected function puoInviare(Assegnazione $assegnazione): bool;
abstract protected function paginaDopoInvio(): string;
/**
* Apre l'invio: subito dopo l'assegnazione, o da un'assegnazione rimasta
* in sospeso ("Invia link" nell'elenco, che lo chiede con apri-invio).
*/
#[On('apri-invio')]
public function apriInvio(int $assegnazioneId): void
{
$assegnazione = Assegnazione::aperte()->find($assegnazioneId);
if (! $assegnazione) {
return;
}
abort_unless($this->puoInviare($assegnazione), 403);
$this->invioId = $assegnazione->id;
$this->canaleUsato = null;
$this->erroreInvio = '';
}
/** Copia link o Condividi: l'invio vero lo fa chi usa l'app, qui si annota il canale. */
public function canale(string $canale): void
{
$assegnazione = $this->assegnazioneInInvio();
if (! $assegnazione || ! in_array($canale, Assegnazione::CANALI_ATTIVI, true) || ! $assegnazione->shortPdfUrl()) {
return;
}
$this->canaleUsato = $canale;
$this->erroreInvio = '';
}
/**
* La risposta a "Hai inviato il link?": la si registra e solo adesso si
* va avanti. "Sì" vuole un canale usato; "No" lascia il link da inviare.
*/
public function concludiInvio(bool $inviato)
{
$assegnazione = $this->assegnazioneInInvio();
if (! $assegnazione) {
$this->invioId = null;
return null;
}
if ($inviato && $this->canaleUsato === null) {
$this->erroreInvio = 'Scegli prima come mandare il link.';
return null;
}
$assegnazione->registraInvioLink($inviato, $this->canaleUsato);
// Nel log il canale e l'id del proclamatore, mai il nome.
activity()->causedBy(auth()->user())
->performedOn($assegnazione)
->withProperties([
'territorio' => $assegnazione->territorio?->numero,
'proclamatore_id' => $assegnazione->proclamatore_id,
'canale' => $inviato ? $this->canaleUsato : null,
])
->log($inviato ? 'link_inviato' : 'link_non_inviato');
$numero = $assegnazione->territorio?->numero;
$nome = $assegnazione->proclamatore?->nome_completo;
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
session()->flash('success', $inviato
? "Territorio {$numero} assegnato a {$nome}: link inviato."
: "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco.");
return $this->redirect($this->paginaDopoInvio(), navigate: true);
}
/** L'invio in corso, per la vista; null se l'assegnazione nel frattempo è rientrata o sparita. */
protected function invioCorrente(): ?Assegnazione
{
$invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
if ($this->invioId && ! $invio) {
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
}
return $invio;
}
private function assegnazioneInInvio(): ?Assegnazione
{
if (! $this->invioId) {
return null;
}
$assegnazione = Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
abort_unless($assegnazione && $this->puoInviare($assegnazione), 403);
return $assegnazione;
}
}

View File

@@ -0,0 +1,88 @@
<?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 un'email
* fittizia ricavata dal suo nome (GruppiServizio::emailFittizia) e una
* password temporanea, mostrate 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;
/** 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|unique:gruppi_servizio,nome',
'sorveglianteId' => 'required|integer|exists:proclamatori,id',
], [
'nome.unique' => 'Esiste già un gruppo con questo nome.',
'sorveglianteId.required' => 'Scegli il sorvegliante.',
]);
try {
[$gruppo, $password] = app(GruppiServizio::class)->crea(
$this->nome,
Proclamatore::findOrFail($this->sorveglianteId),
);
} catch (InvalidArgumentException $e) {
$this->addError('sorveglianteId', $e->getMessage());
return;
}
$this->reset(['nome', 'sorveglianteId']);
$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'])
->orderBy('nome')
->get();
// Possono diventare sorveglianti i proclamatori attivi senza gruppo (e
// quindi senza utenza: sciolto il gruppo, la si elimina).
$candidati = Proclamatore::attivi()
->whereNull('gruppo_servizio_id')
->whereDoesntHave('utente')
->get();
return view('livewire.gruppi.gruppi-index', [
'gruppi' => $gruppi,
'opzioniSorveglianti' => Proclamatore::opzioniScelta($candidati),
'creato' => $this->creatoId ? GruppoServizio::with('sorvegliante.utente')->find($this->creatoId) : null,
]);
}
}

View File

@@ -0,0 +1,200 @@
<?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 = [];
// Filtri dei territori in reparto da aggiungere al pacchetto. L'ordine è
// quello di priorità (Territorio::ordinaPerPriorita), come nella Home.
public string $cercaTerritorio = '';
public string $filtroZona = '';
public string $filtroTipologia = '';
public bool $soloPrioritari = false;
#[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 = [];
}
/** Tutti i territori che i filtri mostrano finiscono tra quelli da aggiungere. */
public function selezionaMostrati(): void
{
$this->nuoviTerritori = collect($this->nuoviTerritori)
->map(fn ($id) => (string) $id)
->merge($this->candidatiFiltrati()->pluck('id')->map(fn ($id) => (string) $id))
->unique()
->values()
->all();
}
public function deselezionaTutti(): void
{
$this->nuoviTerritori = [];
}
/** I territori in reparto, in ordine di priorità. */
private function candidati(): \Illuminate\Support\Collection
{
return Territorio::ordinaPerPriorita(
Territorio::inReparto()->with(['zona', 'tipologia', 'ultimaAssegnazione'])->get()
);
}
private function candidatiFiltrati(?\Illuminate\Support\Collection $candidati = null): \Illuminate\Support\Collection
{
$cerca = Str::lower(Str::ascii(trim($this->cercaTerritorio)));
return ($candidati ?? $this->candidati())
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
->when($cerca !== '', fn ($c) => $c->filter(fn (Territorio $t) => str_contains(
Str::lower(Str::ascii("{$t->numero} {$t->zona?->nome} {$t->tipologia?->nome}")),
$cerca
)))
->values();
}
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->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
return;
}
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($utente);
}
/**
* Toglie il ruolo di sorvegliante: il gruppo si scioglie e si elimina, si
* torna all'elenco dei gruppi.
*/
public function sciogli()
{
$nome = $this->gruppo->nome;
$this->servizio()->sciogli($this->gruppo);
session()->flash('success', "Gruppo {$nome} sciolto: i territori non ancora dati sono tornati in reparto e l'utenza del sorvegliante è stata eliminata.");
return $this->redirect(route('gruppi.index'), navigate: true);
}
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();
// I territori in reparto (attivi, non assegnati, fuori da ogni
// pacchetto), in ordine di priorità; zone e tipologie dei filtri sono
// quelle che ci sono tra loro.
$candidati = $this->candidati();
return view('livewire.gruppi.gruppo-show', [
'membri' => $membri,
'territori' => $territori,
// Si possono aggiungere i proclamatori attivi senza gruppo...
'candidatiMembri' => Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(),
// ...e i territori in reparto.
'totaleCandidati' => $candidati->count(),
'candidatiTerritori' => $this->candidatiFiltrati($candidati),
'zoneCandidati' => $candidati->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
'tipologieCandidati' => $candidati->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
]);
}
}

View File

@@ -0,0 +1,227 @@
<?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\Attributes\Url;
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 = '';
/** Il membro ha chiesto la cartolina cartacea (elenco Cartacei). */
public bool $cartaceo = false;
// Filtri del pacchetto, nell'indirizzo: tornando dal rientro si ritrovano.
#[Url(except: '')]
public string $filtroZona = '';
#[Url(except: '')]
public string $filtroTipologia = '';
/** '' tutti, 'da_dare', 'dati' */
#[Url(except: '')]
public string $filtroStato = '';
#[Url(except: false)]
public bool $soloPrioritari = false;
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->cartaceo = false;
$this->dataAssegnazione = now()->format('Y-m-d');
$this->resetValidation();
}
public function annullaAssegnazione(): void
{
$this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
$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), $this->cartaceo);
} catch (InvalidArgumentException $e) {
$this->addError('membroId', $e->getMessage());
return;
}
$this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
// 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()?->load('sorvegliante');
// Prima quelli da dare, in ordine di priorità come "Da assegnare"
// nella Home (Territorio::ordinaPerPriorita); poi quelli già dati, dal
// più vecchio.
$pacchetto = $gruppo
? $gruppo->territori()->with(['zona', 'tipologia', 'ultimaAssegnazione', 'assegnazioneCorrente.proclamatore'])->get()
: collect();
[$dati, $daDare] = $pacchetto->partition(fn (Territorio $t) => $t->assegnazioneCorrente !== null);
$pacchetto = Territorio::ordinaPerPriorita($daDare)
->concat($dati->sortBy(fn (Territorio $t) => $t->assegnazioneCorrente->assigned_at)->values());
$territori = $pacchetto
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
->when($this->filtroStato === 'da_dare', fn ($c) => $c->whereNull('assegnazioneCorrente'))
->when($this->filtroStato === 'dati', fn ($c) => $c->whereNotNull('assegnazioneCorrente'))
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
->values();
$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,
'pacchetto' => $pacchetto,
'territori' => $territori,
'zone' => $pacchetto->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
'tipologie' => $pacchetto->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
'membri' => $membri,
'opzioniMembri' => Proclamatore::opzioniScelta($membri, conGruppo: false),
'invio' => $this->invioCorrente(),
]);
}
}

View File

@@ -3,50 +3,67 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component; use Livewire\Component;
use App\Livewire\Concerns\Avvisa;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\Richiesta;
use App\Models\Setting; use App\Models\Setting;
use App\Services\RichiesteServizio;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
class Home extends Component class Home extends Component
{ {
use Avvisa;
/**
* Le richieste dal modulo pubblico si confermano portandole su Assegna o
* Rientra (link nel riquadro); qui si possono solo scartare.
*/
public function scartaRichiesta(int $id): void
{
$this->authorize('richieste.gestisci');
Richiesta::findOrFail($id)->scarta(auth()->user());
$this->avvisa('Richiesta scartata.');
}
/**
* Le richieste aperte, con quello che serve a confermarle: per un
* territorio nuovo i proclamatori dal nome simile a quello scritto, per
* una riconsegna l'assegnazione aperta di quel territorio.
*/
private function richiesteAperte(): \Illuminate\Support\Collection
{
if (! auth()->user()->can('richieste.gestisci')) {
return collect();
}
$aperte = Richiesta::aperte()->orderBy('created_at')->get();
if ($aperte->isEmpty()) {
return collect();
}
$servizio = app(RichiesteServizio::class);
$proclamatori = Proclamatore::attivi()->get();
return $aperte->map(fn (Richiesta $r) => [
'richiesta' => $r,
'simili' => $r->tipo === Richiesta::NUOVO ? $servizio->nomiSimili($r->nome, $proclamatori) : collect(),
'assegnazione' => $r->tipo === Richiesta::RICONSEGNA ? $servizio->assegnazioneAperta($r->numero_territorio) : null,
]);
}
public function downloadPdfDaAssegnare() public function downloadPdfDaAssegnare()
{ {
$this->authorize('territori.assign'); $this->authorize('territori.assign');
$settings = Setting::instance(); $settings = Setting::instance();
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$territori = Territorio::inReparto() $territori = Territorio::ordinaPerPriorita(
->with('zona', 'tipologia', 'ultimaAssegnazione') Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
->get() );
->map(function (Territorio $territorio) use ($priorityThreshold) {
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorniGiacenza = 0;
}
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
$territorio->setAttribute(
'home_is_prioritario',
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
);
return $territorio;
})
->sort(function (Territorio $left, Territorio $right) {
$p = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
if ($p !== 0) return $p;
$g = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
if ($g !== 0) return $g;
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})
->values();
$pdf = Pdf::loadView('pdf.territori-lista', [ $pdf = Pdf::loadView('pdf.territori-lista', [
'titolo' => 'Territori da Assegnare', 'titolo' => 'Territori da Assegnare',
@@ -90,7 +107,6 @@ class Home extends Component
{ {
$settings = Setting::instance(); $settings = Setting::instance();
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10)); $homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$annoCorrente = AnnoTeocratico::corrente(); $annoCorrente = AnnoTeocratico::corrente();
$campagnaAttiva = Campagna::attiva(); $campagnaAttiva = Campagna::attiva();
@@ -149,46 +165,11 @@ class Home extends Component
]; ];
} }
// Quick lists // Quick lists: la logica di priorità sta in Territorio::ordinaPerPriorita
$territoriDaAssegnare = Territorio::inReparto() // (la usano anche il pacchetto dei gruppi di servizio e "Il mio gruppo").
->with('zona', 'tipologia', 'ultimaAssegnazione') $territoriDaAssegnare = Territorio::ordinaPerPriorita(
->get() Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
->map(function (Territorio $territorio) use ($priorityThreshold) { )->take($homeLimit)->values();
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorniGiacenza = 0;
}
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
$territorio->setAttribute(
'home_is_prioritario',
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
);
return $territorio;
})
->sort(function (Territorio $left, Territorio $right) {
$priorityComparison = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
if ($priorityComparison !== 0) {
return $priorityComparison;
}
$giacenzaComparison = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
if ($giacenzaComparison !== 0) {
return $giacenzaComparison;
}
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})
->take($homeLimit)
->values();
$daRientrare = Territorio::daRientrare() $daRientrare = Territorio::daRientrare()
->with(['zona', 'assegnazioneCorrente.proclamatore']) ->with(['zona', 'assegnazioneCorrente.proclamatore'])
@@ -208,6 +189,7 @@ class Home extends Component
'homeLimit' => $homeLimit, 'homeLimit' => $homeLimit,
'territoriDaAssegnare' => $territoriDaAssegnare, 'territoriDaAssegnare' => $territoriDaAssegnare,
'daRientrare' => $daRientrare, 'daRientrare' => $daRientrare,
'richieste' => $this->richiesteAperte(),
]); ]);
} }
} }

View File

@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Services\ConfigurazionePosta; use App\Services\ConfigurazionePosta;
@@ -23,18 +24,20 @@ use Spatie\Permission\Models\Role;
class Privacy extends Component class Privacy extends Component
{ {
/** Data dell'ultima revisione del testo dell'informativa. */ /** Data dell'ultima revisione del testo dell'informativa. */
public const REVISIONE = '19 ottobre 2026'; public const REVISIONE = '21 ottobre 2026';
/** I permessi spiegati a chi legge l'informativa. */ /** I permessi spiegati a chi legge l'informativa. */
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',
'registro.export' => 'esportazione del registro', 'registro.export' => 'esportazione del registro',
'territori.assign' => 'assegnazione dei territori', 'territori.assign' => 'assegnazione dei territori',
'territori.return' => 'rientro dei territori', 'territori.return' => 'rientro dei territori',
'richieste.gestisci' => 'richieste arrivate dal modulo pubblico (nuovo territorio o riconsegna)',
'audit.view' => 'consultazione del registro delle attività', 'audit.view' => 'consultazione del registro delle attività',
'audit.export' => 'esportazione del registro delle attività', 'audit.export' => 'esportazione del registro delle attività',
]; ];
@@ -77,6 +80,8 @@ class Privacy extends Component
'utenti' => User::count(), 'utenti' => User::count(),
'assegnazioni' => Assegnazione::count(), 'assegnazioni' => Assegnazione::count(),
'assegnazioniAperte' => Assegnazione::aperte()->count(), 'assegnazioniAperte' => Assegnazione::aperte()->count(),
'richieste' => Richiesta::count(),
'richiesteAperte' => Richiesta::aperte()->count(),
'attivita' => Activity::count(), 'attivita' => Activity::count(),
'attivitaDal' => Activity::min('created_at'), 'attivitaDal' => Activity::min('created_at'),
], ],

View File

@@ -38,13 +38,17 @@ class ProclamatoreEdit extends Component
$this->validate(); $this->validate();
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
$gruppo = ! $this->attivo ? $this->proclamatore->gruppoSorvegliato() : null;
$this->proclamatore->update([ $this->proclamatore->update([
'nome' => $this->nome, 'nome' => $this->nome,
'cognome' => $this->cognome, 'cognome' => $this->cognome,
'attivo' => $this->attivo, 'attivo' => $this->attivo,
]); ]);
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato."); $avviso = "Proclamatore {$this->proclamatore->nome_completo} aggiornato." . ($gruppo ? ' ' . ProclamatoreIndex::gruppoSciolto($gruppo) : '');
session()->flash('success', $avviso);
return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true); return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
} }
@@ -53,6 +57,7 @@ class ProclamatoreEdit extends Component
return view('livewire.proclamatori.proclamatore-form', [ return view('livewire.proclamatori.proclamatore-form', [
'titolo' => "Modifica {$this->proclamatore->nome_completo}", 'titolo' => "Modifica {$this->proclamatore->nome_completo}",
'btnLabel' => 'Salva Modifiche', 'btnLabel' => 'Salva Modifiche',
'gruppoSorvegliato' => $this->proclamatore->gruppoSorvegliato(),
]); ]);
} }
} }

View File

@@ -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
@@ -57,7 +58,13 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage'); $this->authorize('settings.manage');
$proclamatore = Proclamatore::findOrFail($id); $proclamatore = Proclamatore::findOrFail($id);
$gruppo = $proclamatore->attivo ? $proclamatore->gruppoSorvegliato() : null;
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
$proclamatore->update(['attivo' => !$proclamatore->attivo]); $proclamatore->update(['attivo' => !$proclamatore->attivo]);
if ($gruppo) {
$this->avvisa("{$proclamatore->nome_completo} non è più attivo. " . self::gruppoSciolto($gruppo));
}
} }
public function deleteProclamatore(int $id) public function deleteProclamatore(int $id)
@@ -71,8 +78,11 @@ class ProclamatoreIndex extends Component
return; return;
} }
// Nel cestino lascia il gruppo; se ne era il sorvegliante, il gruppo
// si scioglie (Proclamatore::booted).
$gruppo = $proclamatore->gruppoSorvegliato();
$proclamatore->delete(); $proclamatore->delete();
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino."); $this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino." . ($gruppo ? ' ' . self::gruppoSciolto($gruppo) : ''));
} }
public function anonimizza(int $id) public function anonimizza(int $id)
@@ -80,8 +90,15 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage'); $this->authorize('settings.manage');
$proclamatore = Proclamatore::findOrFail($id); $proclamatore = Proclamatore::findOrFail($id);
$gruppo = $proclamatore->gruppoSorvegliato();
$proclamatore->anonimizza(); $proclamatore->anonimizza();
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)."); $this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)." . ($gruppo ? ' ' . self::gruppoSciolto($gruppo) : ''));
}
/** L'avviso quando un sorvegliante esce di scena: anche per ProclamatoreEdit. */
public static function gruppoSciolto(GruppoServizio $gruppo): string
{
return "Era il sorvegliante del gruppo {$gruppo->nome}: il gruppo è stato sciolto, i territori non ancora dati sono tornati in reparto e la sua utenza è stata eliminata.";
} }
public function render() public function render()
@@ -89,7 +106,7 @@ class ProclamatoreIndex extends Component
// withCount rather than letting the view call // withCount rather than letting the view call
// ->assegnazioni()->aperte()->count() per row, which issued one // ->assegnazioni()->aperte()->count() per row, which issued one
// COUNT(*) per displayed proclamatore on every render. // COUNT(*) per displayed proclamatore on every render.
$query = Proclamatore::query()->withCount([ $query = Proclamatore::query()->with('gruppo')->withCount([
'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'), 'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'),
]); ]);
@@ -107,6 +124,8 @@ class ProclamatoreIndex extends Component
$filtered = $filtered->where('attivo', true); $filtered = $filtered->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') { } elseif ($this->filtroStato === 'inattivo') {
$filtered = $filtered->where('attivo', false); $filtered = $filtered->where('attivo', false);
} elseif ($this->filtroStato === 'gruppo') {
$filtered = $filtered->whereNotNull('gruppo_servizio_id');
} }
// Sort in-memory // Sort in-memory
@@ -131,6 +150,8 @@ class ProclamatoreIndex extends Component
$query->where('attivo', true); $query->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') { } elseif ($this->filtroStato === 'inattivo') {
$query->where('attivo', false); $query->where('attivo', false);
} elseif ($this->filtroStato === 'gruppo') {
$query->whereNotNull('gruppo_servizio_id');
} }
// cognome/nome encrypted, so sort in-memory // cognome/nome encrypted, so sort in-memory
@@ -153,6 +174,8 @@ class ProclamatoreIndex extends Component
return view('livewire.proclamatori.proclamatore-index', [ return view('livewire.proclamatori.proclamatore-index', [
'proclamatori' => $proclamatori, 'proclamatori' => $proclamatori,
// Per avvisare, prima di disattivare o cestinare, che il gruppo si scioglie.
'gruppiSorvegliati' => GruppoServizio::pluck('nome', 'sorvegliante_id'),
]); ]);
} }
} }

View File

@@ -6,6 +6,7 @@ use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use Spatie\Activitylog\Models\Activity;
class ProclamatoreShow extends Component class ProclamatoreShow extends Component
{ {
@@ -23,8 +24,11 @@ class ProclamatoreShow extends Component
$assegnazioni = Assegnazione::where('proclamatore_id', $this->proclamatore->id) $assegnazioni = Assegnazione::where('proclamatore_id', $this->proclamatore->id)
->with(['territorio', 'annoTeocratico', 'campagna']) ->with(['territorio', 'annoTeocratico', 'campagna'])
->orderByDesc('assigned_at') ->orderByDesc('assigned_at')
->orderByDesc('id')
->get() ->get()
->groupBy(fn($a) => $a->annoTeocratico->label); ->groupBy(fn($a) => $a->annoTeocratico->label)
// Dal più recente: gli anni ("2026-2027") si ordinano come testo.
->sortKeysDesc();
$stats = [ $stats = [
'totale_assegnazioni' => Assegnazione::where('proclamatore_id', $this->proclamatore->id)->count(), 'totale_assegnazioni' => Assegnazione::where('proclamatore_id', $this->proclamatore->id)->count(),
@@ -37,9 +41,21 @@ class ProclamatoreShow extends Component
), ),
]; ];
// Gruppo di servizio: sorvegliante (con i dettagli del gruppo e della
// sua utenza) o membro. I gruppi sciolti non esistono più.
$gruppoSorvegliato = $this->proclamatore->gruppoSorvegliato();
$utenza = $gruppoSorvegliato ? $this->proclamatore->utente : null;
return view('livewire.proclamatori.proclamatore-show', [ return view('livewire.proclamatori.proclamatore-show', [
'assegnazioniPerAnno' => $assegnazioni, 'assegnazioniPerAnno' => $assegnazioni,
'stats' => $stats, 'stats' => $stats,
'gruppoSorvegliato' => $gruppoSorvegliato,
'riepilogoGruppo' => $gruppoSorvegliato?->riepilogo(),
'gruppoMembro' => $gruppoSorvegliato ? null : $this->proclamatore->gruppo()->with('sorvegliante')->first(),
'utenza' => $utenza,
'ultimoAccesso' => $utenza
? Activity::whereMorphedTo('causer', $utenza)->where('description', 'login')->max('created_at')
: null,
]); ]);
} }
} }

View File

@@ -27,21 +27,24 @@ class Profile extends Component
{ {
$user = auth()->user(); $user = auth()->user();
// Il sorvegliante di gruppo entra con l'email fittizia creata col
// gruppo (GruppiServizio::emailFittizia): dal profilo non la cambia.
$emailFissa = $user->isSorvegliante();
$validated = $this->validate([ $validated = $this->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
] + ($emailFissa ? [] : [
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
]); ]));
$user->fill([ $user->fill(['name' => $validated['name']] + ($emailFissa ? [] : ['email' => $validated['email']]));
'name' => $validated['name'],
'email' => $validated['email'],
]);
$emailCambiata = $user->isDirty('email'); $emailCambiata = $user->isDirty('email');
$user->save(); $user->save();
// 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.');
@@ -77,6 +80,7 @@ class Profile extends Component
auth()->user()->update([ auth()->user()->update([
'password' => $this->password, 'password' => $this->password,
'password_change_required' => false, 'password_change_required' => false,
'password_temporanea' => false,
]); ]);
$this->reset(['current_password', 'password', 'password_confirmation']); $this->reset(['current_password', 'password', 'password_confirmation']);
@@ -86,8 +90,13 @@ class Profile extends Component
public function render() public function render()
{ {
// Il sorvegliante vede qui i dettagli del suo gruppo di servizio.
$gruppo = auth()->user()->gruppoSorvegliato();
return view('livewire.profile', [ return view('livewire.profile', [
'user' => auth()->user(), 'user' => auth()->user(),
'gruppo' => $gruppo,
'riepilogoGruppo' => $gruppo?->riepilogo(),
]); ]);
} }
} }

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Livewire\Richieste;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Models\Territorio;
use App\Services\RichiesteServizio;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
* territorio nuovo o annuncia una riconsegna. L'unico dato già presente che
* mostra è l'elenco dei territori nella riconsegna, per il completamento
* automatico del numero: numero e zona, mai a chi è assegnato né se lo è.
* Nomi e altri dati non si vedono.
*
* Contro lo spam, tre difese che a chi compila davvero non costano niente:
* - un campo trappola ($sito) nascosto alle persone e riempito dai programmi;
* - un tempo minimo fra apertura e invio (i programmi inviano subito);
* - al massimo MASSIMO_ORA richieste all'ora per indirizzo.
* Nei primi due casi si risponde come se fosse andata bene, senza salvare:
* chi prova non impara cosa l'ha fermato.
*/
class ModuloRichiesta extends Component
{
public const MASSIMO_ORA = 5;
public const SECONDI_MINIMI = 3;
public string $tipo = Richiesta::NUOVO;
public string $nome = '';
public string $numero = '';
public string $note = '';
/** Trappola: nascosto alle persone, i programmi lo riempiono. */
public string $sito = '';
#[Locked]
public int $apertoAlle = 0;
#[Locked]
public bool $inviata = false;
public function mount(): void
{
$this->apertoAlle = now()->timestamp;
}
public function invia(RichiesteServizio $richieste): void
{
if ($this->sito !== '' || now()->timestamp - $this->apertoAlle < self::SECONDI_MINIMI) {
$this->inviata = true;
return;
}
$chiave = 'richiesta-pubblica:' . sha1((string) request()->ip());
if (RateLimiter::tooManyAttempts($chiave, self::MASSIMO_ORA)) {
$this->addError('nome', "Hai già mandato diverse richieste: riprova fra un po'.");
return;
}
$this->validate([
'tipo' => 'required|in:' . Richiesta::NUOVO . ',' . Richiesta::RICONSEGNA,
'nome' => 'required|string|min:3|max:100',
'numero' => ['nullable', 'required_if:tipo,' . Richiesta::RICONSEGNA, 'string', 'max:20',
function ($attributo, $valore, $errore) {
if ($this->tipo === Richiesta::RICONSEGNA && ! self::territori()->whereRaw('LOWER(numero) = ?', [Str::lower(trim((string) $valore))])->exists()) {
$errore("Scegli il territorio dall'elenco.");
}
}],
'note' => 'nullable|string|max:1000',
], [
'nome.required' => 'Scrivi nome e cognome.',
'nome.min' => 'Scrivi nome e cognome.',
'numero.required_if' => 'Scegli il territorio che riconsegni.',
]);
RateLimiter::hit($chiave, 3600);
$richieste->registra($this->tipo, $this->nome, $this->numero, $this->note);
$this->reset(['nome', 'numero', 'note']);
$this->inviata = true;
}
public function nuova(): void
{
$this->reset(['tipo', 'nome', 'numero', 'note', 'inviata']);
$this->apertoAlle = now()->timestamp;
}
/**
* I territori che si possono riconsegnare: quelli attivi e quelli ancora
* fuori anche se nel frattempo disattivati.
*/
private static function territori(): Builder
{
return Territorio::query()->where(fn ($q) => $q->where('attivo', true)
->orWhereHas('assegnazioni', fn ($a) => $a->whereNull('returned_at')));
}
/**
* Le proposte del campo con ricerca (<x-scelta>): solo numero e zona. Il
* valore è il numero, come lo salva la richiesta.
*/
private static function opzioniTerritori(): array
{
return self::territori()->with('zona')->get()
->sortBy('numero', SORT_NATURAL)
->map(fn (Territorio $t) => array_filter([
'valore' => (string) $t->numero,
'etichetta' => "N° {$t->numero}",
'dettaglio' => $t->zona?->nome,
'chiavi' => [(string) $t->numero, (string) $t->zona?->nome],
], fn ($v) => $v !== null))
->values()
->all();
}
public function render()
{
return view('livewire.richieste.modulo-richiesta', [
'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2',
// Solo quando serve: chi chiede un territorio nuovo non vede l'elenco.
'opzioniTerritori' => $this->tipo === Richiesta::RICONSEGNA && ! $this->inviata ? self::opzioniTerritori() : [],
])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']);
}
}

View File

@@ -3,13 +3,13 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa; use App\Livewire\Concerns\Avvisa;
use App\Models\Assegnazione; use App\Models\GruppoServizio;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB; use App\Services\GruppiServizio;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use InvalidArgumentException;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Component; use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
class UsersIndex extends Component class UsersIndex extends Component
@@ -25,6 +25,17 @@ class UsersIndex extends Component
#[Locked] #[Locked]
public array $availableRoles = []; public array $availableRoles = [];
/**
* Nuova password temporanea di un sorvegliante di gruppo, mostrata una
* volta sola (come nella pagina del gruppo). Locked: le scrive solo
* nuovaPasswordSorvegliante().
*/
#[Locked]
public ?int $passwordPerUtenteId = null;
#[Locked]
public ?string $passwordTemporanea = null;
public ?int $editingUserId = null; public ?int $editingUserId = null;
public string $editName = ''; public string $editName = '';
public string $editEmail = ''; public string $editEmail = '';
@@ -34,7 +45,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 +125,28 @@ class UsersIndex extends Component
} }
} }
/**
* Le utenze dei sorveglianti di gruppo 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 +176,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 +229,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.');
@@ -204,37 +243,7 @@ class UsersIndex extends Component
return; return;
} }
DB::transaction(function () use ($user, $currentUser) { $user->eliminaConservandoStorico($currentUser, $user->name, $user->email);
$causerName = $user->name;
$causerEmail = $user->email;
$deletedAt = now()->toDateTimeString();
Activity::query()
->where('causer_type', User::class)
->where('causer_id', $user->id)
->chunkById(200, function ($activities) use ($causerName, $causerEmail, $deletedAt) {
foreach ($activities as $activity) {
$properties = $activity->properties?->toArray() ?? [];
$properties['causer_name'] = $causerName;
$properties['causer_email'] = $causerEmail;
$properties['causer_deleted_at'] = $deletedAt;
$activity->properties = $properties;
$activity->save();
}
});
Assegnazione::query()
->where('created_by', $user->id)
->update(['created_by' => $currentUser->id]);
Assegnazione::query()
->where('returned_by', $user->id)
->update(['returned_by' => $currentUser->id]);
$user->syncRoles([]);
$user->delete();
});
if ($this->editingUserId === $userId) { if ($this->editingUserId === $userId) {
$this->cancelEdit(); $this->cancelEdit();
@@ -243,10 +252,39 @@ class UsersIndex extends Component
$this->avvisa('Utente cancellato. I log sono stati preservati.'); $this->avvisa('Utente cancellato. I log sono stati preservati.');
} }
/**
* Il sorvegliante ha perso la password: una temporanea nuova, da cambiare
* al primo accesso. È l'unica cosa che di quell'utenza si fa da qui; il
* resto passa dal gruppo (gestitaDaiGruppi).
*/
public function nuovaPasswordSorvegliante(int $userId): void
{
$this->authorize('settings.manage');
$user = User::query()->findOrFail($userId);
try {
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($user);
} catch (InvalidArgumentException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->passwordPerUtenteId = $user->id;
}
public function chiudiPassword(): void
{
$this->reset(['passwordPerUtenteId', 'passwordTemporanea']);
}
public function render() public function render()
{ {
return view('livewire.settings.users-index', [ return view('livewire.settings.users-index', [
'users' => User::query()->with('roles')->orderBy('name')->get(), 'users' => User::query()->with('roles')->orderBy('name')->get(),
// Per ogni sorvegliante il suo gruppo, per il collegamento.
'gruppiDeiSorveglianti' => GruppoServizio::pluck('id', 'sorvegliante_id'),
'utentePassword' => $this->passwordPerUtenteId ? User::find($this->passwordPerUtenteId) : null,
]); ]);
} }
} }

View File

@@ -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,10 +522,18 @@ 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'] ?? '');
// Nei file del 19-20 ottobre 2026 c'erano le utenze disattivate
// degli ex sorveglianti: ora, sciolto il gruppo, l'utenza non
// esiste più. Le loro assegnazioni passano a chi importa.
if (trim((string) ($userNode->disattivato_at ?? '')) !== '') {
continue;
}
if ($email === '') { if ($email === '') {
$this->pushImportIssue('utente', $legacyId, 'Email mancante'); $this->pushImportIssue('utente', $legacyId, 'Email mancante');
continue; continue;
@@ -572,6 +582,7 @@ class XmlExchange extends Component
if ($passwordHash !== '' && ! $eAttore) { if ($passwordHash !== '' && ! $eAttore) {
$meta['password'] = $passwordHash; $meta['password'] = $passwordHash;
$meta['password_change_required'] = true; $meta['password_change_required'] = true;
$meta['password_temporanea'] = false;
} }
if (! $eAttore) { if (! $eAttore) {
$meta['remember_token'] = null; $meta['remember_token'] = null;
@@ -589,6 +600,10 @@ 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] = (string) ($userNode->legacy_proclamatore_id ?? '');
$stats['utenti_importati']++; $stats['utenti_importati']++;
if ($legacyId !== '') { if ($legacyId !== '') {
$userMap[$legacyId] = $user->id; $userMap[$legacyId] = $user->id;
@@ -624,6 +639,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 +650,46 @@ 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 ?? '');
// Nei file del 19-20 ottobre 2026 un gruppo sciolto c'era ancora
// (<sciolto_at>): ora un gruppo sciolto non esiste più, quindi
// non si importa.
if (trim((string) ($gruppoNode->sciolto_at ?? '')) !== '') {
continue;
}
$g = GruppoServizio::create([
'nome' => (string) $gruppoNode->nome,
'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null,
]);
$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 => $legacyProclamatore) {
DB::table('users')->where('id', $idUtente)->update([
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore] ?? null,
]);
}
$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 +754,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 +817,9 @@ 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,
'cartaceo' => ((int) ($assegnazioneNode->cartaceo ?? 0)) === 1,
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null,
'gruppo_nome' => trim((string) ($assegnazioneNode->gruppo_nome ?? '')) ?: null,
'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null, '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 +876,19 @@ 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.
'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '',
'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', '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', 'cartaceo', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']),
]; ];
} }
@@ -1232,6 +1287,7 @@ 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('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 +1321,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 +1337,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 +1354,16 @@ 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'] ?? ''));
$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 +1389,9 @@ 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('cartaceo', (string) ((int) ($assegnazione['cartaceo'] ?? 0)));
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? ''));
$this->addXmlText($node, 'gruppo_nome', (string) ($assegnazione['gruppo_nome'] ?? ''));
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? '')); $this->addXmlText($node, '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'] ?? ''));

View File

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

View File

@@ -23,8 +23,11 @@ class TerritorioShow extends Component
$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'])
->orderByDesc('assigned_at') ->orderByDesc('assigned_at')
->orderByDesc('id')
->get() ->get()
->groupBy(fn($a) => $a->annoTeocratico->label); ->groupBy(fn($a) => $a->annoTeocratico->label)
// Dal più recente: gli anni ("2026-2027") si ordinano come testo.
->sortKeysDesc();
return view('livewire.territori.territorio-show', [ return view('livewire.territori.territorio-show', [
'activeAssignment' => $this->territorio->assegnazioneCorrente, 'activeAssignment' => $this->territorio->assegnazioneCorrente,

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Mail;
use App\Models\AnnoTeocratico;
use App\Models\User;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Il modulo S-13 in PDF per un amministratore (registro:invia-s13). Non va in
* coda (niente ShouldQueue): il PDF contiene i nomi dei proclamatori, che in
* coda starebbero in chiaro.
*/
class ModuloS13Mail extends Mailable
{
public function __construct(
public User $utente,
public AnnoTeocratico $anno,
private string $pdf,
) {}
public function nomeFile(): string
{
return "S-13-registro-assegnazioni-{$this->anno->label}.pdf";
}
public function envelope(): Envelope
{
return new Envelope(subject: "Modulo S-13 — anno di servizio {$this->anno->label}");
}
public function content(): Content
{
return new Content(
text: 'mail.modulo-s13',
with: ['data' => now()->format('d/m/Y')],
);
}
/** @return array<int, Attachment> */
public function attachments(): array
{
return [
Attachment::fromData(fn () => $this->pdf, $this->nomeFile())->withMime('application/pdf'),
];
}
}

View File

@@ -22,11 +22,20 @@ class Assegnazione extends Model
'link_sent', 'link_sent',
'link_da_inviare', 'link_da_inviare',
'link_canale', 'link_canale',
'cartaceo',
'note', 'note',
'created_by', 'created_by',
'returned_by', 'returned_by',
'gruppo_servizio_id',
'gruppo_nome',
]; ];
/** 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 +44,9 @@ 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',
// Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei).
'cartaceo' => 'boolean',
'gruppo_servizio_id' => 'integer',
]; ];
} }
@@ -56,6 +68,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]);
});
} }
/** /**

View File

@@ -0,0 +1,85 @@
<?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.
*
* Sciolto, il gruppo si elimina: non resta nello storico (decisione del 20
* ottobre 2026). Le assegnazioni già date dal sorvegliante ne conservano solo
* il nome (assegnazioni.gruppo_nome). Logica in App\Services\GruppiServizio.
*/
class GruppoServizio extends Model
{
use LogsActivity;
protected $table = 'gruppi_servizio';
protected $fillable = ['nome', 'sorvegliante_id'];
protected function casts(): array
{
return ['sorvegliante_id' => 'integer'];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['nome', 'sorvegliante_id'])
->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');
}
/**
* I numeri del gruppo per la scheda del proclamatore e il profilo del
* sorvegliante: solo conteggi in SQL, nessun nome da decifrare.
*
* @return array{membri: int, pacchetto: int, da_dare: int, dati: int, assegnazioni: int}
*/
public function riepilogo(): array
{
$pacchetto = $this->territori()->count();
$dati = $this->territori()->whereHas('assegnazioni', fn ($q) => $q->whereNull('returned_at'))->count();
return [
'membri' => $this->membri()->count(),
'pacchetto' => $pacchetto,
'da_dare' => $pacchetto - $dati,
'dati' => $dati,
'assegnazioni' => $this->assegnazioni()->count(),
];
}
}

View File

@@ -16,7 +16,59 @@ 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');
}
/**
* Un sorvegliante che non è più attivo — reso non attivo, spostato nel
* cestino o anonimizzato — non guida più il gruppo: il gruppo si scioglie
* (GruppiServizio::sciogli), da qualunque pagina arrivi la modifica. Un
* membro nel cestino lascia il gruppo.
*/
protected static function booted(): void
{
static::updated(function (self $proclamatore) {
if ($proclamatore->wasChanged('attivo') && ! $proclamatore->attivo) {
$proclamatore->lasciaIlGruppo('sorvegliante non più attivo');
}
});
static::deleted(function (self $proclamatore) {
$proclamatore->lasciaIlGruppo('sorvegliante eliminato');
});
}
/** Il gruppo attivo di cui è sorvegliante, se c'è. */
public function gruppoSorvegliato(): ?GruppoServizio
{
return GruppoServizio::where('sorvegliante_id', $this->id)->first();
}
private function lasciaIlGruppo(string $motivo): void
{
$gruppo = $this->gruppoSorvegliato();
if ($gruppo) {
app(\App\Services\GruppiServizio::class)->sciogli($gruppo, $motivo);
return;
}
if ($this->trashed() && $this->gruppo_servizio_id) {
static::withTrashed()->whereKey($this->id)->update(['gruppo_servizio_id' => null]);
$this->gruppo_servizio_id = null;
}
}
protected function casts(): array protected function casts(): array
{ {
@@ -24,6 +76,7 @@ class Proclamatore extends Model
'nome' => 'encrypted', 'nome' => 'encrypted',
'cognome' => 'encrypted', 'cognome' => 'encrypted',
'attivo' => 'boolean', 'attivo' => 'boolean',
'gruppo_servizio_id' => 'integer',
]; ];
} }
@@ -50,9 +103,13 @@ class Proclamatore extends Model
*/ */
public function opzioneScelta(?string $motivo = null): array public function opzioneScelta(?string $motivo = null): array
{ {
// Il gruppo di servizio come nota, solo se già caricato (opzioniScelta).
$gruppo = $this->relationLoaded('gruppo') ? $this->gruppo_etichetta : null;
return array_filter([ return array_filter([
'valore' => (string) $this->id, 'valore' => (string) $this->id,
'etichetta' => $this->nome_completo, 'etichetta' => $this->nome_completo,
'dettaglio' => $gruppo !== null ? lcfirst($gruppo) : null,
'chiavi' => [(string) $this->cognome, (string) $this->nome], 'chiavi' => [(string) $this->cognome, (string) $this->nome],
'motivo' => $motivo, 'motivo' => $motivo,
], fn ($v) => $v !== null); ], fn ($v) => $v !== null);
@@ -60,10 +117,16 @@ class Proclamatore extends Model
/** /**
* Le opzioni di un elenco di proclamatori, in ordine di cognome e nome. * Le opzioni di un elenco di proclamatori, in ordine di cognome e nome.
* L'ordine si fa qui: in SQL ordinerebbe i testi cifrati. * L'ordine si fa qui: in SQL ordinerebbe i testi cifrati. Con $conGruppo
* chi fa parte di un gruppo di servizio lo dice accanto al nome (non serve
* dove sono tutti dello stesso gruppo, come in "Il mio gruppo").
*/ */
public static function opzioniScelta(iterable $proclamatori): array public static function opzioniScelta(iterable $proclamatori, bool $conGruppo = true): array
{ {
if ($conGruppo && $proclamatori instanceof \Illuminate\Database\Eloquent\Collection) {
$proclamatori->loadMissing('gruppo');
}
return collect($proclamatori) return collect($proclamatori)
->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome)) ->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome))
->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo')) ->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo'))
@@ -71,6 +134,22 @@ class Proclamatore extends Model
->all(); ->all();
} }
/**
* "Sorvegliante del gruppo Zisa" o "Gruppo Zisa"; null fuori dai gruppi.
* Senza gruppo_servizio_id non fa query.
*/
public function getGruppoEtichettaAttribute(): ?string
{
$gruppo = $this->gruppo;
if (! $gruppo) {
return null;
}
return $gruppo->sorvegliante_id === $this->id
? "Sorvegliante del gruppo {$gruppo->nome}"
: "Gruppo {$gruppo->nome}";
}
public function assegnazioni() public function assegnazioni()
{ {
return $this->hasMany(Assegnazione::class, 'proclamatore_id'); return $this->hasMany(Assegnazione::class, 'proclamatore_id');
@@ -95,6 +174,9 @@ 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;
// Se era sorvegliante, il salvataggio (non più attivo) scioglie il
// gruppo ed elimina la sua utenza: booted().
$this->save(); $this->save();
$this->delete(); // soft delete $this->delete(); // soft delete
} }

112
app/Models/Richiesta.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Una richiesta dal modulo pubblico (/richiesta): un proclamatore chiede un
* territorio nuovo o ne annuncia la riconsegna. Non tocca niente da sola:
* compare nella Home a chi ha `richieste.gestisci` (amministratore e
* assistenti), che la porta sul modulo di assegnazione o di rientro già
* esistenti; fatta l'operazione la richiesta risulta evasa. Si può anche
* scartare. Logica in App\Services\RichiesteServizio.
*
* Nome e note li scrive chi compila, senza account: cifrati come i dati dei
* proclamatori, e fuori dal registro delle attività (che logga solo lo stato).
*/
class Richiesta extends Model
{
use LogsActivity;
public const NUOVO = 'nuovo';
public const RICONSEGNA = 'riconsegna';
public const APERTA = 'aperta';
public const EVASA = 'evasa';
public const SCARTATA = 'scartata';
protected $table = 'richieste';
// Appena creata è aperta anche nell'oggetto, non solo nel database.
protected $attributes = ['stato' => self::APERTA];
// L'arrivo lo registra RichiesteServizio (con il numero del territorio):
// qui solo i cambi di stato, con chi li ha fatti.
protected static $recordEvents = ['updated'];
protected $fillable = ['tipo', 'nome', 'numero_territorio', 'note', 'stato', 'assegnazione_id', 'gestita_da', 'gestita_at'];
protected function casts(): array
{
return [
'nome' => 'encrypted',
'note' => 'encrypted',
'gestita_at' => 'datetime',
'assegnazione_id' => 'integer',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['tipo', 'stato', 'assegnazione_id'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function assegnazione()
{
return $this->belongsTo(Assegnazione::class);
}
public function gestitaDa()
{
return $this->belongsTo(User::class, 'gestita_da');
}
public function scopeAperte($query)
{
return $query->where('stato', self::APERTA);
}
public function isAperta(): bool
{
return $this->stato === self::APERTA;
}
public function getTipoEtichettaAttribute(): string
{
return $this->tipo === self::RICONSEGNA ? 'Riconsegna' : 'Nuovo territorio';
}
/** Chiusa da un'assegnazione o da un rientro fatti a partire da lei. */
public function evadi(?Assegnazione $assegnazione, ?User $chi): void
{
if (! $this->isAperta()) {
return;
}
$this->update([
'stato' => self::EVASA,
'assegnazione_id' => $assegnazione?->id,
'gestita_da' => $chi?->id,
'gestita_at' => now(),
]);
}
public function scarta(?User $chi): void
{
if (! $this->isAperta()) {
return;
}
$this->update([
'stato' => self::SCARTATA,
'gestita_da' => $chi?->id,
'gestita_at' => now(),
]);
}
}

View File

@@ -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;
@@ -145,6 +169,41 @@ class Territorio extends Model
return false; return false;
} }
/**
* La logica di priorità dei territori da distribuire, la stessa ovunque
* (Home "Da assegnare", pacchetto di un gruppo, "Il mio gruppo"): prima i
* prioritari — spunta manuale o fermi da più giorni della soglia
* "giorni_giacenza_prioritari" — poi chi è fermo da più tempo, poi il
* numero. Su ogni territorio imposta giacenza_giorni (dall'ultimo rientro,
* o dalla creazione se mai assegnato; 0 se è fuori) e in_priorita, da
* mostrare. Vuole 'ultimaAssegnazione' già caricata, per non fare una
* query a territorio.
*/
public static function ordinaPerPriorita(iterable $territori): \Illuminate\Support\Collection
{
$soglia = (int) (Setting::instance()->giorni_giacenza_prioritari ?? 180);
return collect($territori)
->each(function (self $territorio) use ($soglia) {
$ultima = $territorio->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
$giorni = (int) $ultima->returned_at->startOfDay()->diffInDays(today());
} elseif (! $ultima) {
$giorni = (int) $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorni = 0;
}
$territorio->setAttribute('giacenza_giorni', $giorni);
$territorio->setAttribute('in_priorita', (bool) $territorio->prioritario || $giorni > $soglia);
})
->sort(function (self $a, self $b) {
return [(int) $b->in_priorita, $b->giacenza_giorni] <=> [(int) $a->in_priorita, $a->giacenza_giorni]
?: strnatcasecmp((string) $a->numero, (string) $b->numero);
})
->values();
}
/** /**
* L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per * L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per
* numero, zona e tipologia. Con $motivo compare cercando ma non si può * numero, zona e tipologia. Con $motivo compare cercando ma non si può
@@ -172,12 +231,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()
@@ -258,4 +328,12 @@ class Territorio extends Model
? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)]) ? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
: null; : null;
} }
/** La miniatura per il sorvegliante di gruppo ("Il mio gruppo"): solo del suo pacchetto. */
public function miniaturaGruppoUrl(): ?string
{
return $this->thumbnail_path
? route('mio-gruppo.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
: null;
}
} }

View File

@@ -9,6 +9,8 @@ use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Models\Activity;
use Illuminate\Support\Facades\DB;
/** /**
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato * MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
@@ -24,6 +26,8 @@ class User extends Authenticatable implements MustVerifyEmail
'email', 'email',
'password', 'password',
'password_change_required', 'password_change_required',
'password_temporanea',
'proclamatore_id',
]; ];
protected $hidden = [ protected $hidden = [
@@ -37,9 +41,80 @@ 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',
// Il cambio obbligatorio viene da una password temporanea data
// dall'amministratore, non da un ripristino (pagina del cambio).
'password_temporanea' => 'boolean',
'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 di cui è sorvegliante (sciolto il gruppo, l'utenza non esiste più). */
public function gruppoSorvegliato(): ?GruppoServizio
{
return $this->proclamatore_id
? GruppoServizio::where('sorvegliante_id', $this->proclamatore_id)->first()
: null;
}
/**
* Elimina l'utenza senza perdere lo storico: le assegnazioni che ha fatto
* o fatto rientrare passano a $subentra (la colonna non può restare
* vuota), e nel registro delle attività le sue voci si tengono, con
* scritto chi era ($nome, $email) al posto del collegamento all'utenza.
*/
public function eliminaConservandoStorico(self $subentra, string $nome, ?string $email): void
{
DB::transaction(function () use ($subentra, $nome, $email) {
$eliminatoIl = now()->toDateTimeString();
Activity::query()
->where('causer_type', self::class)
->where('causer_id', $this->id)
->chunkById(200, function ($attivita) use ($nome, $email, $eliminatoIl) {
foreach ($attivita as $voce) {
$proprieta = $voce->properties?->toArray() ?? [];
$proprieta['causer_name'] = $nome;
$proprieta['causer_email'] = $email;
$proprieta['causer_deleted_at'] = $eliminatoIl;
$voce->properties = $proprieta;
$voce->save();
}
});
Assegnazione::query()->where('created_by', $this->id)->update(['created_by' => $subentra->id]);
Assegnazione::query()->where('returned_by', $this->id)->update(['returned_by' => $subentra->id]);
$this->syncRoles([]);
$this->delete();
});
}
/**
* 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 +123,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 +166,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) {

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use App\Models\Richiesta;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Avviso ad amministratori e assistenti: è arrivata una richiesta dal modulo
* pubblico. Volutamente non ShouldQueue: il nome scritto nella richiesta
* finirebbe in chiaro nella coda (Redis, failed_jobs).
*/
class RichiestaRicevuta extends Notification
{
public function __construct(private Richiesta $richiesta) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$r = $this->richiesta;
$messaggio = (new MailMessage)
->subject("Nuova richiesta: {$r->tipo_etichetta} — TerManager2")
->greeting('Ciao ' . $notifiable->name . ',')
->line($r->tipo === Richiesta::RICONSEGNA
? "{$r->nome} annuncia la riconsegna del territorio N° {$r->numero_territorio}."
: "{$r->nome} chiede un territorio nuovo.");
if ($r->note) {
$messaggio->line('Note: ' . $r->note);
}
return $messaggio
->action('Apri la Home di TerManager2', route('dashboard'))
->line('La richiesta si conferma dalla Home, nel riquadro "Richieste".')
->salutation('TerManager2');
}
}

View File

@@ -0,0 +1,347 @@
<?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
{
/** Il dominio delle email fittizie dei sorveglianti (iniziale+cognome@…). */
public const DOMINIO_EMAIL = 'termanager.it';
/**
* Crea il gruppo e l'utenza del sorvegliante: email fittizia ricavata dal
* suo nome (emailFittizia), 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 insieme all'email.
*
* @param int[] $membri id dei proclamatori, oltre al sorvegliante
* @return array{0: GruppoServizio, 1: string}
*/
public function crea(string $nome, Proclamatore $sorvegliante, array $membri = []): array
{
$nome = trim($nome);
if ($nome === '') {
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
}
if (GruppoServizio::where('nome', $nome)->exists()) {
throw new InvalidArgumentException("Esiste già un gruppo «{$nome}».");
}
if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
}
if ($sorvegliante->gruppo_servizio_id) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
}
if ($sorvegliante->utente()->exists()) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
}
$password = Str::password(12, symbols: false);
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $membri, $password) {
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri);
$utente = User::create([
'name' => $sorvegliante->nome_completo,
'email' => self::emailFittizia($sorvegliante),
'password' => $password,
'password_change_required' => true,
'password_temporanea' => true,
'proclamatore_id' => $sorvegliante->id,
]);
$utente->assignRole('sorvegliante');
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
->log('gruppo_creato');
return $gruppo;
});
return [$gruppo, $password];
}
/**
* Una password temporanea nuova per il sorvegliante che l'ha persa: da
* cambiare al primo accesso ("Password temporanea"). Le sessioni aperte
* con quella vecchia si chiudono alla richiesta successiva
* (AuthenticateSession: è cambiato l'hash), il "ricordami" si azzera.
* Restituisce la password, da mostrare una volta sola.
*/
public function nuovaPasswordTemporanea(User $utente): string
{
if (! $utente->proclamatore_id || ! $utente->isSorvegliante()) {
throw new InvalidArgumentException("{$utente->name} non è il sorvegliante di un gruppo.");
}
$password = Str::password(12, symbols: false);
$utente->forceFill([
'password' => $password,
'password_change_required' => true,
'password_temporanea' => true,
'remember_token' => null,
])->save();
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
return $password;
}
/**
* L'email fittizia del sorvegliante, con cui entra: iniziale del nome e
* cognome, minuscoli, senza accenti, spazi né apostrofi, sul dominio
* dell'app (Francesco Fontana → ffontana@termanager.it, Francesco
* D'Angelo → fdangelo@…). Se è già presa, un numero in coda
* (ffontana2@…). Non riceve posta: il sorvegliante non conferma l'email.
*/
public static function emailFittizia(Proclamatore $proclamatore): string
{
$pulisci = fn (string $testo) => preg_replace('/[^a-z0-9]/', '', Str::lower(Str::ascii($testo)));
$base = Str::limit(mb_substr($pulisci((string) $proclamatore->nome), 0, 1) . $pulisci((string) $proclamatore->cognome), 60, '');
if ($base === '') {
$base = "sorvegliante{$proclamatore->id}";
}
$email = "{$base}@" . self::DOMINIO_EMAIL;
for ($n = 2; User::where('email', $email)->exists(); $n++) {
$email = "{$base}{$n}@" . self::DOMINIO_EMAIL;
}
return $email;
}
/** @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, bool $cartaceo = false): 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, $cartaceo) {
$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,
// Il nome resta anche quando il gruppo, sciolto, non c'è più.
'gruppo_nome' => $gruppo->nome,
'link_da_inviare' => (bool) $territorio->pdf_path,
'cartaceo' => $cartaceo,
]);
});
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, o il
* sorvegliante non è più attivo) e lo elimina: non resta nello storico.
* I territori del pacchetto non ancora dati tornano in reparto; quelli
* già dati restano ai membri come assegnazioni qualsiasi, che conservano
* solo il nome del gruppo (gruppo_nome). I membri restano senza gruppo.
* L'utenza del sorvegliante si elimina: torna un proclamatore come gli
* altri. Le assegnazioni che aveva fatto o fatto rientrare passano a chi
* scioglie il gruppo; nel registro le sue voci restano, a nome
* "Sorvegliante del gruppo …", senza dati personali.
*/
public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void
{
if (! GruppoServizio::whereKey($gruppo->id)->exists()) {
return;
}
DB::transaction(function () use ($gruppo, $motivo) {
$liberati = Territorio::withTrashed()
->where('gruppo_servizio_id', $gruppo->id)
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'))
->pluck('numero');
Territorio::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Assegnazione::where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(array_filter([
'gruppo' => $gruppo->nome,
'territori_liberati' => $liberati->all(),
'utente_eliminato' => $utente?->id,
'motivo' => $motivo,
], fn ($v) => $v !== null))
->log('gruppo_sciolto');
if ($utente) {
$utente->eliminaConservandoStorico($this->subentrante($utente), "Sorvegliante del gruppo {$gruppo->nome}", null);
}
$gruppo->delete();
});
}
/**
* Chi prende le assegnazioni fatte dal sorvegliante quando la sua utenza
* si elimina: chi sta sciogliendo il gruppo, altrimenti il primo
* amministratore.
*/
private function subentrante(User $sorvegliante): User
{
$chi = auth()->user();
if ($chi instanceof User && $chi->id !== $sorvegliante->id) {
return $chi;
}
return User::role('amministratore')->orderBy('id')->firstOrFail();
}
private function attivo(GruppoServizio $gruppo): void
{
if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {
throw new InvalidArgumentException('Il gruppo non esiste più: è stato sciolto.');
}
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Services;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Territorio;
use App\Models\User;
use App\Notifications\RichiestaRicevuta;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
/**
* Le richieste dal modulo pubblico: registrarle, avvisare chi le gestisce e
* aiutarlo a ricondurle a un proclamatore o a un'assegnazione. La conferma
* passa dai moduli già esistenti (Assegna, Rientra), che chiudono la
* richiesta da cui sono partiti.
*/
class RichiesteServizio
{
public function __construct(private ConfigurazionePosta $posta) {}
/** Registra la richiesta e avvisa per email amministratori e assistenti. */
public function registra(string $tipo, string $nome, ?string $numero, ?string $note): Richiesta
{
$richiesta = Richiesta::create([
'tipo' => $tipo === Richiesta::RICONSEGNA ? Richiesta::RICONSEGNA : Richiesta::NUOVO,
'nome' => trim($nome),
'numero_territorio' => $tipo === Richiesta::RICONSEGNA ? trim((string) $numero) : null,
'note' => trim((string) $note) !== '' ? trim((string) $note) : null,
]);
// Nel registro tipo e numero, mai il nome.
activity()->performedOn($richiesta)
->withProperties(array_filter(['tipo' => $richiesta->tipo, 'territorio' => $richiesta->numero_territorio]))
->log('richiesta_ricevuta');
$this->avvisa($richiesta);
return $richiesta;
}
/**
* Chi gestisce le richieste e ha un'email vera: amministratori e
* assistenti (permesso richieste.gestisci) con l'indirizzo confermato.
*/
public function destinatari(): Collection
{
return User::permission('richieste.gestisci')->get()
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
->values();
}
/**
* L'email parte subito e non in coda: contiene il nome scritto nella
* richiesta, che nel database è cifrato e in coda starebbe in chiaro.
* Senza un server di posta funzionante non si manda niente: la richiesta
* resta comunque nella Home.
*/
private function avvisa(Richiesta $richiesta): void
{
if (! $this->posta->funzionante()) {
return;
}
$destinatari = $this->destinatari();
if ($destinatari->isEmpty()) {
return;
}
try {
Notification::send($destinatari, new RichiestaRicevuta($richiesta));
} catch (\Throwable $e) {
report($e);
}
}
/**
* I proclamatori attivi con un nome riconducibile a quello scritto nella
* richiesta, dal più simile: uguale, iniziale, una lettera sbagliata.
* I nomi sono cifrati, quindi il confronto si fa qui, non in SQL.
*
* @return Collection<int, Proclamatore>
*/
public function nomiSimili(string $nome, ?Collection $proclamatori = null, int $quanti = 5): Collection
{
$cercate = self::parole($nome);
if ($cercate === []) {
return collect();
}
return ($proclamatori ?? Proclamatore::attivi()->get())
->map(function (Proclamatore $p) use ($cercate) {
$sue = self::parole($p->cognome . ' ' . $p->nome);
$punti = 0;
foreach ($cercate as $parola) {
$migliore = 0;
foreach ($sue as $sua) {
$migliore = max($migliore, self::somiglianza($parola, $sua));
}
$punti += $migliore;
}
return [$p, $punti];
})
->filter(fn ($coppia) => $coppia[1] >= 2)
// Prima il punteggio più alto, a pari punti in ordine alfabetico.
->sort(fn ($a, $b) => [$b[1], mb_strtolower($a[0]->nome_completo)] <=> [$a[1], mb_strtolower($b[0]->nome_completo)])
->take($quanti)
->map(fn ($coppia) => $coppia[0])
->values();
}
/** L'assegnazione aperta del territorio indicato in una riconsegna, se c'è. */
public function assegnazioneAperta(?string $numero): ?Assegnazione
{
$numero = trim((string) $numero);
if ($numero === '') {
return null;
}
$territorio = Territorio::query()
->whereRaw('LOWER(numero) = ?', [Str::lower($numero)])
->with('assegnazioneCorrente.proclamatore')
->first();
return $territorio?->assegnazioneCorrente;
}
/** @return string[] parole minuscole, senza accenti né segni */
private static function parole(string $testo): array
{
$pulito = preg_replace('/[^a-z0-9 ]+/', ' ', Str::lower(Str::ascii($testo)));
return array_values(array_filter(explode(' ', (string) $pulito), fn ($p) => strlen($p) >= 2));
}
/** 3 uguale, 2 una inizia con l'altra, 1 una lettera di differenza. */
private static function somiglianza(string $a, string $b): int
{
if ($a === $b) {
return 3;
}
if (min(strlen($a), strlen($b)) >= 3 && (str_starts_with($a, $b) || str_starts_with($b, $a))) {
return 2;
}
if (min(strlen($a), strlen($b)) >= 4 && levenshtein($a, $b) <= 1) {
return 1;
}
return 0;
}
}

View File

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

View File

@@ -0,0 +1,78 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Gruppi di servizio (richiesta del 19 ottobre 2026).
*
* - gruppi_servizio: il gruppo, con il suo sorvegliante (un proclamatore) e
* sciolto_at quando viene sciolto (resta nello storico: le assegnazioni
* nate dal gruppo continuano a dirlo).
* - proclamatori.gruppo_servizio_id: i membri, un solo gruppo a testa.
* - territori.gruppo_servizio_id: il pacchetto del gruppo. Un territorio nel
* pacchetto non ancora dato a nessuno è "in carico al gruppo"; il pacchetto
* non è un'assegnazione e non entra nelle statistiche.
* - assegnazioni.gruppo_servizio_id: l'assegnazione è nata dal gruppo (data
* dal sorvegliante a un membro). È un'assegnazione normale a tutti gli
* effetti, segnata così.
* - users.proclamatore_id: l'utenza del sorvegliante, collegata al suo
* proclamatore. users.disattivato_at: utenza che non può più entrare (un
* gruppo sciolto disattiva quella del suo sorvegliante).
* - Ruolo "sorvegliante" con il solo permesso "gruppo.gestisci". Si crea qui
* e non solo nel seeder, che sulle installazioni esistenti non gira più.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('gruppi_servizio', function (Blueprint $table) {
$table->id();
$table->string('nome', 100);
$table->foreignId('sorvegliante_id')->nullable()->constrained('proclamatori')->nullOnDelete();
$table->timestamp('sciolto_at')->nullable();
$table->timestamps();
});
Schema::table('proclamatori', function (Blueprint $table) {
$table->foreignId('gruppo_servizio_id')->nullable()->after('attivo')->constrained('gruppi_servizio')->nullOnDelete();
});
Schema::table('territori', function (Blueprint $table) {
$table->foreignId('gruppo_servizio_id')->nullable()->after('prioritario')->constrained('gruppi_servizio')->nullOnDelete();
});
Schema::table('assegnazioni', function (Blueprint $table) {
$table->foreignId('gruppo_servizio_id')->nullable()->after('campaign_id')->constrained('gruppi_servizio')->nullOnDelete();
});
Schema::table('users', function (Blueprint $table) {
$table->foreignId('proclamatore_id')->nullable()->unique()->after('email')->constrained('proclamatori')->nullOnDelete();
$table->timestamp('disattivato_at')->nullable()->after('password_change_required');
});
$permesso = Permission::firstOrCreate(['name' => 'gruppo.gestisci', 'guard_name' => 'web']);
Role::firstOrCreate(['name' => 'sorvegliante', 'guard_name' => 'web'])->givePermissionTo($permesso);
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('proclamatore_id');
$table->dropColumn('disattivato_at');
});
Schema::table('assegnazioni', fn (Blueprint $table) => $table->dropConstrainedForeignId('gruppo_servizio_id'));
Schema::table('territori', fn (Blueprint $table) => $table->dropConstrainedForeignId('gruppo_servizio_id'));
Schema::table('proclamatori', fn (Blueprint $table) => $table->dropConstrainedForeignId('gruppo_servizio_id'));
Schema::dropIfExists('gruppi_servizio');
Role::where('name', 'sorvegliante')->delete();
Permission::where('name', 'gruppo.gestisci')->delete();
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
};

View File

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

View File

@@ -0,0 +1,92 @@
<?php
use App\Models\Proclamatore;
use App\Services\GruppiServizio;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Gruppi di servizio, 20 ottobre 2026 (decisioni dell'utente, che
* sostituiscono il nome utente introdotto poche ore prima):
*
* - il sorvegliante entra con un'email fittizia, iniziale del nome e cognome
* (ffontana@termanager.it), come gli altri utenti: via users.username, e
* users.email torna obbligatoria;
* - sciolto il gruppo l'utenza del sorvegliante si elimina, non si disattiva:
* via users.disattivato_at e le utenze già disattivate. Le loro
* assegnazioni passano al primo amministratore; nel registro le loro voci
* restano, a nome "Ex sorvegliante di gruppo".
*/
return new class extends Migration
{
public function up(): void
{
$amministratore = DB::table('model_has_roles')
->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
->where('roles.name', 'amministratore')
->where('model_has_roles.model_type', 'App\\Models\\User')
->min('model_has_roles.model_id');
if (Schema::hasColumn('users', 'disattivato_at')) {
foreach (DB::table('users')->whereNotNull('disattivato_at')->get() as $utente) {
if (! $amministratore) {
continue; // nessuno a cui passare le assegnazioni: resta
}
DB::table('assegnazioni')->where('created_by', $utente->id)->update(['created_by' => $amministratore]);
DB::table('assegnazioni')->where('returned_by', $utente->id)->update(['returned_by' => $amministratore]);
foreach (DB::table('activity_log')->where('causer_type', 'App\\Models\\User')->where('causer_id', $utente->id)->get() as $voce) {
$proprieta = json_decode((string) $voce->properties, true) ?: [];
$proprieta['causer_name'] = 'Ex sorvegliante di gruppo';
$proprieta['causer_email'] = null;
$proprieta['causer_deleted_at'] = now()->toDateTimeString();
DB::table('activity_log')->where('id', $voce->id)->update(['properties' => json_encode($proprieta)]);
}
DB::table('model_has_roles')->where('model_type', 'App\\Models\\User')->where('model_id', $utente->id)->delete();
DB::table('model_has_permissions')->where('model_type', 'App\\Models\\User')->where('model_id', $utente->id)->delete();
DB::table('users')->where('id', $utente->id)->delete();
}
}
// Chi è rimasto senza email (sorveglianti col nome utente): la fittizia.
foreach (DB::table('users')->whereNull('email')->get() as $utente) {
$proclamatore = $utente->proclamatore_id ? Proclamatore::withTrashed()->find($utente->proclamatore_id) : null;
$email = $proclamatore
? GruppiServizio::emailFittizia($proclamatore)
: "utente{$utente->id}@" . GruppiServizio::DOMINIO_EMAIL;
DB::table('users')->where('id', $utente->id)->update(['email' => $email]);
}
if (Schema::hasColumn('users', 'username')) {
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['username']);
});
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
});
}
if (Schema::hasColumn('users', 'disattivato_at')) {
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('disattivato_at');
});
}
Schema::table('users', function (Blueprint $table) {
$table->string('email')->nullable(false)->change();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('email')->nullable()->change();
});
Schema::table('users', function (Blueprint $table) {
$table->string('username', 60)->nullable()->unique()->after('email');
$table->timestamp('disattivato_at')->nullable();
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Perché un utente deve cambiare la password: dopo un ripristino da backup
* (com'era finora l'unico caso) o perché l'amministratore gli ha dato una
* password temporanea (sorvegliante di gruppo, alla creazione del gruppo o
* con "Nuova password temporanea"). La pagina del cambio password spiega il
* motivo giusto.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('password_temporanea')->default(false)->after('password_change_required');
});
// I sorveglianti che non hanno ancora cambiato la password di
// creazione del gruppo: la loro è temporanea, non da ripristino.
DB::table('users')
->whereNotNull('proclamatore_id')
->where('password_change_required', true)
->update(['password_temporanea' => true]);
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('password_temporanea');
});
}
};

View File

@@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Dal documento delle attività per la versione 2, decise dall'utente il
* 21/10/2026:
*
* - richieste dal modulo pubblico (nuovo territorio o riconsegna), che
* responsabile e assistenti confermano dalla Home: tabella `richieste` e
* permesso `richieste.gestisci` (amministratore e assistente);
* - territorio chiesto in cartaceo: segno sull'assegnazione
* (`assegnazioni.cartaceo`).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('richieste', function (Blueprint $table) {
$table->id();
// 'nuovo' (richiesta di un territorio) | 'riconsegna'
$table->string('tipo', 20);
// Nome e cognome come li ha scritti chi compila: cifrati, come i
// proclamatori (cast `encrypted` nel modello).
$table->text('nome');
$table->string('numero_territorio', 20)->nullable();
$table->text('note')->nullable();
// 'aperta' | 'evasa' | 'scartata'
$table->string('stato', 20)->default('aperta')->index();
$table->foreignId('assegnazione_id')->nullable()->constrained('assegnazioni')->nullOnDelete();
$table->foreignId('gestita_da')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('gestita_at')->nullable();
$table->timestamps();
});
Schema::table('assegnazioni', function (Blueprint $table) {
$table->boolean('cartaceo')->default(false)->after('link_canale');
});
app(PermissionRegistrar::class)->forgetCachedPermissions();
$permesso = Permission::firstOrCreate(['name' => 'richieste.gestisci', 'guard_name' => 'web']);
foreach (['amministratore', 'assistente'] as $ruolo) {
Role::where('name', $ruolo)->where('guard_name', 'web')->first()?->givePermissionTo($permesso);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
public function down(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
Permission::where('name', 'richieste.gestisci')->delete();
app(PermissionRegistrar::class)->forgetCachedPermissions();
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropColumn('cartaceo');
});
Schema::dropIfExists('richieste');
}
};

View File

@@ -25,6 +25,8 @@ class RolesAndPermissionsSeeder extends Seeder
'territori.return', 'territori.return',
'audit.view', 'audit.view',
'audit.export', 'audit.export',
'gruppo.gestisci',
'richieste.gestisci',
]; ];
foreach ($permissions as $permission) { foreach ($permissions as $permission) {
@@ -43,7 +45,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']);
@@ -53,6 +57,8 @@ class RolesAndPermissionsSeeder extends Seeder
'territori.assign', 'territori.assign',
'territori.return', 'territori.return',
'registro.view', 'registro.view',
// Le richieste dal modulo pubblico, confermate dalla Home.
'richieste.gestisci',
]); ]);
// Operatore: assign and return only // Operatore: assign and return only
@@ -61,5 +67,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']);
} }
} }

View File

@@ -1652,6 +1652,18 @@ html { scroll-behavior: smooth; }
padding-top: 10px; padding-top: 10px;
border-top: 1px solid var(--tm-bordo) !important; border-top: 1px solid var(--tm-bordo) !important;
} }
/* Riga di separazione di un gruppo (l'anno teocratico negli storici):
resta un titolo, non diventa una scheda. */
table.table-cards tr.tm-riga-gruppo {
border: none;
background: transparent;
padding: 0;
margin: 16px 0 6px;
}
table.table-cards tr.tm-riga-gruppo th {
display: block;
padding: 0 2px;
}
/* Cells spanning all columns (empty state, inline edit form) stay full /* Cells spanning all columns (empty state, inline edit form) stay full
width and block, so their inner layout isn't squeezed by the flex row. */ width and block, so their inner layout isn't squeezed by the flex row. */
table.table-cards td[colspan] { table.table-cards td[colspan] {
@@ -1793,10 +1805,29 @@ html { scroll-behavior: smooth; }
} }
.tm-assegnato-numero { color: #4f46e5; font-size: 14px; font-weight: 700; } .tm-assegnato-numero { color: #4f46e5; font-size: 14px; font-weight: 700; }
.tm-assegnato-numero:hover { text-decoration: underline; } .tm-assegnato-numero:hover { text-decoration: underline; }
/* Campo trappola del modulo pubblico delle richieste: fuori dallo schermo,
non `display:none` (alcuni programmi saltano i campi nascosti così). */
.tm-trappola {
position: absolute; left: -10000px; top: auto;
width: 1px; height: 1px; overflow: hidden;
}
/* Il numero come pulsante ("Il mio gruppo": apre l'anteprima): stesso aspetto. */
button.tm-assegnato-numero {
background: none; border: 0; padding: 0; cursor: pointer;
font-family: inherit; line-height: inherit;
}
button.tm-assegnato-numero:focus-visible {
outline: 2px solid var(--tm-accento); outline-offset: 2px; border-radius: 2px;
}
.tm-assegnato-zona { .tm-assegnato-zona {
margin-left: 6px; padding: 2px 6px; border-radius: 4px; margin-left: 6px; padding: 2px 6px; border-radius: 4px;
background: #f3f4f6; color: #9ca3af; font-size: 12px; background: #f3f4f6; color: #9ca3af; font-size: 12px;
} }
/* Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). */
.tm-cartaceo {
margin-left: 4px; padding: 2px 6px; border-radius: 4px;
background: #fffbeb; color: #b45309; font-size: 12px; font-weight: 600;
}
.tm-assegnato-info { color: #6b7280; font-size: 12px; } .tm-assegnato-info { color: #6b7280; font-size: 12px; }
.tm-assegnato-info b { color: #1f2937; font-size: 14px; font-weight: 500; } .tm-assegnato-info b { color: #1f2937; font-size: 14px; font-weight: 500; }
.tm-assegnato-giorni { .tm-assegnato-giorni {

View File

@@ -21,7 +21,15 @@
</div> </div>
@endif @endif
@if($forced) @if($forced && $temporanea)
{{-- Primo accesso del sorvegliante, o password nuova data dall'amministratore --}}
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<strong>Password temporanea.</strong>
Stai usando la password provvisoria che ti ha dato l&rsquo;amministratore:
scegline una tua per continuare. Fino ad allora il resto
dell&rsquo;applicazione resta bloccato.
</div>
@elseif($forced)
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800"> <div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<strong>Ripristino da backup eseguito.</strong> <strong>Ripristino da backup eseguito.</strong>
Il pacchetto di backup contiene le password di tutti gli account, quindi Il pacchetto di backup contiene le password di tutti gli account, quindi

View File

@@ -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') }}"
@@ -107,6 +120,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="M12 4v16m8-8H4"/></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="M12 4v16m8-8H4"/></svg>
Assegnazioni Assegnazioni
</a> </a>
<a href="{{ route('assegnazioni.cartacei') }}"
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('assegnazioni.cartacei') ? '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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Cartacei
</a>
@endcan @endcan
@can('campagne.manage') @can('campagne.manage')
@@ -146,6 +164,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>

View File

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

View File

@@ -8,76 +8,8 @@
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p> <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 }">
@@ -94,6 +26,13 @@
{{-- Form --}} {{-- Form --}}
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl"> <div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
@if($richiesta)
{{-- Partita da una richiesta del modulo pubblico (Home): assegnando, risulta evasa. --}}
<div class="mb-4 rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900" role="status">
Richiesta di <strong>{{ $richiesta->nome }}</strong> del {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif.
Assegnando il territorio, la richiesta risulta evasa.
</div>
@endif
<form wire:submit="save" class="space-y-4"> <form wire:submit="save" class="space-y-4">
{{-- L'anteprima segue la scelta nel browser (evento "scelta" di {{-- L'anteprima segue la scelta nel browser (evento "scelta" di
<x-scelta>, miniatura nelle opzioni): scegliere un <x-scelta>, miniatura nelle opzioni): scegliere un
@@ -133,6 +72,12 @@
@error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input wire:model="cartaceo" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Ha chiesto la cartolina cartacea
<a href="{{ route('assegnazioni.cartacei') }}" class="text-xs text-indigo-600 hover:underline">(vedi i cartacei fuori)</a>
</label>
<div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center"> <div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
<x-btn variante="primary" type="submit">Assegna</x-btn> <x-btn variante="primary" type="submit">Assegna</x-btn>
@if($ritorno) @if($ritorno)
@@ -148,6 +93,9 @@
{{-- Elenco dei territori assegnati: componente a sé, caricato subito {{-- Elenco dei territori assegnati: componente a sé, caricato subito
dopo la pagina (ElencoAssegnati, #[Lazy]). Le azioni del form non dopo la pagina (ElencoAssegnati, #[Lazy]). Le azioni del form non
lo ridisegnano. --}} lo ridisegnano. Non c'è quando si arriva da "Assegna →" della Home
(Assegna::$elencoNascosto). --}}
@unless($elencoNascosto)
<livewire:assegnazioni.elenco-assegnati /> <livewire:assegnazioni.elenco-assegnati />
@endunless
</div> </div>

View File

@@ -0,0 +1,47 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Territori cartacei</h1>
<p class="text-sm text-gray-500 mt-1">
Le cartoline cartacee fuori e a chi: territori assegnati per cui il proclamatore ha chiesto il cartaceo.
Al rientro escono dall'elenco.
</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
@if($assegnazioni->isEmpty())
<p class="px-5 py-8 text-center text-sm text-gray-500">Nessun territorio cartaceo fuori.</p>
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Territorio</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Proclamatore</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Dal</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Giorni</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $a)
<tr wire:key="cartaceo-{{ $a->id }}">
<td data-label="Territorio" class="px-4 py-3">
<a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="font-semibold text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a>
<span class="text-gray-500">{{ $a->territorio?->zona?->nome }}</span>
</td>
<td data-label="Proclamatore" class="px-4 py-3">{{ $a->proclamatore?->nome_completo }}</td>
<td data-label="Dal" class="px-4 py-3 tabular-nums">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td data-label="Giorni" class="px-4 py-3 text-right tabular-nums">{{ $a->giorni }}</td>
<td class="px-4 py-3 text-right cell-actions">
@can('territori.return')
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', $a)">Rientro</x-btn>
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>

View File

@@ -40,17 +40,22 @@
@endphp @endphp
{{-- Riga compatta: classi brevi (tm-assegnato-*, in app.css), copia con {{-- Riga compatta: classi brevi (tm-assegnato-*, in app.css), copia con
il metodo condiviso copia(): sono ~80 righe, ogni byte si il metodo condiviso copia(): sono ~80 righe, ogni byte si
moltiplica. I pulsanti restano <x-btn> (StileUnificatoTest). --}} moltiplica. I pulsanti restano <x-btn> (StileUnificatoTest).
"Rientro" porta alla registrazione del rientro e torna qui
(data-ritorno); indirizzo relativo e niente a capo: pesa meno.
L'etichetta "cartaceo" è un'espressione e non un blocco if:
Livewire circonda ogni if con due commenti segnaposto (58 byte
a riga, anche quando è falso). Il testo è fisso, niente dati. --}}
<div wire:key="assegnato-{{ $a->id }}" data-cerca="{{ $cerca[$a->id] }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato"> <div wire:key="assegnato-{{ $a->id }}" data-cerca="{{ $cerca[$a->id] }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
<div class="tm-assegnato-testa"> <div class="tm-assegnato-testa">
<span><a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="tm-assegnato-numero">N° {{ $a->territorio?->numero }}</a> @if($a->territorio?->zona?->nome)<span class="tm-assegnato-zona">{{ $a->territorio->zona->nome }}</span>@endif</span> <span><a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="tm-assegnato-numero">N° {{ $a->territorio?->numero }}</a> @if($a->territorio?->zona?->nome)<span class="tm-assegnato-zona">{{ $a->territorio->zona->nome }}</span>@endif{!! $a->cartaceo ? ' <span class="tm-cartaceo">cartaceo</span>' : '' !!}</span>
<span class="tm-assegnato-info"><b>{{ $nome }}</b> · {{ $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> <span class="tm-assegnato-info"><b>{{ $nome }}</b> · {{ $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>
</div> </div>
@if($a->link_in_sospeso) @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="$dispatch('apri-invio', { assegnazioneId: {{ $a->id }} })">Invia link</x-btn></div> <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="$dispatch('apri-invio', { assegnazioneId: {{ $a->id }} })">Invia link</x-btn></div>
@endif @endif
@if($pdfUrl) @if($pdfUrl)
<div class="tm-assegnato-link" data-link data-id="{{ $a->id }}" data-messaggio="{{ $a->messaggioLink($pdfUrl) }}" data-titolo="Territorio N° {{ $a->territorio?->numero }}"> <div class="tm-assegnato-link" data-link data-id="{{ $a->id }}" data-messaggio="{{ $a->messaggioLink($pdfUrl) }}" data-titolo="Territorio N° {{ $a->territorio?->numero }}">@can('territori.return')<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a, false)">Rientro</x-btn>@endcan
<code x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code> <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 del PDF"><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 class="tm-copia" x-on:click="copia($el)" title="Copia link" aria-label="Copia il link del PDF"><svg aria-hidden="true"><use href="#tm-i-copia"/></svg><svg aria-hidden="true"><use href="#tm-i-spunta"/></svg></x-btn>
{{-- Condividi: menu di condivisione del telefono, solo dove il browser lo offre (elencoCercabile.condividi). --}} {{-- Condividi: menu di condivisione del telefono, solo dove il browser lo offre (elencoCercabile.condividi). --}}
@@ -59,7 +64,10 @@
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" wire:click="toggleLinkSent({{ $a->id }})" aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}" title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}"><svg aria-hidden="true"><use href="{{ $a->link_sent ? '#tm-i-spunta' : '#tm-i-invio' }}"/></svg> {{ $a->link_sent ? 'Link inviato' : 'Non inviato' }}</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' }}" title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}"><svg aria-hidden="true"><use href="{{ $a->link_sent ? '#tm-i-spunta' : '#tm-i-invio' }}"/></svg> {{ $a->link_sent ? 'Link inviato' : 'Non inviato' }}</x-btn>
</div> </div>
@else @else
<p class="tm-assegnato-senza">Nessun PDF disponibile</p> <div class="tm-assegnato-link">
@can('territori.return')<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a, false)">Rientro</x-btn>@endcan
<p class="tm-assegnato-senza">Nessun PDF disponibile</p>
</div>
@endif @endif
</div> </div>
@endforeach @endforeach

View File

@@ -1,12 +1,19 @@
<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>
@if($richiesta)
{{-- Partito da una richiesta di riconsegna (Home): registrando il rientro, risulta evasa. --}}
<div class="mb-4 rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900 max-w-lg" role="status">
Riconsegna annunciata da <strong>{{ $richiesta->nome }}</strong> il {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif.
</div>
@endif
{{-- Assignment summary --}} {{-- Assignment summary --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
@@ -62,7 +69,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>

View File

@@ -0,0 +1,63 @@
<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 class="select-all">{{ $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, e riceve un'utenza con un'email fittizia: iniziale del nome e cognome (es. Francesco Fontana → <code>ffontana@{{ \App\Services\GruppiServizio::DOMINIO_EMAIL }}</code>).</p>
@error('sorveglianteId') <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>
<p class="text-xs text-gray-500">
Sorvegliante: {{ $g->sorvegliante?->nome_completo ?? '—' }}
· {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto
</p>
</div>
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">Gestisci</x-btn>
</li>
@empty
<li class="px-4 py-8 text-center text-sm text-gray-500">Nessun gruppo di servizio.</li>
@endforelse
</ul>
</section>
</div>

View File

@@ -0,0 +1,165 @@
<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($gruppo->sorvegliante?->utente)
· accede con <code class="select-all">{{ $gruppo->sorvegliante->utente->email }}</code>
@endif
</p>
</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
{{-- ─── 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, in
ordine di priorità (come "Da assegnare" nella Home), filtrabili. --}}
<div class="border-t border-gray-100 bg-gray-50 p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi territori al pacchetto</h3>
@if($totaleCandidati === 0)
<p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 mb-2">
<input wire:model.live.debounce.300ms="cercaTerritorio" type="search" inputmode="search" autocomplete="off" aria-label="Cerca tra i territori in reparto"
placeholder="Numero, zona o tipologia…"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<select wire:model.live="filtroZona" aria-label="Zona"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le zone</option>
@foreach($zoneCandidati as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<select wire:model.live="filtroTipologia" aria-label="Tipologia"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le tipologie</option>
@foreach($tipologieCandidati as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<label class="flex items-center gap-2 text-sm text-gray-700 px-1">
<input type="checkbox" wire:model.live="soloPrioritari" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
Solo prioritari
</label>
</div>
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>{{ $candidatiTerritori->count() }} di {{ $totaleCandidati }} in reparto, prima i prioritari e quelli fermi da più tempo</span>
@if($candidatiTerritori->isNotEmpty())
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="selezionaMostrati">Seleziona tutti quelli mostrati</x-btn>
@endif
<x-btn variante="ghost" dimensione="sm" wire:click="deselezionaTutti">Deseleziona</x-btn>
</div>
<div class="max-h-96 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
@forelse($candidatiTerritori as $c)
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40" 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 class="min-w-0">
<strong>N° {{ $c->numero }}</strong>
@if($c->in_priorita)
<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
@endif
<span class="block text-xs text-gray-500">
{{ collect([$c->zona?->nome, $c->tipologia?->nome])->filter()->join(' — ') }}
@if($c->giacenza_giorni > 0) · fermo da {{ $c->giacenza_giorni }} gg @endif
</span>
</span>
</label>
@empty
<p class="px-3 py-6 text-center text-sm text-gray-500">Nessun territorio con questi filtri.</p>
@endforelse
</div>
<div class="mt-3 flex flex-wrap items-center gap-3">
<x-btn variante="primary" wire:click="aggiungiTerritori">Aggiungi i territori selezionati</x-btn>
<span class="text-xs text-gray-500" x-data>Selezionati: <strong x-text="$wire.nuoviTerritori.length">{{ count($nuoviTerritori) }}</strong></span>
</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 e viene eliminato: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene eliminata. Le assegnazioni già fatte restano ai membri.">Togli il ruolo e sciogli il gruppo</x-btn>
</div>
</section>
</div>

View File

@@ -0,0 +1,193 @@
<div x-data="{ anteprima: null }">
{{-- 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.
Il numero del territorio apre l'anteprima (stato Alpine `anteprima`,
finestra in fondo alla vista): miniatura e PDF dalle rotte
mio-gruppo.*, che rispondono solo per il pacchetto del gruppo. --}}
@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>
@if($gruppo->sorvegliante)
<p class="text-sm text-gray-700 mt-0.5">Sorvegliante: <strong>{{ trim($gruppo->sorvegliante->nome . ' ' . $gruppo->sorvegliante->cognome) }}</strong></p>
@endif
{{-- In questa vista solo blocchi php…endphp: la forma in linea, se
viene prima di un blocco, confonde il compilatore di Blade. --}}
@php $daDare = $pacchetto->filter(fn ($t) => ! $t->assegnazioneCorrente)->count(); @endphp
<p class="text-sm text-gray-500 mt-1">
{{ $pacchetto->count() }} {{ $pacchetto->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 space-y-2">
<div class="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($pacchetto->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>
{{-- Filtri; l'ordine è di priorità: prima i prioritari e quelli fermi da più tempo --}}
@if($pacchetto->isNotEmpty())
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<select wire:model.live="filtroStato" aria-label="Stato"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutti</option>
<option value="da_dare">Da dare</option>
<option value="dati">Già dati</option>
</select>
<select wire:model.live="filtroZona" aria-label="Zona"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le zone</option>
@foreach($zone as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<select wire:model.live="filtroTipologia" aria-label="Tipologia"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le tipologie</option>
@foreach($tipologie as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<label class="flex items-center gap-2 text-sm text-gray-700 px-1">
<input type="checkbox" wire:model.live="soloPrioritari" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
Solo prioritari
</label>
</div>
@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>@if($t->thumbnail_path || $t->pdf_path)<button type="button" class="tm-assegnato-numero" title="Anteprima del territorio"
data-titolo="Territorio N° {{ $t->numero }}{{ $t->zona?->nome ? ' — ' . $t->zona->nome : '' }}"
data-miniatura="{{ $t->miniaturaGruppoUrl() }}"
data-pdf="{{ $t->pdf_path ? route('mio-gruppo.pdf', $t) : '' }}"
x-on:click="anteprima = { titolo: $el.dataset.titolo, miniatura: $el.dataset.miniatura, pdf: $el.dataset.pdf }">N° {{ $t->numero }}</button>@else<span class="tm-assegnato-numero">N° {{ $t->numero }}</span>@endif @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif
@if($t->in_priorita)<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>@endif @if($a?->cartaceo)<span class="tm-cartaceo">cartaceo</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>@if($t->tipologia?->nome) · {{ $t->tipologia->nome }}@endif @if($t->giacenza_giorni > 0) · fermo da {{ $t->giacenza_giorni }} gg @endif</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>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input wire:model="cartaceo" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Ha chiesto la cartolina cartacea
</label>
<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">
{{ $pacchetto->isEmpty() ? "Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo." : 'Nessun territorio con questi filtri.' }}
</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 = $pacchetto->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>
{{-- Anteprima del territorio: si apre dal numero, si chiude con la X,
con Esc o toccando fuori. --}}
<div class="tm-modale-sfondo" x-show="anteprima" x-cloak x-transition.opacity
x-on:click.self="anteprima = null" x-on:keydown.escape.window="anteprima = null">
<div class="tm-modale" role="dialog" aria-modal="true" aria-labelledby="anteprima-titolo">
<header class="tm-modale-testata">
<h2 id="anteprima-titolo" x-text="anteprima?.titolo"></h2>
<button type="button" class="tm-editor-chiudi" x-on:click="anteprima = null" aria-label="Chiudi l'anteprima">&times;</button>
</header>
<div class="tm-modale-corpo">
<template x-if="anteprima?.miniatura">
<img x-bind:src="anteprima.miniatura" x-bind:alt="'Anteprima: ' + anteprima.titolo"
class="mx-auto max-w-full rounded-lg border border-gray-200 bg-gray-50">
</template>
<p x-show="! anteprima?.miniatura" class="text-sm text-gray-500">Per questo territorio non c'è ancora un'anteprima.</p>
</div>
<footer class="tm-modale-piede" x-show="anteprima?.pdf">
<a x-bind:href="anteprima?.pdf" target="_blank" rel="noopener" class="tm-collegamento">Apri la piantina (PDF)</a>
</footer>
</div>
</div>
@endif
</div>

View File

@@ -78,6 +78,61 @@
@endif @endif
</div> </div>
{{-- Richieste dal modulo pubblico: si confermano portandole sul modulo
di assegnazione o di rientro, che le chiude da solo. --}}
@can('richieste.gestisci')
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-richieste">
<div class="px-5 py-4 border-b flex flex-wrap items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe">
<div>
<h3 id="titolo-richieste" class="text-sm font-semibold" style="color:#3730a3">Richieste ({{ $richieste->count() }})</h3>
<p class="text-xs" style="color:#4f46e5">Dal modulo pubblico: <code class="select-all">{{ route('richiesta') }}</code></p>
</div>
</div>
@forelse($richieste as $voce)
@php
$r = $voce['richiesta'];
$a = $voce['assegnazione'];
@endphp
<div class="px-5 py-4 border-b border-gray-100 last:border-b-0" wire:key="richiesta-{{ $r->id }}">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="text-sm text-gray-900">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $r->tipo === \App\Models\Richiesta::RICONSEGNA ? 'bg-amber-50 text-amber-800' : 'bg-indigo-50 text-indigo-700' }}">{{ $r->tipo_etichetta }}</span>
<strong class="ml-1">{{ $r->nome }}</strong>
@if($r->numero_territorio) · territorio N° {{ $r->numero_territorio }} @endif
</p>
<span class="text-xs text-gray-500">{{ $r->created_at->format('d/m/Y H:i') }}</span>
</div>
@if($r->note)
<p class="mt-1 text-sm text-gray-600 italic">«{{ $r->note }}»</p>
@endif
<div class="mt-3 flex flex-wrap items-center gap-2">
@if($r->tipo === \App\Models\Richiesta::NUOVO)
<span class="text-xs text-gray-500">{{ $voce['simili']->isNotEmpty() ? 'Chi è? Assegna a:' : 'Nessun proclamatore con un nome simile.' }}</span>
@foreach($voce['simili'] as $p)
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm"
:href="route('assegnazioni.assegna', ['proclamatore' => $p->id, 'richiesta' => $r->id, 'dalla_home' => 1])">{{ $p->nome_completo }} →</x-btn>
@endforeach
<x-btn data-ritorno variante="ghost" dimensione="sm"
:href="route('assegnazioni.assegna', ['richiesta' => $r->id, 'dalla_home' => 1])">{{ $voce['simili']->isNotEmpty() ? 'Nessuno di questi: apri il modulo' : 'Apri il modulo' }}</x-btn>
@elseif($a)
<span class="text-xs text-gray-500">Assegnato a <strong>{{ $a->proclamatore?->nome_completo }}</strong> dal {{ $a->assigned_at->format('d/m/Y') }}</span>
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm"
:href="route('assegnazioni.rientra', ['assegnazione' => $a->id, 'richiesta' => $r->id])">Registra rientro →</x-btn>
@else
<span class="text-xs text-amber-800">Il territorio N° {{ $r->numero_territorio }} non risulta assegnato.</span>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm" class="ml-auto"
wire:click="scartaRichiesta({{ $r->id }})"
wire:confirm="Scartare la richiesta di {{ $r->nome }}?">Scarta</x-btn>
</div>
</div>
@empty
<p class="px-5 py-4 text-sm text-gray-500">Nessuna richiesta aperta.</p>
@endforelse
</section>
@endcan
{{-- Quick lists --}} {{-- Quick lists --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Da assegnare --}} {{-- Da assegnare --}}
@@ -110,19 +165,19 @@
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a data-ritorno href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a> <a data-ritorno href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
@if($t->home_is_prioritario) @if($t->in_priorita)
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
@endif @endif
</div> </div>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }} {{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}
@if($t->home_giorni_giacenza > 0) @if($t->giacenza_giorni > 0)
— <span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto — <span class="font-medium">{{ $t->giacenza_giorni }} gg</span> in reparto
@endif @endif
</p> </p>
</div> </div>
@can('territori.assign') @can('territori.assign')
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $t->id])">Assegna →</x-btn> <x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $t->id, 'dalla_home' => 1])">Assegna →</x-btn>
@endcan @endcan
</li> </li>
@empty @empty

View File

@@ -16,10 +16,11 @@
<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, stato del suo invio (inviato o no, e con quale canale) se il proclamatore ha chiesto la cartolina cartacea e, se l'ha data il sorvegliante di un gruppo di servizio, il nome del gruppo.</li>
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo.</li> <li><strong>Richieste dal modulo pubblico</strong>: chi chiede un territorio o ne annuncia la riconsegna scrive il proprio nome, l'eventuale numero del territorio e una nota. Nome e nota sono cifrati nel database ({{ $cifratura }}) e non finiscono nel registro delle attività. Dell'indirizzo IP resta solo un'impronta temporanea, per limitare i tentativi.</li>
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo. Il sorvegliante di un gruppo di servizio entra con un'email fittizia creata dall'applicazione (iniziale del nome e cognome), che non riceve posta e non va confermata. La sua utenza è collegata al suo proclamatore e viene eliminata quando il gruppo si scioglie; anche il gruppo, sciolto, viene eliminato.</li>
<li><strong>Registro delle attività</strong>: chi ha fatto cosa e quando. I proclamatori vi compaiono solo con un identificativo numerico, mai con il nome.</li> <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>
@@ -29,6 +30,7 @@
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<li>{{ $conteggi['proclamatori'] }} proclamatori, di cui {{ $conteggi['proclamatoriAttivi'] }} attivi; {{ $conteggi['anonimizzati'] }} nel cestino o anonimizzati.</li> <li>{{ $conteggi['proclamatori'] }} proclamatori, di cui {{ $conteggi['proclamatoriAttivi'] }} attivi; {{ $conteggi['anonimizzati'] }} nel cestino o anonimizzati.</li>
<li>{{ $conteggi['assegnazioni'] }} assegnazioni nello storico, di cui {{ $conteggi['assegnazioniAperte'] }} in corso.</li> <li>{{ $conteggi['assegnazioni'] }} assegnazioni nello storico, di cui {{ $conteggi['assegnazioniAperte'] }} in corso.</li>
<li>{{ $conteggi['richieste'] }} richieste dal modulo pubblico, di cui {{ $conteggi['richiesteAperte'] }} da gestire.</li>
<li>{{ $conteggi['utenti'] }} utenti dell'applicazione.</li> <li>{{ $conteggi['utenti'] }} utenti dell'applicazione.</li>
<li>{{ $conteggi['attivita'] }} voci nel registro delle attività@if($conteggi['attivitaDal']), dal {{ \Illuminate\Support\Carbon::parse($conteggi['attivitaDal'])->format('d/m/Y') }}@endif.</li> <li>{{ $conteggi['attivita'] }} voci nel registro delle attività@if($conteggi['attivitaDal']), dal {{ \Illuminate\Support\Carbon::parse($conteggi['attivitaDal'])->format('d/m/Y') }}@endif.</li>
</ul> </ul>
@@ -58,7 +60,8 @@
<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 fittizia e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
<p class="mt-2">Il modulo pubblico di richiesta non mostra dati personali: chi annuncia una riconsegna sceglie il territorio da un elenco con solo numero e zona, senza sapere a chi è assegnato né se lo è. Le richieste le vedono e le gestiscono, dalla Home, solo i ruoli con il permesso sulle richieste.</p>
</section> </section>
<section> <section>
@@ -67,6 +70,7 @@
<li><strong>Cifratura</strong>: nome e cognome dei proclamatori sono cifrati con {{ $cifratura }} prima di essere salvati; la chiave non sta nel database.</li> <li><strong>Cifratura</strong>: nome e cognome dei proclamatori sono cifrati con {{ $cifratura }} prima di essere salvati; la chiave non sta nel database.</li>
<li><strong>Accesso</strong>: password personale, conferma dell'email, blocco temporaneo dopo troppi tentativi; la sessione scade dopo {{ $durataSessione }} minuti di inattività.</li> <li><strong>Accesso</strong>: password personale, conferma dell'email, blocco temporaneo dopo troppi tentativi; la sessione scade dopo {{ $durataSessione }} minuti di inattività.</li>
<li><strong>Permessi</strong>: ogni ruolo vede e modifica solo ciò che gli serve (vedi sopra).</li> <li><strong>Permessi</strong>: ogni ruolo vede e modifica solo ciò che gli serve (vedi sopra).</li>
<li><strong>Modulo pubblico</strong>: protetto dallo spam con un campo trappola, un tempo minimo di compilazione e un limite di richieste per indirizzo.</li>
<li><strong>Registro delle attività</strong>: le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</li> <li><strong>Registro delle attività</strong>: le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</li>
<li><strong>Comunicazioni</strong>: l'applicazione risponde all'indirizzo <code>{{ $indirizzo }}</code>@if($https), su connessione cifrata (HTTPS)@else. <span class="text-red-700 font-medium">Attenzione: l'indirizzo non usa HTTPS, le comunicazioni non sono cifrate.</span>@endif.</li> <li><strong>Comunicazioni</strong>: l'applicazione risponde all'indirizzo <code>{{ $indirizzo }}</code>@if($https), su connessione cifrata (HTTPS)@else. <span class="text-red-700 font-medium">Attenzione: l'indirizzo non usa HTTPS, le comunicazioni non sono cifrate.</span>@endif.</li>
<li><strong>Hosting</strong>: i dati risiedono sul server gestito dagli amministratori dell'applicazione, non su servizi cloud di terze parti.</li> <li><strong>Hosting</strong>: i dati risiedono sul server gestito dagli amministratori dell'applicazione, non su servizi cloud di terze parti.</li>
@@ -88,7 +92,7 @@
<li><strong>Condivisione del link</strong>: il messaggio con il nome del proclamatore e il link passa per l'app scelta da chi assegna il territorio (WhatsApp, Telegram, SMS…), dal suo telefono, non dall'applicazione.</li> <li><strong>Condivisione del link</strong>: il messaggio con il nome del proclamatore e il link passa per l'app scelta da chi assegna il territorio (WhatsApp, Telegram, SMS…), dal suo telefono, non dall'applicazione.</li>
<li><strong>Posta</strong>: <li><strong>Posta</strong>:
@if($postaAttiva) @if($postaAttiva)
le email agli utenti dell'applicazione (conferma dell'indirizzo) partono dal server di posta <code>{{ $hostPosta ?? 'configurato' }}</code>. Ai proclamatori non si mandano email. le email agli utenti dell'applicazione partono dal server di posta <code>{{ $hostPosta ?? 'configurato' }}</code>: conferma dell'indirizzo, avviso di una nuova richiesta dal modulo pubblico a chi le gestisce, e il modulo S-13 (registro delle assegnazioni, in PDF) agli amministratori l'ultima domenica del mese. Ai proclamatori non si mandano email.
@else @else
nessun server di posta configurato: l'applicazione non invia email. Ai proclamatori non se ne mandano comunque. nessun server di posta configurato: l'applicazione non invia email. Ai proclamatori non se ne mandano comunque.
@endif @endif
@@ -100,10 +104,11 @@
<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; se era sorvegliante di un gruppo, il gruppo si scioglie e la sua utenza viene eliminata.</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>
<li>Le richieste dal modulo pubblico sono cancellate automaticamente <strong>{{ \App\Console\Commands\PuliziaRichieste::GIORNI }} giorni</strong> dopo essere state gestite.</li>
<li>I backup e gli export XML scaricati dagli amministratori contengono nome e cognome dei proclamatori <strong>in chiaro</strong>: vanno custoditi in un luogo protetto e cancellati quando non servono più.</li> <li>I backup e gli export XML scaricati dagli amministratori contengono nome e cognome dei proclamatori <strong>in chiaro</strong>: vanno custoditi in un luogo protetto e cancellati quando non servono più.</li>
</ul> </ul>
</section> </section>

View File

@@ -25,6 +25,12 @@
<input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"> <input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="attivo" class="text-sm text-gray-700">Attivo</label> <label for="attivo" class="text-sm text-gray-700">Attivo</label>
</div> </div>
@if($gruppoSorvegliato ?? null)
<p class="-mt-2 text-xs text-amber-800">
È il sorvegliante del gruppo <strong>{{ $gruppoSorvegliato->nome }}</strong>: se non è più attivo il gruppo si scioglie,
i territori non ancora dati tornano in reparto e la sua utenza viene eliminata.
</p>
@endif
<div class="flex items-center gap-3 pt-4"> <div class="flex items-center gap-3 pt-4">
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn> <x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>

View File

@@ -26,6 +26,7 @@
<option value="">Tutti</option> <option value="">Tutti</option>
<option value="attivo">Attivi</option> <option value="attivo">Attivi</option>
<option value="inattivo">Inattivi</option> <option value="inattivo">Inattivi</option>
<option value="gruppo">In un gruppo di servizio</option>
</select> </select>
</div> </div>
</div> </div>
@@ -53,11 +54,16 @@
<td data-label="Cognome" class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td> <td data-label="Cognome" class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td>
<td data-label="Nome" class="px-4 py-3 text-sm text-gray-700">{{ $proclamatore->nome }}</td> <td data-label="Nome" class="px-4 py-3 text-sm text-gray-700">{{ $proclamatore->nome }}</td>
<td data-label="Stato" class="px-4 py-3 text-sm"> <td data-label="Stato" class="px-4 py-3 text-sm">
<div class="flex flex-wrap gap-1">
@if($proclamatore->attivo) @if($proclamatore->attivo)
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attivo</span> <span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attivo</span>
@else @else
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span> <span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
@endif @endif
@if($proclamatore->gruppo_etichetta)
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-800">{{ $proclamatore->gruppo_etichetta }}</span>
@endif
</div>
</td> </td>
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td> <td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
<td class="px-4 py-3 text-sm text-right cell-actions"> <td class="px-4 py-3 text-sm text-right cell-actions">
@@ -73,6 +79,15 @@
title="Modifica" aria-label="Modifica il proclamatore"> title="Modifica" aria-label="Modifica il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</x-btn> </x-btn>
@if($proclamatore->attivo && isset($gruppiSorvegliati[$proclamatore->id]))
{{-- Sorvegliante: disattivarlo scioglie il gruppo, si chiede conferma --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="toggleActive({{ $proclamatore->id }})"
wire:confirm="{{ $proclamatore->nome_completo }} è il sorvegliante del gruppo {{ $gruppiSorvegliati[$proclamatore->id] }}. Se non è più attivo il gruppo si scioglie: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e la sua utenza viene eliminata. Procedere?"
title="Disattiva" aria-label="Disattiva il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
</x-btn>
@else
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona <x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="toggleActive({{ $proclamatore->id }})" wire:click="toggleActive({{ $proclamatore->id }})"
title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}" title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}"
@@ -83,9 +98,10 @@
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
@endif @endif
</x-btn> </x-btn>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm" icona <x-btn variante="danger" tono="tenue" dimensione="sm" icona
wire:click="deleteProclamatore({{ $proclamatore->id }})" wire:click="deleteProclamatore({{ $proclamatore->id }})"
wire:confirm="Spostare il proclamatore nel cestino?" wire:confirm="{{ isset($gruppiSorvegliati[$proclamatore->id]) ? 'Spostare nel cestino ' . $proclamatore->nome_completo . '? È il sorvegliante del gruppo ' . $gruppiSorvegliati[$proclamatore->id] . ': il gruppo si scioglie, i territori non ancora dati tornano in reparto e la sua utenza viene eliminata.' : 'Spostare il proclamatore nel cestino?' }}"
title="Elimina" aria-label="Sposta nel cestino il proclamatore"> title="Elimina" aria-label="Sposta nel cestino il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</x-btn> </x-btn>

View File

@@ -6,6 +6,9 @@
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }} {{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
@if($proclamatore->gruppo_etichetta)
<span class="mt-1 inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">{{ $proclamatore->gruppo_etichetta }}</span>
@endif
</div> </div>
@can('settings.manage') @can('settings.manage')
<x-btn data-ritorno variante="secondary" :href="route('proclamatori.edit', $proclamatore)"> <x-btn data-ritorno variante="secondary" :href="route('proclamatori.edit', $proclamatore)">
@@ -36,56 +39,118 @@
</div> </div>
</div> </div>
{{-- Gruppo di servizio --}}
@if($gruppoSorvegliato || $gruppoMembro)
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" aria-labelledby="titolo-gruppo">
<h3 id="titolo-gruppo" class="text-lg font-semibold text-gray-900 mb-3">Gruppo di servizio</h3>
@if($gruppoSorvegliato)
<p class="text-sm text-gray-700">
<span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">Sorvegliante</span>
del gruppo
@can('settings.manage')
<a data-ritorno href="{{ route('gruppi.show', $gruppoSorvegliato) }}" class="font-semibold text-indigo-600 hover:underline">{{ $gruppoSorvegliato->nome }}</a>
@else
<strong>{{ $gruppoSorvegliato->nome }}</strong>
@endcan
dal {{ $gruppoSorvegliato->created_at->format('d/m/Y') }}
</p>
<dl class="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="rounded-lg bg-gray-50 px-3 py-2">
<dt class="text-xs text-gray-500">Membri</dt>
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['membri'] }}</dd>
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2">
<dt class="text-xs text-gray-500">Nel pacchetto</dt>
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['pacchetto'] }}</dd>
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2">
<dt class="text-xs text-gray-500">Da dare</dt>
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['da_dare'] }}</dd>
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2">
<dt class="text-xs text-gray-500">Assegnazioni dal gruppo</dt>
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['assegnazioni'] }}</dd>
</div>
</dl>
@if($utenza)
<p class="mt-4 border-t border-gray-100 pt-3 text-sm text-gray-600">
Utenza
@can('settings.manage') <code>{{ $utenza->email }}</code> @endcan
· {{ $utenza->password_change_required ? 'password temporanea, ancora da cambiare' : 'password personale impostata' }}
· {{ $ultimoAccesso ? 'ultimo accesso il ' . \Illuminate\Support\Carbon::parse($ultimoAccesso)->format('d/m/Y H:i') : 'non è ancora entrato' }}
</p>
@endif
@elseif($gruppoMembro)
<p class="text-sm text-gray-700">
Membro del gruppo
@can('settings.manage')
<a data-ritorno href="{{ route('gruppi.show', $gruppoMembro) }}" class="font-semibold text-indigo-600 hover:underline">{{ $gruppoMembro->nome }}</a>,
@else
<strong>{{ $gruppoMembro->nome }}</strong>,
@endcan
sorvegliante {{ $gruppoMembro->sorvegliante?->nome_completo ?? '—' }}.
</p>
@endif
</section>
@endif
{{-- Assignment history --}} {{-- Assignment history --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Storico Assegnazioni</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Storico Assegnazioni</h3>
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni) {{-- Come nella scheda del territorio: una tabella sola, colonne fisse,
<div class="mb-6"> l'anno come riga di separazione (tm-riga-gruppo), dal più recente. --}}
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4> @if($assegnazioniPerAnno->isEmpty())
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $assegnazione)
<tr>
<td data-label="Territorio" class="px-3 py-2">
<a data-ritorno href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">
{{ $assegnazione->territorio?->numero ?? 'N/A' }}
</a>
</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2">
@if($assegnazione->returned_at)
{{ $assegnazione->returned_at->format('d/m/Y') }}
@else
<span class="text-amber-600 font-medium">In corso</span>
@endif
</td>
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</td>
<td data-label="Campagna" class="px-3 py-2">
@if($assegnazione->counted_in_campaign)
<span class="text-green-600">{{ $assegnazione->campagna?->descrizione }}</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@empty
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p> <p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
@endforelse @else
<div class="overflow-x-auto">
<table class="min-w-full table-fixed text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="w-[36%] px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
<th class="w-[9%] px-3 py-2 text-right text-xs font-medium text-gray-500">Giorni</th>
<th class="w-[25%] px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
</tr>
</thead>
@foreach($assegnazioniPerAnno as $annoLabel => $assegnazioni)
<tbody class="divide-y divide-gray-100">
<tr class="tm-riga-gruppo">
<th colspan="5" scope="colgroup" class="px-3 pt-5 pb-2 text-left text-sm font-semibold text-indigo-600 bg-white">Anno Teocratico {{ $annoLabel }}</th>
</tr>
@foreach($assegnazioni as $assegnazione)
<tr>
<td data-label="Territorio" class="px-3 py-2">
<a data-ritorno href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $assegnazione->territorio?->numero ?? 'N/A' }}</a>@if($assegnazione->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo_nome }}</span>@endif
</td>
<td data-label="Assegnato" class="px-3 py-2 tabular-nums">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2 tabular-nums">
@if($assegnazione->returned_at)
{{ $assegnazione->returned_at->format('d/m/Y') }}
@else
<span class="text-amber-600 font-medium">In corso</span>
@endif
</td>
<td data-label="Giorni" class="px-3 py-2 text-right tabular-nums">{{ $assegnazione->giorni }}</td>
<td data-label="Campagna" class="px-3 py-2">
@if($assegnazione->counted_in_campaign)
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
@elseif($assegnazione->counted_in_campaign === false)
<span class="text-gray-400">No</span>
@else
<span class="text-gray-300">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
@endforeach
</table>
</div>
@endif
</div> </div>
</div> </div>

View File

@@ -1,8 +1,8 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('dashboard') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1"> <a href="{{ $gruppo ? route('mio-gruppo') : route('dashboard') }}" 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>
Torna alla home {{ $gruppo ? 'Torna al mio gruppo' : 'Torna alla home' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">Il mio profilo</h1> <h1 class="text-2xl font-bold text-gray-900">Il mio profilo</h1>
</div> </div>
@@ -25,10 +25,41 @@
</div> </div>
</div> </div>
{{-- Gruppo di servizio (solo per il sorvegliante) --}}
@if($gruppo)
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-6" aria-labelledby="titolo-mio-gruppo">
<h3 id="titolo-mio-gruppo" class="text-lg font-semibold text-gray-900 mb-1">Il tuo gruppo di servizio</h3>
<p class="text-sm text-gray-500 mb-4">
Sei il sorvegliante del gruppo <strong class="text-gray-800">{{ $gruppo->nome }}</strong> dal {{ $gruppo->created_at->format('d/m/Y') }}.
</p>
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="rounded-lg bg-gray-50 px-3 py-2">
<dt class="text-xs text-gray-500">Membri</dt>
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['membri'] }}</dd>
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2">
<dt class="text-xs text-gray-500">Nel pacchetto</dt>
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['pacchetto'] }}</dd>
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2">
<dt class="text-xs text-gray-500">Da dare</dt>
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['da_dare'] }}</dd>
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2">
<dt class="text-xs text-gray-500">Dati ai membri</dt>
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['dati'] }}</dd>
</div>
</dl>
<div class="mt-4">
<x-btn variante="primary" :href="route('mio-gruppo')">Vai al mio gruppo</x-btn>
</div>
</section>
@endif
{{-- Dati profilo --}} {{-- Dati profilo --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-1">Dati profilo</h3> <h3 class="text-lg font-semibold text-gray-900 mb-1">Dati profilo</h3>
<p class="text-sm text-gray-500 mb-4">Aggiorna nome e cognome ed email.</p> <p class="text-sm text-gray-500 mb-4">{{ $user->isSorvegliante() ? 'Aggiorna nome e cognome.' : 'Aggiorna nome e cognome ed email.' }}</p>
@if(session('profile_success')) @if(session('profile_success'))
<div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800"> <div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
@@ -42,11 +73,20 @@
<input wire:model="name" type="text" id="name" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm"> <input wire:model="name" type="text" id="name" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
@if($user->isSorvegliante())
{{-- Il sorvegliante entra con l'email fittizia creata col gruppo --}}
<div>
<p class="block text-sm font-medium text-gray-700">Email per entrare</p>
<p class="mt-1 text-sm"><code class="select-all">{{ $user->email }}</code></p>
<p class="mt-1 text-xs text-gray-500">L'ha creata l'applicazione insieme al gruppo: la usi per entrare, insieme alla password. Non riceve posta.</p>
</div>
@else
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label> <label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input wire:model="email" type="email" id="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm"> <input wire:model="email" type="email" id="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
@endif
<div class="flex justify-end"> <div class="flex justify-end">
<x-btn variante="primary" type="submit">Salva modifiche</x-btn> <x-btn variante="primary" type="submit">Salva modifiche</x-btn>
</div> </div>

View File

@@ -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_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700" title="Assegnato dal sorvegliante del gruppo di servizio">via gruppo {{ $a->gruppo_nome }}</span>@endif</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td> <td data-label="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)

View File

@@ -0,0 +1,74 @@
<div class="bg-white shadow-xl rounded-2xl p-6 sm:p-8 my-8" style="border-top:4px solid #6366f1">
{{-- Modulo pubblico (ModuloRichiesta): nessun dato già presente si vede
qui, tranne l'elenco dei territori (numero e zona) per scegliere
quello da riconsegnare. --}}
<div class="text-center mb-6">
<div class="mx-auto h-14 w-14 rounded-xl flex items-center justify-center text-white font-bold text-xl mb-4 shadow-lg" style="background:linear-gradient(135deg,#4338ca,#6366f1)">T2</div>
<h1 class="text-2xl font-bold text-gray-900">Richiesta territorio</h1>
<p class="text-gray-500 text-sm mt-1">{{ $congregazione }}</p>
</div>
@if($inviata)
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-800" role="status">
<p class="font-semibold">Richiesta inviata.</p>
<p class="mt-1">Il responsabile dei territori la vedrà e ti farà sapere.</p>
</div>
<div class="mt-5 text-center">
<x-btn variante="secondary" wire:click="nuova">Fai un'altra richiesta</x-btn>
</div>
@else
<form wire:submit="invia" class="space-y-5">
<fieldset>
<legend class="block text-sm font-medium text-gray-700 mb-2">Cosa ti serve? *</legend>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<label class="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-3 text-sm cursor-pointer has-[:checked]:border-indigo-400 has-[:checked]:bg-indigo-50">
<input type="radio" wire:model.live="tipo" value="{{ \App\Models\Richiesta::NUOVO }}" class="h-4 w-4 text-indigo-600">
Chiedo un territorio nuovo
</label>
<label class="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-3 text-sm cursor-pointer has-[:checked]:border-indigo-400 has-[:checked]:bg-indigo-50">
<input type="radio" wire:model.live="tipo" value="{{ \App\Models\Richiesta::RICONSEGNA }}" class="h-4 w-4 text-indigo-600">
Riconsegno un territorio
</label>
</div>
</fieldset>
<div>
<label for="nome" class="block text-sm font-medium text-gray-700">Nome e cognome *</label>
<input wire:model="nome" type="text" id="nome" autocomplete="name" maxlength="100"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5">
@error('nome') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@if($tipo === \App\Models\Richiesta::RICONSEGNA)
<div>
<label for="numero" class="block text-sm font-medium text-gray-700">Territorio che riconsegni *</label>
<x-scelta id="numero" wire:model="numero" :opzioni="$opzioniTerritori" segnaposto="Scrivi il numero o la zona" class="mt-1" />
@error('numero') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
<div>
<label for="note" class="block text-sm font-medium text-gray-700">
{{ $tipo === \App\Models\Richiesta::RICONSEGNA ? 'Note' : 'Esigenze particolari / note' }}
</label>
<textarea wire:model="note" id="note" rows="3" maxlength="1000"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5"></textarea>
@error('note') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Trappola per i programmi che riempiono ogni campo: nascosta a chi
guarda e ai lettori di schermo. --}}
<div class="tm-trappola" aria-hidden="true">
<label for="sito">Lascia vuoto questo campo</label>
<input wire:model="sito" type="text" id="sito" tabindex="-1" autocomplete="off">
</div>
<x-btn variante="primary" type="submit" class="w-full">Invia la richiesta</x-btn>
<p class="text-xs text-gray-500">
Nome e note servono solo a gestire questa richiesta: li vedono il responsabile dei territori e i suoi
assistenti, e vengono cancellati {{ \App\Console\Commands\PuliziaRichieste::GIORNI }} giorni dopo che la richiesta è stata gestita.
</p>
</form>
@endif
</div>

View File

@@ -57,6 +57,19 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Utenti esistenti</h2> <h2 class="text-lg font-semibold text-gray-900 mb-4">Utenti esistenti</h2>
@if($passwordTemporanea && $utentePassword)
<div class="mb-4 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
<p>Nuova password temporanea per <strong>{{ $utentePassword->name }}</strong>, da cambiare al primo accesso. Si vede <strong>solo ora</strong>:</p>
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt class="text-green-700">Email</dt><dd><code class="select-all">{{ $utentePassword->email }}</code></dd>
<dt class="text-green-700">Password</dt><dd><code class="select-all text-base">{{ $passwordTemporanea }}</code></dd>
</dl>
<div class="mt-3">
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiPassword">Ho annotato la password</x-btn>
</div>
</div>
@endif
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards"> <table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
@@ -129,6 +142,18 @@
</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;
da qui solo la password, se l'ha persa. --}}
<div class="flex flex-wrap items-center gap-2">
<x-btn variante="secondary" tono="tenue" dimensione="sm"
wire:click="nuovaPasswordSorvegliante({{ $user->id }})"
wire:confirm="Generare una nuova password temporanea per {{ $user->name }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
@if(isset($gruppiDeiSorveglianti[$user->proclamatore_id]))
<x-btn data-ritorno variante="ghost" dimensione="sm" :href="route('gruppi.show', $gruppiDeiSorveglianti[$user->proclamatore_id])">Apri il gruppo</x-btn>
@endif
</div>
@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 +170,7 @@
Elimina Elimina
</x-btn> </x-btn>
</div> </div>
@endif
</td> </td>
@endif @endif
</tr> </tr>

View File

@@ -46,6 +46,7 @@
<select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500"> <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">

View File

@@ -55,12 +55,13 @@
<span class="inline-flex items-center mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium <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>
@@ -160,50 +161,56 @@
<h3 class="text-lg font-semibold text-gray-900">Storico Assegnazioni</h3> <h3 class="text-lg font-semibold text-gray-900">Storico Assegnazioni</h3>
</div> </div>
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni) {{-- Una tabella sola, colonne a larghezza fissa: con una tabella per
<div class="mb-6"> anno ciascuna si dimensionava sul suo contenuto e le colonne non
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4> erano in linea. L'anno è una riga di separazione (tm-riga-gruppo).
<div class="overflow-x-auto"> Ordine: dal più recente, per anno e per data di assegnazione. --}}
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards"> @if($assegnazioniPerAnno->isEmpty())
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $assegnazione)
<tr>
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2">
@if($assegnazione->returned_at)
{{ $assegnazione->returned_at->format('d/m/Y') }}
@else
<span class="text-amber-600 font-medium">In corso</span>
@endif
</td>
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</td>
<td data-label="Campagna" class="px-3 py-2">
@if($assegnazione->counted_in_campaign)
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
@elseif($assegnazione->counted_in_campaign === false)
<span class="text-gray-400">No</span>
@else
<span class="text-gray-300">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@empty
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p> <p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
@endforelse @else
<div class="overflow-x-auto">
<table class="min-w-full table-fixed text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="w-[36%] px-3 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
<th class="w-[9%] px-3 py-2 text-right text-xs font-medium text-gray-500">Giorni</th>
<th class="w-[25%] px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
</tr>
</thead>
@foreach($assegnazioniPerAnno as $annoLabel => $assegnazioni)
<tbody class="divide-y divide-gray-100">
<tr class="tm-riga-gruppo">
<th colspan="5" scope="colgroup" class="px-3 pt-5 pb-2 text-left text-sm font-semibold text-indigo-600 bg-white">Anno Teocratico {{ $annoLabel }}</th>
</tr>
@foreach($assegnazioni as $assegnazione)
<tr>
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}@if($assegnazione->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo_nome }}</span>@endif</td>
<td data-label="Assegnato" class="px-3 py-2 tabular-nums">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2 tabular-nums">
@if($assegnazione->returned_at)
{{ $assegnazione->returned_at->format('d/m/Y') }}
@else
<span class="text-amber-600 font-medium">In corso</span>
@endif
</td>
<td data-label="Giorni" class="px-3 py-2 text-right tabular-nums">{{ $assegnazione->giorni }}</td>
<td data-label="Campagna" class="px-3 py-2">
@if($assegnazione->counted_in_campaign)
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
@elseif($assegnazione->counted_in_campaign === false)
<span class="text-gray-400">No</span>
@else
<span class="text-gray-300">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
@endforeach
</table>
</div>
@endif
</div> </div>
</div> </div>

View File

@@ -0,0 +1,7 @@
Ciao {!! $utente->name !!},
in allegato il modulo S-13 (registro di assegnazione dei territori) dell'anno di servizio {!! $anno->label !!}, aggiornato al {!! $data !!}.
Lo manda TerManager2 l'ultima domenica di ogni mese agli amministratori.
TerManager2

View File

@@ -44,9 +44,9 @@
<td style="font-weight:bold">{{ $t->numero }}</td> <td style="font-weight:bold">{{ $t->numero }}</td>
<td>{{ $t->zona?->nome ?? '-' }}</td> <td>{{ $t->zona?->nome ?? '-' }}</td>
<td>{{ $t->tipologia?->nome ?? '-' }}</td> <td>{{ $t->tipologia?->nome ?? '-' }}</td>
<td>{{ $t->home_giorni_giacenza }} gg</td> <td>{{ $t->giacenza_giorni }} gg</td>
<td> <td>
@if($t->home_is_prioritario) @if($t->in_priorita)
<span class="prioritario">★ Prioritario</span> <span class="prioritario">★ Prioritario</span>
@else @else
- -

View File

@@ -15,3 +15,15 @@ Schedule::command('audit:cleanup')->daily();
// mai completato o da un job interrotto. Vivono nello stesso volume dei PDF dei // mai completato o da un job interrotto. Vivono nello stesso volume dei PDF dei
// territori, quindi accumularli finisce per riempirlo. // territori, quindi accumularli finisce per riempirlo.
Schedule::command('backup:cleanup')->daily(); Schedule::command('backup:cleanup')->daily();
// Modulo S-13 per email agli amministratori, l'ultima domenica di ogni mese
// alle 8 (documento delle attività per la versione 2, deciso il 21/10/2026):
// una domenica è l'ultima del mese quando la settimana dopo è già nel mese
// seguente.
Schedule::command('registro:invia-s13')
->weeklyOn(0, '08:00')
->when(fn () => now()->addWeek()->month !== now()->month)
->withoutOverlapping();
// Richieste del modulo pubblico già gestite: nome e note non servono più.
Schedule::command('richieste:pulizia')->daily();

View File

@@ -23,7 +23,11 @@ use App\Livewire\Proclamatori\ProclamatoreShow;
use App\Livewire\Proclamatori\ProclamatoreEdit; 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\Cartacei;
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;
@@ -65,6 +69,12 @@ Route::get('assegnazioni/pdf/{assignment}/{code}', [AssignmentPdfController::cla
Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file']) Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file'])
->name('assignments.pdf.file'); ->name('assignments.pdf.file');
// Modulo pubblico delle richieste (territorio nuovo, riconsegna): senza login.
// Le difese anti-spam stanno nel componente (ModuloRichiesta).
Route::get('richiesta', App\Livewire\Richieste\ModuloRichiesta::class)
->middleware('throttle:60,1')
->name('richiesta');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Authenticated Routes | Authenticated Routes
@@ -162,11 +172,24 @@ Route::middleware(['auth', 'verified'])->group(function () {
// Assegnazioni // Assegnazioni
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::get('assegnazioni/cartacei', Cartacei::class)->name('assegnazioni.cartacei');
}); });
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');
Route::get('mio-gruppo/territori/{territorio}/miniatura', [TerritorioFileController::class, 'miniaturaGruppo'])
->name('mio-gruppo.miniatura');
});
// 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 +223,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');

View File

@@ -0,0 +1,836 @@
<?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, [$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', 'mrossi@termanager.it')->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');
// L'email non si chiede: è fittizia, iniziale del nome e cognome.
$pagina = Livewire::test(GruppiIndex::class)
->assertDontSee('Email per l')
->set('nome', 'Zisa')
->set('sorveglianteId', $rossi->id)
->call('crea')
->assertHasNoErrors()
->assertSee('mrossi@termanager.it');
$gruppo = GruppoServizio::sole();
$utente = User::where('email', 'mrossi@termanager.it')->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_l_email_fittizia_viene_da_iniziale_del_nome_e_cognome(): void
{
$email = fn (string $cognome, string $nome) => GruppiServizio::emailFittizia($this->proclamatore($cognome, $nome));
$this->assertSame('ffontana@termanager.it', $email('Fontana', 'Francesco'));
$this->assertSame('fdangelo@termanager.it', $email("D'Angelo", 'Francesco'));
$this->assertSame('ndeluca@termanager.it', $email('De Luca', 'Niccolò'));
// Già presa da un altro utente: un numero in coda.
User::create(['name' => 'Francesco Fontana', 'email' => 'ffontana@termanager.it', 'password' => 'password-di-prova']);
$this->assertSame('ffontana2@termanager.it', $email('Fontana', 'Federica'));
// Due gruppi con lo stesso nome no.
app(GruppiServizio::class)->crea('Zisa', $this->proclamatore('Gialli'));
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->crea('Zisa', $this->proclamatore('Blu'));
}
public function test_un_proclamatore_sta_in_un_solo_gruppo(): void
{
['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo();
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'));
$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());
}
public function test_dal_pannello_utenti_si_da_al_sorvegliante_una_nuova_password_temporanea(): void
{
['sorvegliante' => $s, 'gruppo' => $gruppo] = $this->gruppo();
$pagina = Livewire::test(UsersIndex::class)
->assertSee('Nuova password temporanea')
->assertSee(route('gruppi.show', $gruppo), false)
->call('nuovaPasswordSorvegliante', $s->id);
// Si vede una volta, ed è quella vera: temporanea, da cambiare.
$password = $pagina->get('passwordTemporanea');
$this->assertNotEmpty($password);
$pagina->assertSee($password)->assertSee('mrossi@termanager.it');
$s->refresh();
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $s->password));
$this->assertTrue($s->password_change_required);
$this->assertTrue($s->password_temporanea);
$this->assertSame(1, \Spatie\Activitylog\Models\Activity::where('description', 'password_reimpostata')->count());
$pagina->call('chiudiPassword')->assertDontSee($password);
// Solo per i sorveglianti: gli altri utenti hanno il loro modulo.
$pagina->call('nuovaPasswordSorvegliante', $this->admin->id)
->assertDispatched('avviso', fn ($nome, $parametri) => $parametri['tipo'] === 'errore');
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('password-di-prova', $this->admin->fresh()->password));
}
// ─── 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_al_primo_accesso_il_sorvegliante_cambia_la_password_temporanea(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
[$gruppo] = app(GruppiServizio::class)->crea('Zisa', $rossi);
$s = User::where('email', 'mrossi@termanager.it')->sole();
$this->assertTrue($s->password_temporanea);
$this->flushSession();
$this->actingAs($s);
$this->get(route('mio-gruppo'))->assertRedirect(route('password.change'));
$this->get(route('password.change'))->assertOk()
->assertSee('Password temporanea')
->assertDontSee('Ripristino da backup');
$this->post(route('password.change.store'), ['password' => 'una-password-mia-1', 'password_confirmation' => 'una-password-mia-1'])
->assertRedirect(route('dashboard'));
$this->assertFalse($s->fresh()->password_temporanea);
$this->assertFalse($s->fresh()->password_change_required);
$this->get(route('mio-gruppo'))->assertOk();
// "Nuova password temporanea" dalla pagina del gruppo: di nuovo temporanea.
$this->flushSession();
Livewire::actingAs($this->admin)->test(GruppoShow::class, ['gruppo' => $gruppo])->call('nuovaPassword');
$this->assertTrue($s->fresh()->password_temporanea);
$this->assertTrue($s->fresh()->password_change_required);
}
public function test_il_sorvegliante_entra_con_l_email_fittizia_senza_conferma_e_va_al_suo_gruppo(): void
{
$this->gruppo();
$this->flushSession();
auth()->logout();
$password = 'una-password-nuova-1';
User::where('email', 'mrossi@termanager.it')->sole()->forceFill(['password' => $password])->save();
$this->post(route('login.store'), ['email' => 'mrossi@termanager.it', '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);
$this->assertFalse($a->cartaceo);
}
public function test_il_sorvegliante_segna_la_cartolina_cartacea(): void
{
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
$this->flushSession();
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $territori[0]->id)
->set('membroId', $verdi->id)
->set('cartaceo', true)
->call('assegna')
->assertHasNoErrors()
->assertSet('cartaceo', false);
$this->assertTrue(Assegnazione::sole()->cartaceo);
}
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_il_sorvegliante_apre_l_anteprima_dal_numero_del_territorio(): void
{
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
$fuori = $this->territorio('88');
foreach ([$territori[0], $fuori] as $t) {
Storage::disk('public')->put("territori-thumbnails/t{$t->numero}.png", 'PNG');
$t->forceFill(['thumbnail_path' => "territori-thumbnails/t{$t->numero}.png"])->save();
}
$dodici = $territori[0]->fresh();
$this->flushSession();
// Il numero è un pulsante che porta miniatura e PDF del pacchetto.
$this->actingAs($s)->get(route('mio-gruppo'))->assertOk()
->assertSee('data-miniatura="' . $dodici->miniaturaGruppoUrl() . '"', false)
->assertSee('data-pdf="' . route('mio-gruppo.pdf', $dodici) . '"', false)
->assertSee('Apri la piantina (PDF)');
// La miniatura si apre solo per il proprio pacchetto, e solo da qui.
$this->actingAs($s)->get($dodici->miniaturaGruppoUrl())->assertOk();
$this->actingAs($s)->get(route('mio-gruppo.miniatura', $fuori))->assertForbidden();
$this->actingAs($s)->get($dodici->miniaturaUrl())->assertForbidden();
}
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();
// L'assegnazione la fa il sorvegliante, come nella realtà.
auth()->setUser($s);
$data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
auth()->setUser($this->admin);
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])
->call('sciogli')
->assertRedirect(route('gruppi.index'));
// Il gruppo non resta nello storico: non esiste più.
$this->assertNull(GruppoServizio::find($gruppo->id));
$this->get(route('gruppi.index'))->assertOk()->assertSee('Nessun gruppo di servizio');
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione
// normale, che del gruppo conserva solo il nome.
$this->assertNull($territori[1]->fresh()->gruppo_servizio_id);
$this->assertSame('in_reparto', $territori[2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at);
$this->assertSame('assegnato', $territori[0]->fresh()->stato);
$this->assertNull($data->fresh()->gruppo_servizio_id);
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
$this->get(route('territori.show', $territori[0]))->assertOk()->assertSee('via gruppo Zisa');
// Membri liberi; l'utenza del sorvegliante non c'è più, lui torna un
// proclamatore come gli altri.
$this->assertSame(0, Proclamatore::whereNotNull('gruppo_servizio_id')->count());
$this->assertNull(User::find($s->id));
$this->assertNotNull(Proclamatore::find($s->proclamatore_id));
$this->assertSame(0, \Illuminate\Support\Facades\DB::table('model_has_roles')->where('model_id', $s->id)->count());
// Quello che aveva fatto passa a chi ha sciolto il gruppo; nel registro
// le sue voci restano, senza dati personali.
$this->assertSame($this->admin->id, $data->fresh()->created_by);
$voce = \Spatie\Activitylog\Models\Activity::where('description', 'assigned')->sole();
$this->assertNull($voce->causer);
$this->assertSame('Sorvegliante del gruppo Zisa', $voce->properties['causer_name']);
$this->assertNull($voce->properties['causer_email']);
// Per ripartire serve un gruppo nuovo.
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$territori[1]->id]);
}
public function test_sciolto_il_gruppo_l_ex_sorvegliante_non_entra_piu(): 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' => 'mrossi@termanager.it', 'password' => $password])
->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo(): void
{
['gruppo' => $vecchio, 'rossi' => $rossi] = $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');
// Sciolto, il nome del gruppo e l'email sono di nuovo liberi.
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi->fresh());
$utente = User::where('proclamatore_id', $rossi->id)->sole();
$this->assertTrue($utente->hasRole('sorvegliante'));
$this->assertSame('mrossi@termanager.it', $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);
}
// ─── Sorvegliante non più attivo: il gruppo si scioglie ─────
/** Il gruppo è sciolto come da "Togli il ruolo": tutto tranne l'assegnazione già fatta. */
private function assertSciolto(array $g, Assegnazione $data): void
{
$this->assertNull(GruppoServizio::find($g['gruppo']->id), 'Gruppo sciolto ed eliminato');
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
$this->assertNull($g['territori'][1]->fresh()->gruppo_servizio_id, 'I non dati tornano in reparto');
$this->assertSame('in_reparto', $g['territori'][2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at, 'Quello già dato resta al membro');
$this->assertNull($g['bianchi']->fresh()->gruppo_servizio_id, 'Membri liberi');
$this->assertNull(User::find($g['sorvegliante']->id), 'Utenza eliminata');
}
public function test_disattivare_il_sorvegliante_dall_elenco_scioglie_il_gruppo(): void
{
$g = $this->gruppo();
$data = app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
->assertSeeHtml('è il sorvegliante del gruppo Zisa')
->call('toggleActive', $g['rossi']->id)
->assertDispatched('avviso', fn ($nome, $parametri) => str_contains($parametri['testo'], 'il gruppo è stato sciolto'));
$this->assertFalse($g['rossi']->fresh()->attivo);
$this->assertSciolto($g, $data);
// Riattivarlo non ricostruisce il gruppo: se ne crea uno nuovo.
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('toggleActive', $g['rossi']->id);
$this->assertSame(0, GruppoServizio::count());
$this->assertNull($g['rossi']->fresh()->gruppoSorvegliato());
}
public function test_disattivare_il_sorvegliante_dalla_modifica_scioglie_il_gruppo(): void
{
$g = $this->gruppo();
$data = app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
$this->get(route('proclamatori.edit', $g['rossi']))->assertOk()->assertSee('se non è più attivo il gruppo si scioglie');
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreEdit::class, ['proclamatore' => $g['rossi']])
->set('attivo', false)
->call('save')
->assertHasNoErrors();
$this->assertSciolto($g, $data);
$this->assertStringContainsString('il gruppo è stato sciolto', session('success'));
}
public function test_spostare_nel_cestino_il_sorvegliante_scioglie_il_gruppo(): void
{
$g = $this->gruppo();
$data = app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('deleteProclamatore', $g['rossi']->id);
$this->assertTrue($g['rossi']->fresh()->trashed());
$this->assertSciolto($g, $data);
}
public function test_disattivare_o_cestinare_un_membro_non_scioglie_il_gruppo(): void
{
['gruppo' => $gruppo, 'verdi' => $verdi, 'bianchi' => $bianchi] = $this->gruppo();
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
->call('toggleActive', $verdi->id)
->call('deleteProclamatore', $bianchi->id);
$this->assertNotNull(GruppoServizio::find($gruppo->id));
$this->assertSame($gruppo->id, $verdi->fresh()->gruppo_servizio_id, 'Non attivo resta membro, ma non riceve territori');
$this->assertNull($bianchi->fresh()->gruppo_servizio_id, 'Nel cestino lascia il gruppo');
}
// ─── Dettagli del gruppo nel profilo ────────────────────────
public function test_la_scheda_del_proclamatore_mostra_il_gruppo_che_guida(): void
{
$g = $this->gruppo();
app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
$this->get(route('proclamatori.show', $g['rossi']))->assertOk()
->assertSeeInOrder(['Gruppo di servizio', 'Sorvegliante', 'Zisa', 'Membri', '3', 'Nel pacchetto', '3', 'Da dare', '2', 'Assegnazioni dal gruppo', '1'])
->assertSee('<code>mrossi@termanager.it</code>', false)
->assertSee('non è ancora entrato');
$this->get(route('proclamatori.show', $g['verdi']))->assertOk()
->assertSee('Membro del gruppo')
->assertSee('sorvegliante Rossi Mario');
// Sciolto il gruppo, nella scheda non ne resta traccia.
app(GruppiServizio::class)->sciogli($g['gruppo']);
$this->get(route('proclamatori.show', $g['rossi']))->assertOk()
->assertDontSee('Assegnazioni dal gruppo')
->assertDontSee('In passato');
}
public function test_chi_fa_parte_di_un_gruppo_e_segnalato_in_elenco_scheda_e_scelta(): void
{
$g = $this->gruppo();
$this->proclamatore('Neri');
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
->assertSee('Sorvegliante del gruppo Zisa')
->assertSee('Gruppo Zisa')
->set('filtroStato', 'gruppo')
->assertSee('Verdi')->assertSee('Bianchi')->assertSee('Rossi')
->assertDontSee('Neri');
$this->get(route('proclamatori.show', $g['verdi']))->assertOk()->assertSee('Gruppo Zisa');
// Nella tendina di Assegna la nota accanto al nome...
$opzioni = collect(Livewire::test(Assegna::class)->viewData('opzioniProclamatori'))->keyBy('etichetta');
$this->assertSame('gruppo Zisa', $opzioni['Verdi Anna']['dettaglio']);
$this->assertSame('sorvegliante del gruppo Zisa', $opzioni['Rossi Mario']['dettaglio']);
$this->assertArrayNotHasKey('dettaglio', $opzioni['Neri Test']);
$this->assertArrayNotHasKey('motivo', $opzioni['Verdi Anna'], 'Si può comunque scegliere');
// ...che in "Il mio gruppo", dove sono tutti dello stesso gruppo, non serve.
$this->flushSession();
$membri = Livewire::actingAs($g['sorvegliante'])->test(MioGruppo::class)->viewData('opzioniMembri');
$this->assertSame([], collect($membri)->pluck('dettaglio')->filter()->all());
}
public function test_il_profilo_del_sorvegliante_mostra_il_suo_gruppo(): void
{
$g = $this->gruppo();
app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
$this->flushSession();
$this->actingAs($g['sorvegliante'])->get(route('profile'))->assertOk()
->assertSee('Il tuo gruppo di servizio')
->assertSeeInOrder(['Zisa', 'Membri', '3', 'Nel pacchetto', '3', 'Da dare', '2', 'Dati ai membri', '1'])
->assertSee('Torna al mio gruppo');
// L'amministratore un gruppo suo non ce l'ha (sessione nuova: è un
// altro utente).
$this->flushSession();
$this->actingAs($this->admin)->get(route('profile'))->assertOk()
->assertDontSee('Il tuo gruppo di servizio')
->assertSee('Torna alla home');
}
// ─── Proclamatori: anonimizzazione, registro ────────────────
public function test_anonimizzare_il_sorvegliante_scioglie_il_gruppo_e_ne_elimina_l_utenza(): void
{
['gruppo' => $gruppo, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id);
$this->assertNull(GruppoServizio::find($gruppo->id));
$this->assertNull(User::find($s->id), "Nome ed email dell'utenza spariscono con lei");
$this->assertSame('Anonimo', Proclamatore::withTrashed()->find($rossi->id)->nome);
}
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);
}
}
// ─── Le azioni del sorvegliante passano dal middleware ──────
public function test_le_azioni_livewire_del_sorvegliante_non_finiscono_in_403(): void
{
// Livewire::test() non passa dai middleware delle rotte: qui si chiama
// la rotta vera delle azioni (in Livewire 3.7 "default.livewire.update",
// che il filtro 'livewire.*' non riconosceva: ogni clic era un 403).
['sorvegliante' => $s] = $this->gruppo();
$this->flushSession();
$rotta = collect(app('router')->getRoutes()->getRoutes())
->first(fn ($r) => str_ends_with($r->uri(), 'livewire/update'));
$this->assertNotNull($rotta);
$this->withoutExceptionHandling();
try {
$this->actingAs($s)->post('/' . ltrim($rotta->uri(), '/'), ['components' => []], ['X-Livewire' => 'true']);
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
$this->assertNotSame(403, $e->getStatusCode(), $e->getMessage());
} catch (\Throwable) {
// Livewire rifiuta la richiesta vuota: non è il 403 del sorvegliante.
}
$this->addToAssertionCount(1);
}
// ─── Priorità e filtri ──────────────────────────────────────
/** Territori in reparto con giacenze diverse: 40 (200 gg), 41 (50 gg), 42 (spunta prioritario), 43 (nuovo). */
private function territoriConPriorita(): array
{
// Soglia dei prioritari a 180 giorni, e senza l'istanza delle
// impostazioni rimasta in memoria da un altro test.
Setting::query()->update(['giorni_giacenza_prioritari' => 180]);
Setting::flushCache();
$zona = \App\Models\Zona::create(['nome' => 'Noce', 'attivo' => true]);
$altra = \App\Models\Zona::create(['nome' => 'Zisa', 'attivo' => true]);
$t = [];
foreach (['40' => 200, '41' => 50, '42' => 0, '43' => 0] as $numero => $giorni) {
$numero = (string) $numero; // le chiavi numeriche diventano interi
$t[$numero] = $this->territorio($numero);
$t[$numero]->forceFill([
'created_at' => now()->subDays($giorni),
'prioritario' => $numero === '42',
'zona_id' => $numero === '43' ? $altra->id : $zona->id,
])->save();
}
return $t;
}
public function test_aggiungendo_al_pacchetto_i_territori_sono_in_ordine_di_priorita_e_filtrabili(): void
{
['gruppo' => $gruppo] = $this->gruppo();
$t = $this->territoriConPriorita();
$pagina = Livewire::test(GruppoShow::class, ['gruppo' => $gruppo]);
// Prima i prioritari (per giacenza), poi gli altri: la regola della Home.
$this->assertSame(['40', '42', '41', '43'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
$pagina->assertSee('★ Prioritario')->assertSee('fermo da 200 gg');
$pagina->set('soloPrioritari', true);
$this->assertSame(['40', '42'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
$pagina->set('soloPrioritari', false)->set('filtroZona', (string) $t['43']->zona_id);
$this->assertSame(['43'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
$pagina->set('filtroZona', '')->set('cercaTerritorio', '41');
$this->assertSame(['41'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
// "Seleziona tutti quelli mostrati" e aggiunta in blocco.
$pagina->set('cercaTerritorio', '')->set('soloPrioritari', true)
->call('selezionaMostrati')
->call('aggiungiTerritori');
$this->assertSame($gruppo->id, $t['40']->fresh()->gruppo_servizio_id);
$this->assertSame($gruppo->id, $t['42']->fresh()->gruppo_servizio_id);
$this->assertNull($t['41']->fresh()->gruppo_servizio_id);
}
public function test_il_sorvegliante_vede_il_pacchetto_in_ordine_di_priorita_e_lo_filtra(): void
{
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $vecchi] = $this->gruppo();
$t = $this->territoriConPriorita();
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, collect($t)->pluck('id')->all());
// Il pacchetto di gruppo() (12, 14, 35) va via, tranne il 12 che si dà.
app(GruppiServizio::class)->assegna($gruppo, $vecchi[0], $verdi, now());
app(GruppiServizio::class)->rimandaInReparto($gruppo, $vecchi[1]->fresh());
app(GruppiServizio::class)->rimandaInReparto($gruppo, $vecchi[2]->fresh());
$this->flushSession();
$pagina = Livewire::actingAs($s)->test(MioGruppo::class)
->assertSee('Sorvegliante: <strong>Mario Rossi</strong>', false);
// Da dare in ordine di priorità, poi quelli già dati.
$this->assertSame(['40', '42', '41', '43', '12'], $pagina->viewData('territori')->pluck('numero')->all());
$pagina->set('filtroStato', 'da_dare');
$this->assertSame(['40', '42', '41', '43'], $pagina->viewData('territori')->pluck('numero')->all());
$pagina->set('filtroStato', 'dati');
$this->assertSame(['12'], $pagina->viewData('territori')->pluck('numero')->all());
$pagina->set('filtroStato', '')->set('soloPrioritari', true);
$this->assertSame(['40', '42'], $pagina->viewData('territori')->pluck('numero')->all());
$pagina->set('soloPrioritari', false)->set('filtroZona', (string) $t['43']->zona_id);
$this->assertSame(['43'], $pagina->viewData('territori')->pluck('numero')->all());
}
// ─── 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(), cartaceo: true);
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$this->assertTrue(Assegnazione::sole()->cartaceo, 'Il segno del cartaceo passa per l\'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('Zisa', Assegnazione::sole()->gruppo_nome);
$utenza = User::where('email', 'mrossi@termanager.it')->sole();
$this->assertTrue($utenza->hasRole('sorvegliante'));
$this->assertSame($g->sorvegliante_id, $utenza->proclamatore_id);
}
public function test_l_xml_conserva_il_nome_del_gruppo_sulle_assegnazioni_dopo_lo_scioglimento(): void
{
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
app(GruppiServizio::class)->sciogli($gruppo);
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$this->assertSame(0, GruppoServizio::count());
$this->assertNull(Assegnazione::sole()->gruppo_servizio_id);
$this->assertSame('Zisa', Assegnazione::sole()->gruppo_nome);
// Dell'ex sorvegliante non resta un'utenza: solo l'amministratore.
$this->assertSame([$this->admin->id], User::pluck('id')->all());
}
}

View File

@@ -260,4 +260,30 @@ class InvioLinkTest extends TestCase
->get(route('territori.index')) ->get(route('territori.index'))
->assertSee(route('piantine.editor', ['nuovo' => 1]), false); ->assertSee(route('piantine.editor', ['nuovo' => 1]), false);
} }
public function test_da_assegna_della_home_l_elenco_dei_territori_assegnati_non_c_e(): void
{
$t = $this->territorioConPdf('7');
$daHome = route('assegnazioni.assegna', ['territorioId' => $t->id, 'dalla_home' => 1]);
// "Assegna →" della Home porta il segnale...
$this->actingAs($this->admin)->get(route('dashboard'))->assertOk()->assertSee($daHome, false);
// ...e Assegna non mostra (né carica) l'elenco dei territori assegnati.
$this->get($daHome)->assertOk()->assertDontSee('assegnazioni.elenco-assegnati', false);
// Aperta dal menu o dalla scheda del territorio, l'elenco c'è.
$this->get(route('assegnazioni.assegna'))->assertSee('assegnazioni.elenco-assegnati', false);
$this->get(route('assegnazioni.assegna', ['territorioId' => $t->id]))->assertSee('assegnazioni.elenco-assegnati', false);
}
public function test_dall_elenco_degli_assegnati_si_va_al_rientro(): void
{
$this->assegna($this->mario(), $this->territorioConPdf('7'));
$a = Assegnazione::sole();
Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class)
->assertSee('Rientro')
// Relativo: l'elenco ha un peso massimo per riga (AssegnaPrestazioniTest).
->assertSee('href="' . route('assegnazioni.rientra', $a, false) . '"', false);
}
} }

View File

@@ -0,0 +1,428 @@
<?php
namespace Tests\Feature;
use App\Console\Commands\PuliziaRichieste;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\Rientra;
use App\Livewire\Home;
use App\Livewire\Richieste\ModuloRichiesta;
use App\Mail\ModuloS13Mail;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use App\Models\Zona;
use App\Notifications\RichiestaRicevuta;
use App\Services\RichiesteServizio;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
/**
* Dal documento delle attività per la versione 2 (21 ottobre 2026):
* - il modulo pubblico di richiesta (nuovo territorio o riconsegna), che
* amministratore e assistenti confermano dalla Home portandolo sui moduli
* di assegnazione e di rientro già esistenti;
* - il segno "cartaceo" sull'assegnazione e l'elenco dei cartacei fuori;
* - il modulo S-13 per email agli amministratori l'ultima domenica del mese.
*/
class RichiesteTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$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->travelTo(Carbon::parse('2026-10-21 10:00'));
$this->admin = $this->utente('Admin', 'admin@example.test', 'amministratore');
RateLimiter::clear('richiesta-pubblica:' . sha1('127.0.0.1'));
}
private function utente(string $nome, string $email, string $ruolo): User
{
$utente = User::create(['name' => $nome, 'email' => $email, 'password' => 'password-di-prova']);
$utente->assignRole($ruolo);
return $utente;
}
private function proclamatore(string $cognome, string $nome): Proclamatore
{
return Proclamatore::create(['nome' => $nome, 'cognome' => $cognome, 'attivo' => true]);
}
private function territorio(string $numero): Territorio
{
return Territorio::create(['numero' => $numero, 'attivo' => true]);
}
private function assegna(Territorio $t, Proclamatore $p, string $dal, bool $cartaceo = false): Assegnazione
{
return Assegnazione::create([
'territorio_id' => $t->id,
'proclamatore_id' => $p->id,
'anno_teocratico_id' => AnnoTeocratico::perData(Carbon::parse($dal))->id,
'assigned_at' => $dal,
'created_by' => $this->admin->id,
'cartaceo' => $cartaceo,
]);
}
/** Il modulo compilato da una persona: aperto, qualche secondo, inviato. */
private function compila(array $campi): \Livewire\Features\SupportTesting\Testable
{
$modulo = Livewire::test(ModuloRichiesta::class);
foreach ($campi as $campo => $valore) {
$modulo->set($campo, $valore);
}
$this->travel(ModuloRichiesta::SECONDI_MINIMI + 2)->seconds();
return $modulo->call('invia');
}
private function postaFunzionante(): void
{
Setting::instance()->update(['mail_host' => 'smtp.esempio.it']);
Setting::flushCache();
}
// ─── Il modulo pubblico ─────────────────────────────────────
public function test_il_modulo_si_apre_senza_login_e_non_mostra_dati(): void
{
$this->proclamatore('Rossi', 'Mario');
auth()->logout();
$this->get(route('richiesta'))
->assertOk()
->assertSee('Invia la richiesta')
->assertDontSee('Rossi');
}
public function test_una_richiesta_si_salva_cifrata_e_avvisa_chi_la_gestisce(): void
{
Notification::fake();
$this->postaFunzionante();
$assistente = $this->utente('Assistente', 'assistente@example.test', 'assistente');
$operatore = $this->utente('Operatore', 'operatore@example.test', 'operatore');
$nonConfermato = $this->utente('Altro', 'altro@example.test', 'amministratore');
$nonConfermato->forceFill(['email_verified_at' => null])->save();
auth()->logout();
$this->compila(['tipo' => Richiesta::NUOVO, 'nome' => 'Mario Rossi', 'note' => 'Vicino a casa'])
->assertHasNoErrors()
->assertSet('inviata', true);
$r = Richiesta::sole();
$this->assertSame('Mario Rossi', $r->nome);
$this->assertSame(Richiesta::APERTA, $r->stato);
$this->assertNull($r->numero_territorio);
$grezzo = DB::table('richieste')->value('nome');
$this->assertStringNotContainsString('Rossi', $grezzo, 'Il nome nel database è cifrato');
$this->assertStringNotContainsString('Rossi', Activity::all()->toJson(), 'Il nome non va nel registro');
Notification::assertSentTo([$this->admin, $assistente], RichiestaRicevuta::class);
Notification::assertNotSentTo([$operatore, $nonConfermato], RichiestaRicevuta::class);
}
public function test_senza_posta_la_richiesta_resta_comunque(): void
{
Notification::fake();
$this->territorio('12');
auth()->logout();
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Mario Rossi', 'numero' => '12'])->assertHasNoErrors();
$this->assertSame('12', Richiesta::sole()->numero_territorio);
Notification::assertNothingSent();
}
public function test_la_riconsegna_vuole_il_numero_del_territorio(): void
{
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Mario Rossi'])->assertHasErrors('numero');
$this->compila(['tipo' => Richiesta::NUOVO, 'nome' => 'Ma'])->assertHasErrors('nome');
$this->assertSame(0, Richiesta::count());
}
public function test_nella_riconsegna_il_territorio_si_sceglie_dall_elenco(): void
{
$zona = Zona::create(['nome' => 'Centro storico']);
Territorio::create(['numero' => '12', 'zona_id' => $zona->id, 'attivo' => true]);
Territorio::create(['numero' => '3B', 'attivo' => true]);
$fuori = Territorio::create(['numero' => '40', 'attivo' => true]);
$this->assegna($fuori, $this->proclamatore('Verdi', 'Anna'), '2026-10-01');
$fuori->update(['attivo' => false]);
Territorio::create(['numero' => '77', 'attivo' => false]);
auth()->logout();
// L'elenco c'è solo nella riconsegna: numero e zona, nient'altro.
$modulo = Livewire::test(ModuloRichiesta::class)
->assertViewHas('opzioniTerritori', [])
->set('tipo', Richiesta::RICONSEGNA)
->assertSee('Centro storico')
->assertDontSee('Verdi')
->assertDontSee('assegnat');
$this->assertSame(['3B', '12', '40'], array_column($modulo->viewData('opzioniTerritori'), 'valore'),
'In ordine di numero; c\'è anche quello disattivato ma ancora fuori, non quello disattivato e rientrato');
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Anna Verdi', 'numero' => '99'])->assertHasErrors('numero');
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Anna Verdi', 'numero' => '77'])->assertHasErrors('numero');
$this->assertSame(0, Richiesta::count());
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Anna Verdi', 'numero' => '3b'])->assertHasNoErrors();
$this->assertSame('3b', Richiesta::sole()->numero_territorio);
}
public function test_lo_spam_non_si_salva_e_non_se_ne_accorge(): void
{
// Il campo trappola riempito.
$this->compila(['nome' => 'Spam Bot', 'sito' => 'https://spam.example'])->assertSet('inviata', true);
// Inviato subito dopo l'apertura.
Livewire::test(ModuloRichiesta::class)->set('nome', 'Spam Bot')->call('invia')->assertSet('inviata', true);
$this->assertSame(0, Richiesta::count());
}
public function test_al_massimo_cinque_richieste_all_ora_dallo_stesso_indirizzo(): void
{
for ($i = 1; $i <= ModuloRichiesta::MASSIMO_ORA; $i++) {
$this->compila(['nome' => "Mario Rossi {$i}"])->assertHasNoErrors();
}
$this->compila(['nome' => 'Mario Rossi 6'])->assertHasErrors('nome');
$this->assertSame(ModuloRichiesta::MASSIMO_ORA, Richiesta::count());
}
// ─── I nomi simili ──────────────────────────────────────────
public function test_i_nomi_simili_trovano_il_proclamatore_anche_scritto_male(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$rossini = $this->proclamatore('Rossini', 'Maria');
$this->proclamatore('Bianchi', 'Luca');
$servizio = app(RichiesteServizio::class);
$this->assertSame([$rossi->id, $rossini->id], $servizio->nomiSimili('mario rossi')->pluck('id')->all());
$this->assertSame($rossi->id, $servizio->nomiSimili('Rosi Mario')->first()?->id, 'Una lettera in meno');
$this->assertSame($rossi->id, $servizio->nomiSimili('Màrio Rossi')->first()?->id, 'Accenti');
$this->assertTrue($servizio->nomiSimili('Giuseppe Verdi')->isEmpty());
}
// ─── La Home ────────────────────────────────────────────────
public function test_la_home_mostra_le_richieste_con_i_nomi_simili_e_i_collegamenti(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$nuova = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rosi', null, 'Anche cartaceo');
$t = $this->territorio('12');
$a = $this->assegna($t, $this->proclamatore('Verdi', 'Anna'), '2026-10-01');
$riconsegna = app(RichiesteServizio::class)->registra(Richiesta::RICONSEGNA, 'Anna Verdi', '12', null);
app(RichiesteServizio::class)->registra(Richiesta::RICONSEGNA, 'Luca Neri', '99', null);
Livewire::actingAs($this->admin)->test(Home::class)
->assertSee('Richieste (3)')
->assertSee('Mario Rosi')
->assertSee('Anche cartaceo')
->assertSee(route('assegnazioni.assegna', ['proclamatore' => $rossi->id, 'richiesta' => $nuova->id, 'dalla_home' => 1]))
->assertSee('Nessuno di questi: apri il modulo')
->assertSee(route('assegnazioni.rientra', ['assegnazione' => $a->id, 'richiesta' => $riconsegna->id]))
->assertSee('Il territorio N° 99 non risulta assegnato.');
}
public function test_solo_chi_ha_il_permesso_vede_e_scarta_le_richieste(): void
{
$r = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rossi', null, null);
$operatore = $this->utente('Operatore', 'operatore@example.test', 'operatore');
Livewire::actingAs($operatore)->test(Home::class)
->assertDontSee('Mario Rossi')
->call('scartaRichiesta', $r->id)
->assertForbidden();
$this->assertTrue($r->fresh()->isAperta());
$assistente = $this->utente('Assistente', 'assistente@example.test', 'assistente');
Livewire::actingAs($assistente)->test(Home::class)
->assertSee('Mario Rossi')
->call('scartaRichiesta', $r->id)
->assertDontSee('Mario Rossi');
$r->refresh();
$this->assertSame(Richiesta::SCARTATA, $r->stato);
$this->assertSame($assistente->id, $r->gestita_da);
}
// ─── Dalla richiesta ai moduli esistenti ────────────────────
public function test_dalla_richiesta_il_modulo_di_assegnazione_e_precompilato_e_la_chiude(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$t = $this->territorio('7');
$r = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rossi', null, null);
Livewire::withQueryParams(['proclamatore' => $rossi->id, 'richiesta' => $r->id, 'dalla_home' => 1])
->actingAs($this->admin)
->test(Assegna::class)
->assertSet('proclamatore_id', $rossi->id)
->assertSet('richiestaId', $r->id)
->assertSee('Mario Rossi')
->set('territorio_id', $t->id)
->set('cartaceo', true)
->call('save')
->assertHasNoErrors();
$a = Assegnazione::sole();
$this->assertTrue($a->cartaceo);
$r->refresh();
$this->assertSame(Richiesta::EVASA, $r->stato);
$this->assertSame($a->id, $r->assegnazione_id);
$this->assertSame($this->admin->id, $r->gestita_da);
}
public function test_senza_permesso_o_con_una_richiesta_chiusa_il_modulo_la_ignora(): void
{
$r = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rossi', null, null);
$operatore = $this->utente('Operatore', 'operatore@example.test', 'operatore');
Livewire::withQueryParams(['richiesta' => $r->id])->actingAs($operatore)
->test(Assegna::class)->assertSet('richiestaId', null);
$r->scarta($this->admin);
Livewire::withQueryParams(['richiesta' => $r->id])->actingAs($this->admin)
->test(Assegna::class)->assertSet('richiestaId', null);
}
public function test_dalla_riconsegna_il_rientro_la_chiude(): void
{
$t = $this->territorio('12');
$a = $this->assegna($t, $this->proclamatore('Verdi', 'Anna'), '2026-10-01');
$r = app(RichiesteServizio::class)->registra(Richiesta::RICONSEGNA, 'Anna Verdi', '12', null);
Livewire::withQueryParams(['richiesta' => $r->id])->actingAs($this->admin)
->test(Rientra::class, ['assegnazione' => $a])
->assertSee('Riconsegna annunciata da')
->call('save')
->assertHasNoErrors();
$this->assertNotNull($a->fresh()->returned_at);
$r->refresh();
$this->assertSame(Richiesta::EVASA, $r->stato);
$this->assertSame($a->id, $r->assegnazione_id);
}
public function test_le_richieste_gestite_si_cancellano_dopo_novanta_giorni(): void
{
$servizio = app(RichiesteServizio::class);
$vecchia = $servizio->registra(Richiesta::NUOVO, 'Vecchia Gestita', null, null);
$vecchia->scarta($this->admin);
$aperta = $servizio->registra(Richiesta::NUOVO, 'Vecchia Aperta', null, null);
$this->travel(PuliziaRichieste::GIORNI - 1)->days();
$recente = $servizio->registra(Richiesta::NUOVO, 'Recente', null, null);
$recente->scarta($this->admin);
$this->travel(2)->days();
$this->artisan('richieste:pulizia')->assertSuccessful();
$this->assertModelMissing($vecchia);
$this->assertModelExists($aperta);
$this->assertModelExists($recente);
}
// ─── Cartacei ───────────────────────────────────────────────
public function test_l_elenco_dei_cartacei_mostra_quelli_fuori_e_a_chi(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$this->assegna($this->territorio('21'), $rossi, '2026-10-01', cartaceo: true);
$this->assegna($this->territorio('22'), $rossi, '2026-10-01');
$rientrato = $this->assegna($this->territorio('23'), $rossi, '2026-09-01', cartaceo: true);
$rientrato->update(['returned_at' => '2026-10-01']);
$this->actingAs($this->admin)->get(route('assegnazioni.cartacei'))
->assertOk()
->assertSee('N° 21')
->assertSee('Rossi Mario')
->assertDontSee('N° 22')
->assertDontSee('N° 23');
// Nell'elenco degli assegnati, il segno solo dove serve.
$html = $this->actingAs($this->admin)->get(route('assegnazioni.assegna'))->getContent();
$this->assertStringContainsString('assegnazioni.elenco-assegnati', $html);
Livewire::withoutLazyLoading()->actingAs($this->admin)->test(\App\Livewire\Assegnazioni\ElencoAssegnati::class)
->assertSeeHtml('<span class="tm-cartaceo">cartaceo</span>');
// Cambio di utente nello stesso test: la sessione dell'admin va
// svuotata, o la protezione della sessione lo manda al login (302).
$this->flushSession();
$sorvegliante = $this->utente('Sorvegliante', 'sorvegliante@example.test', 'sorvegliante');
$this->actingAs($sorvegliante)->get(route('assegnazioni.cartacei'))->assertForbidden();
}
// ─── Modulo S-13 per email ──────────────────────────────────
public function test_l_s13_va_agli_amministratori_con_l_email_confermata(): void
{
Mail::fake();
$this->postaFunzionante();
$assistente = $this->utente('Assistente', 'assistente@example.test', 'assistente');
$nonConfermato = $this->utente('Altro', 'altro@example.test', 'amministratore');
$nonConfermato->forceFill(['email_verified_at' => null])->save();
$this->artisan('registro:invia-s13')->assertSuccessful();
Mail::assertSent(ModuloS13Mail::class, fn (ModuloS13Mail $m) => $m->hasTo('admin@example.test')
&& collect($m->attachments())->contains(fn ($a) => $a->as === 'S-13-registro-assegnazioni-2026-2027.pdf' && $a->mime === 'application/pdf'));
Mail::assertSent(ModuloS13Mail::class, 1);
Mail::assertNotSent(ModuloS13Mail::class, fn (ModuloS13Mail $m) => $m->hasTo($assistente->email) || $m->hasTo($nonConfermato->email));
$this->assertSame(1, Activity::where('description', 's13_inviato')->sole()->properties['destinatari']);
}
public function test_senza_posta_l_s13_non_parte(): void
{
Mail::fake();
$this->artisan('registro:invia-s13')->assertSuccessful();
Mail::assertNothingSent();
}
public function test_l_s13_parte_solo_l_ultima_domenica_del_mese(): void
{
$evento = collect(app(Schedule::class)->events())
->first(fn ($e) => str_contains((string) $e->command, 'registro:invia-s13'));
$this->assertNotNull($evento);
$parte = function (string $quando) use ($evento) {
$this->travelTo(Carbon::parse($quando, config('app.timezone')));
return $evento->isDue($this->app) && $evento->filtersPass($this->app);
};
$this->assertTrue($parte('2026-10-25 08:00'), 'Ultima domenica di ottobre');
$this->assertFalse($parte('2026-10-18 08:00'), 'Penultima domenica');
$this->assertFalse($parte('2026-10-24 08:00'), 'Sabato');
$this->assertTrue($parte('2026-11-29 08:00'), 'Ultima domenica di novembre');
$this->assertTrue($parte('2027-01-31 08:00'), 'Domenica 31');
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Tests\Feature;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Lo storico delle assegnazioni nelle schede di territorio e proclamatore
* (20 ottobre 2026): una tabella sola con le colonne in linea, l'anno come
* riga di separazione, dal più recente.
*/
class StoricoAssegnazioniTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Territorio $territorio;
private Proclamatore $proclamatore;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
$this->territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
$this->proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
$vecchio = AnnoTeocratico::create(['label' => '2024-2025', 'start_date' => '2024-09-01', 'end_date' => '2025-08-31']);
$recente = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
// Create in disordine: l'ordine lo deve dare la pagina.
foreach ([[$vecchio, '2024-10-01', '2024-11-01'], [$recente, '2025-10-01', '2025-11-15'], [$vecchio, '2025-03-01', '2025-04-01']] as [$anno, $dal, $al]) {
Assegnazione::create([
'territorio_id' => $this->territorio->id, 'proclamatore_id' => $this->proclamatore->id,
'anno_teocratico_id' => $anno->id, 'assigned_at' => $dal, 'returned_at' => $al, 'created_by' => $this->admin->id,
]);
}
}
public function test_nella_scheda_del_territorio_una_tabella_sola_dal_piu_recente(): void
{
$html = $this->actingAs($this->admin)->get(route('territori.show', $this->territorio))->assertOk()
->assertSeeInOrder(['Anno Teocratico 2025-2026', '01/10/2025', 'Anno Teocratico 2024-2025', '01/03/2025', '01/10/2024'])
->getContent();
$this->assertSame(1, substr_count($html, '>Proclamatore</th>'), "Un'intestazione sola: colonne in linea");
$this->assertSame(2, substr_count($html, 'tm-riga-gruppo'));
}
public function test_nella_scheda_del_proclamatore_una_tabella_sola_dal_piu_recente(): void
{
$html = $this->actingAs($this->admin)->get(route('proclamatori.show', $this->proclamatore))->assertOk()
->assertSeeInOrder(['Anno Teocratico 2025-2026', '01/10/2025', 'Anno Teocratico 2024-2025', '01/03/2025', '01/10/2024'])
->getContent();
$this->assertSame(1, substr_count($html, '>Territorio</th>'), "Un'intestazione sola: colonne in linea");
$this->assertSame(2, substr_count($html, 'tm-riga-gruppo'));
}
}