Compare commits

...

31 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
76 changed files with 4094 additions and 786 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

View File

@@ -4,204 +4,380 @@ 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: **19 ottobre 2026** (gruppi di servizio, solo su dev) — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui). Ultimo aggiornamento: **21 ottobre 2026** — si riprende da [Stato attuale](#stato-attuale).
--- ---
## Stato attuale ## Stato attuale
L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è **✅ 21 ottobre 2026 — PHP 8.3.36 su produzione e demo** (CVE-2026-109618/109619,
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti, esecuzione di codice da remoto), con la mitigazione nel `php.ini`. Vedi
redeploy, login, tutte le pagine, code e scheduler. [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 | | Aspetto | Stato |
|-----------------------------|-------| |-----------------------------|-------|
| Deploy Dokploy (Compose) | ✅ verificato | | 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 | | Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
| Redeploy senza interventi | ✅ ~25 secondi | | Redeploy senza interventi | ✅ ~25 secondi |
| Sviluppo locale | ✅ stesso compose, profilo `dev` | | Sviluppo locale | ✅ stesso compose, profilo `dev` |
| Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale | | 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) | | 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 | | 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) |
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto: La storia precedente (luglio-agosto: backup e ripristino, sotto-app Piantine,
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre sistema visivo, prima suite di test) è in [Da dove veniamo](#da-dove-veniamo).
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.
--- ---
## Lavori in corso — 12 ottobre 2026 (riprendere da qui) ## Lavori recenti — ottobre 2026
Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo): Dal più recente. Le sezioni del 12-19 ottobre sono in produzione; quelle del
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in 20-21 ottobre e i gruppi di servizio solo sulla demo.
`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); le ultime due righe
della tabella sono nel commit successivo.
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di ### Richieste dal modulo pubblico, cartacei, S-13 per email (21 ottobre 2026, solo su dev)
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026) Dal documento `_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx`
(attività per la versione 2): analizzato con l'utente, che ha deciso così.
Richieste dell'utente, con le sue scelte: - **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).
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta ### Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026)
`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**, cifrati come nome e cognome,
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
considera italiano).
- **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, 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: Due esecuzioni di codice da remoto nel core di PHP (CVSS v4 9.2), patch alle
email di prova e di conferma arrivate, link aperto senza login, account 14:00 UTC del 21/10/2026; versione corretta per il nostro ramo: **8.3.36**.
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione Il progetto girava su 8.3.35.
e dati di prova rimossi dopo.
### Gruppi di servizio (19 ottobre 2026, solo su dev) - `docker/php/Dockerfile`: `ARG PHP_VERSION=8.3.36` e `FROM
php:${PHP_VERSION}-fpm`. Prima era `php:8.3-fpm`, un tag che si sposta ma
che Docker tiene in cache: una ricostruzione senza `--pull` (anche su
Dokploy) poteva riusare la base vulnerabile. Ora una build o prende la
8.3.36 o fallisce. Test `tests/Unit/VersionePhpTest.php` contro il ritorno a
un tag mobile o a una versione inferiore.
- phpredis da GitHub (`ARG PHPREDIS_VERSION=6.3.0`) invece di `pecl install`:
quel giorno pecl.php.net rispondeva 504 e la build falliva.
- Solo per provare in locale prima che l'immagine sia su Docker Hub:
`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.
**Demo costruita per qualche ora con PHP 8.3.35 (21/10, dalle 14:40 alle
16:44 UTC).** L'utente aveva chiesto il deploy della demo prima che uscisse
`php:8.3.36-fpm`: nelle variabili d'ambiente del compose della demo in
Dokploy era stata messa `PHP_VERSION=8.3.35`, che il `docker-compose.yml`
passa al Dockerfile. **Tolta alle 16:44 UTC** e demo ricostruita su 8.3.36;
nell'env della demo e della produzione non c'è più nessuna `PHP_VERSION`. Se
servisse di nuovo, va tolta subito dopo, altrimenti la demo resta indietro.
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.
### Password del sorvegliante dal pannello Utenti (21 ottobre 2026, solo su dev)
Richiesta dell'utente: poter reimpostare la password del sorvegliante dal
pannello di amministrazione. Prima si poteva solo dalla pagina del gruppo; in
*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`.
### Anteprima dal numero in "Il mio gruppo" (21 ottobre 2026, solo su dev)
Richiesta dell'utente: anche il sorvegliante deve poter vedere l'anteprima del
territorio, con un popup al clic del numero. Il numero diventa un pulsante
(stesso aspetto, `button.tm-assegnato-numero` in `app.css`) che apre una
finestra (`tm-modale`, stato Alpine `anteprima` sulla radice della vista) con
la miniatura e "Apri la piantina (PDF)". La miniatura passa da una rotta
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.
### Assegna aperta dalla Home: senza l'elenco dei territori assegnati (20 ottobre 2026)
Richiesta dell'utente: "quando provengo da un territorio dalla home, non
mostrare lo storico assegnazioni". Avevo capito la scheda del territorio e
ne avevo nascosto lo storico (commit `f1f3408`, poi annullato con un revert):
intendeva la pagina *Assegna* aperta da "Assegna →" della Home, e come
"storico" l'elenco "Territori Assegnati" sotto il modulo. Ora quel link porta
`?dalla_home=1` e `Assegna::$elencoNascosto` toglie `ElencoAssegnati` (che
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.
### Storico assegnazioni in linea (20 ottobre 2026)
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`.
### Rientro dall'elenco dei territori assegnati (20 ottobre 2026)
Richiesta dell'utente ("un tasto che va alla registrazione", dallo screenshot
della pagina *Assegna* con l'elenco filtrato per proclamatore): in
`ElencoAssegnati` ogni riga ha **Rientro** (solo con `territori.return`), che
apre la registrazione del rientro e, con `data-ritorno`, torna all'elenco.
Anche le righe senza PDF lo hanno. L'elenco ha un tetto di peso per riga
(`AssegnaPrestazioniTest`, 2,6 KB) e il pulsante lo sforava di 21 byte: per
starci l'indirizzo è relativo (`route(..., false)`, `data-ritorno` lo
gestisce) e il pulsante sta sulla stessa riga del `<div>`, senza a capo né
commenti dentro il ciclo (i commenti Blade spariscono, gli spazi no).
### Gruppi di servizio (19-20 ottobre 2026, solo su dev)
Richiesta dell'utente: un **gruppo di servizio** con un sorvegliante che, Richiesta dell'utente: un **gruppo di servizio** con un sorvegliante che,
entrato con una sua utenza, distribuisce ai membri un pacchetto di territori entrato con una sua utenza, distribuisce ai membri un pacchetto di territori
e non vede nient'altro. Scelte dell'utente: e non vede nient'altro. Le decisioni sono cambiate più volte in due giorni:
qui sotto **com'è oggi**, in fondo la storia.
- Il gruppo ha **membri** (proclamatori); un proclamatore sta in **un solo** **Come funziona**
gruppo; il sorvegliante è anche membro e assegna solo ai membri, a sé
compreso. - **Chi**: un gruppo ha un nome (unico), un sorvegliante (proclamatore
- Il **pacchetto non è un'assegnazione**: "la media non deve essere alterata". attivo) e dei membri. Un proclamatore sta in **un solo** gruppo; il
I territori del pacchetto sono "In gruppo …" (stato `in_gruppo`), fuori da sorvegliante è anche membro e assegna solo ai membri, a sé compreso.
Gruppi, membri e pacchetti li gestisce **solo l'amministratore** (menu
*Gruppi di servizio*).
- **Utenza del sorvegliante**: nasce con il gruppo. Email **fittizia**
(`GruppiServizio::emailFittizia()`: iniziale del nome e cognome, minuscoli,
senza accenti, spazi né apostrofi, su `GruppiServizio::DOMINIO_EMAIL` =
`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` "Da assegnare" e dalla pagina *Assegna* (`Territorio::scopeInReparto`
esclude `gruppo_servizio_id`). esclude `gruppo_servizio_id`). Si aggiungono in blocco dai territori in
- Quando il sorvegliante dà un territorio nasce un'**assegnazione normale**, reparto in ordine di priorità (`Territorio::ordinaPerPriorita()`, la regola
tracciata con `assegnazioni.gruppo_servizio_id` ("via gruppo …" nel della Home), con ricerca, zona, tipologia, "solo prioritari" e "Seleziona
registro e nella scheda del territorio). tutti quelli mostrati" (`GruppoShow`).
- Al **rientro** il territorio esce dal pacchetto e torna in reparto - **"Il mio gruppo"** (`MioGruppo`): sotto il titolo nome e cognome del
(hook `updated` in `Assegnazione`, vale da qualunque pagina). sorvegliante; prima i territori da dare in ordine di priorità, poi i già
- Il sorvegliante: assegna, manda il link (copia/condividi), registra i dati dal più vecchio; filtri stato, zona, tipologia, "solo prioritari"
rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e (nell'indirizzo). Il sorvegliante assegna, manda il link (copia o
pacchetti li gestisce **solo l'amministratore**. condividi: trait `Concerns\InviaLink` + partial `assegnazioni/_invio-link`,
- Utenza: email e password temporanea da cambiare al primo accesso, **senza gli stessi di *Assegna*), registra i rientri, apre la piantina, rimanda in
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il reparto quelli non dati. Il numero del territorio apre l'anteprima (finestra
sorvegliante; anche cambiandola dal profilo). Alpine nella vista, miniatura e collegamento al PDF).
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`): - **Assegnazione dal pacchetto**: un'**assegnazione normale**, con
non dati in reparto, membri liberi, utenza senza ruolo e disattivata `gruppo_servizio_id` e `gruppo_nome` ("via gruppo …" in registro e schede).
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da `GruppiServizio::assegna()` rilegge territorio e membro e ricontrolla il
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I pacchetto sotto lock. Al **rientro** il territorio esce dal pacchetto e
territori già dati restano ai membri. Si riparte con un gruppo nuovo. 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.
Casi aggiunti di mio, da far confermare all'utente: **Dove sta**
- Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la
sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id`
è unico), con email e password temporanea nuove.
- Il sorvegliante di un gruppo **attivo** non si sposta nel cestino né si
anonimizza (`ProclamatoreIndex`): prima si scioglie il gruppo. Un membro
spostato nel cestino lascia il gruppo. `Proclamatore::anonimizza()`
anonimizza anche l'utenza dell'ex sorvegliante (nome, email, password,
ruoli).
- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro
delle attività `User::tapActivity()` li sostituisce con "proclamatore #id",
come vale per tutti i proclamatori. Restano invece in chiaro nella tabella
`users`, come per ogni utente.
Dove sta: - Migrazioni: `2026_10_20_000010_gruppi_di_servizio` (tabella, colonne
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella `gruppo_servizio_id` su proclamatori, territori e assegnazioni,
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e `users.proclamatore_id` unico, permesso e ruolo; il seeder dà
assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`, all'amministratore tutti i permessi **tranne** `gruppo.gestisci`),
permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà `000020` (`assegnazioni.gruppo_nome`, via `sciolto_at` e i gruppi sciolti),
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`. `000030` (via `users.username` e `users.disattivato_at` e le utenze
- Regole in `App\Services\GruppiServizio` (crea, aggiungiMembri, togliMembro, disattivate, email fittizia a chi non l'aveva, email di nuovo obbligatoria),
aggiungiAlPacchetto, rimandaInReparto, assegna, sciogli); i componenti `000040` (`users.password_temporanea`).
`Gruppi\GruppiIndex`, `Gruppi\GruppoShow` (amministratore) e - Regole in `App\Services\GruppiServizio`; i componenti `Gruppi\GruppiIndex`,
`Gruppi\MioGruppo` (sorvegliante) le chiamano soltanto. `Gruppi\GruppoShow` (amministratore) e `Gruppi\MioGruppo` (sorvegliante) le
- `LimitaSorvegliante` (gruppo `web`): chi ha **solo** il ruolo sorvegliante chiamano soltanto.
raggiunge `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo, - Test: `tests/Feature/GruppiServizioTest.php`.
controllo in `Rientra::autorizza`), profilo e cambio password; la Home lo
rimanda al gruppo; il resto è 403.
- L'invio del link è diventato il trait `Concerns\InviaLink` + il partial
`assegnazioni/_invio-link`, usati da *Assegna* e da *Il mio gruppo*.
- Il ruolo sorvegliante non compare nella pagina *Utenti* e le utenze legate a
un proclamatore lì non si modificano né si eliminano: passano dal gruppo.
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
l'amministratore passa ogni `@can` (`Gate::before`).
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
assegnazioni e il legame utenza ↔ proclamatore.
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
del proclamatore).
- L'azzeramento dell'applicazione (*Zona pericolosa*) cancella anche i gruppi.
- Test: `tests/Feature/GruppiServizioTest.php`. Eseguiti sul server **prima**
del push, su una copia della checkout di dev con i commit portati da un
`git bundle` (la checkout di Dokploy è shallow: il bundle deve partire dal
commit che ha, `git log -1` lì dentro).
Non provato nel browser. **In produzione non c'è**: serve l'ok dell'utente. **Com'è cambiato** (decisioni dell'utente, in ordine)
1. 19/10 — gruppo con membri; il pacchetto non conta nelle statistiche;
utenza con email e password, senza conferma; togliere il ruolo scioglie il
gruppo, che restava archiviato (`sciolto_at`) con l'utenza disattivata.
2. 20/10 — sorvegliante non più attivo ⇒ gruppo sciolto; dettagli nella
scheda e nel profilo; anche i membri segnalati.
3. 20/10 — il gruppo sciolto non resta nello storico (si elimina); traccia
"via gruppo" sulle assegnazioni confermata. Per qualche ora il sorvegliante
è entrato con un nome utente ricavato dal nome del gruppo.
4. 20/10 — al posto del nome utente l'email fittizia iniziale+cognome; sciolto
il gruppo l'utenza si elimina ("torna proclamatore normale, non tenere
traccia").
5. 20/10 — "Password temporanea" al primo accesso; il 403 sulle azioni;
priorità e filtri; nome del sorvegliante sotto il titolo.
### 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)
@@ -302,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.
@@ -404,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.
@@ -423,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 |
@@ -488,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.
@@ -1476,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 |
|------|---------------| |------|---------------|
@@ -1493,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*).
@@ -1507,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
@@ -1589,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
@@ -1683,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

222
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 |
@@ -411,23 +414,26 @@ 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,40 +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 ### Gruppi di servizio
- Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome, - Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome,
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di **sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
anche membro anche membro
- Creando il gruppo il sorvegliante riceve un'**utenza**: l'email indicata e - Creando il gruppo il sorvegliante riceve un'**utenza** con un'**email
una password temporanea, mostrata una volta sola e da cambiare al primo fittizia** creata dall'applicazione: iniziale del nome e cognome sul
accesso. **Nessuna conferma dell'email**. Se la perde, l'amministratore ne dominio `termanager.it` (Francesco Fontana → `ffontana@termanager.it`; se
genera un'altra dalla pagina del gruppo è già presa, un numero in coda), e una password temporanea. Si vedono una
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto. 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 Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
cambiano), escono da "Da assegnare" e dalla pagina *Assegna* cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo - 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 (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 nasce un'**assegnazione normale**, che conta come le altre ed è segnata
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri, "via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
apre la piantina e rimanda in reparto quelli non ancora dati 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 - Al **rientro** il territorio torna in reparto ed esce dal pacchetto
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante: i territori non - **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
ancora dati tornano in reparto, i membri si liberano, l'utenza viene gruppo**, che non resta nello storico: i territori solo nel pacchetto
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello tornano in reparto, i membri si liberano e l'**utenza del sorvegliante si
storico. I territori già dati restano ai membri. Per ripartire si crea un elimina** (anche una sessione aperta si chiude): torna un proclamatore come
gruppo nuovo; se il sorvegliante è lo stesso, si riattiva la sua utenza gli altri. Le assegnazioni che aveva fatto passano a chi scioglie il
- Il sorvegliante di un gruppo attivo non si sposta nel cestino né si gruppo; nel registro delle attività le sue voci restano a nome
anonimizza: prima si scioglie il gruppo. Anonimizzando un ex sorvegliante "Sorvegliante del gruppo …". I territori già dati restano ai membri come
si anonimizza anche la sua utenza 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
@@ -772,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
@@ -812,13 +903,15 @@ Quattro ruoli con permessi granulari (spatie/laravel-permission):
| `territori.return` | ✅ | ✅ | ✅ | | | `territori.return` | ✅ | ✅ | ✅ | |
| `audit.view` | ✅ | | | | | `audit.view` | ✅ | | | |
| `audit.export` | ✅ | | | | | `audit.export` | ✅ | | | |
| `richieste.gestisci` | ✅ | ✅ | | |
| `gruppo.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 Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando
un gruppo di servizio e sparisce sciogliendolo. Chi ha solo quel ruolo è 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 confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro
dei territori del gruppo e al profilo; le sue azioni ricontrollano che dei territori del gruppo e al profilo; le sue azioni ricontrollano che
territorio e assegnazione siano del suo gruppo. territorio e assegnazione siano del suo gruppo.
@@ -837,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
@@ -846,6 +940,13 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione - **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php` - **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
restituisce 404 restituisce 404
- **Versione di PHP fissata alla patch** (`ARG PHP_VERSION` nel Dockerfile,
oggi 8.3.36 per CVE-2026-109618/109619): una build o prende quella versione
o fallisce, mai una base vecchia rimasta in cache. Va alzata a ogni rilascio
di sicurezza del ramo 8.3
- **Sessioni native di PHP chiuse**: `session.upload_progress.enabled = 0` e
`session.use_strict_mode = 1` in `php.ini` (l'applicazione usa le sessioni
di Laravel; è la mitigazione indicata per le stesse CVE)
### GDPR ### GDPR
- **Informativa** (menu *Privacy*, solo amministratore): i valori che possono - **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
@@ -863,6 +964,14 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
- **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 (anche per l'utenza di un sorvegliante di gruppo, che porta il nome in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome
di un proclamatore) 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
@@ -873,16 +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; `proclamatore_id` lega l'utenza del sorvegliante al suo proclamatore, `disattivato_at` la chiude allo scioglimento del gruppo | | `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); `gruppo_servizio_id` è il gruppo di cui fanno parte | | `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
| `gruppi_servizio` | Gruppi di servizio: nome, sorvegliante, `sciolto_at` (sciolti restano per lo storico) | | `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 | | `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`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo | | `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**
@@ -916,10 +1026,16 @@ Calcolato dinamicamente, non memorizzato:
| `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
@@ -952,7 +1068,7 @@ sempre.
| Componente | Tecnologia | | Componente | Tecnologia |
|-------------------|--------------------------------------------| |-------------------|--------------------------------------------|
| Framework | Laravel 11 | | Framework | Laravel 11 |
| PHP | 8.3 (FPM) | | PHP | 8.3 (FPM), versione fissata nel Dockerfile |
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js | | Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
| Database | MariaDB 11 | | Database | MariaDB 11 |
| Cache/Sessioni/Code| Redis 7 | | Cache/Sessioni/Code| Redis 7 |
@@ -967,14 +1083,16 @@ sempre.
``` ```
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/
@@ -996,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

@@ -40,16 +40,6 @@ class LoginController extends Controller
->withInput($request->only('email', 'remember')); ->withInput($request->only('email', 'remember'));
} }
// Utenza disattivata (gruppo di servizio sciolto): la password è giusta
// ma non si entra.
if (Auth::user()->isDisattivato()) {
Auth::logout();
return back()
->withErrors(['email' => 'Questa utenza è stata disattivata.'])
->withInput($request->only('email', 'remember'));
}
RateLimiter::clear($throttleKey); RateLimiter::clear($throttleKey);
$request->session()->regenerate(); $request->session()->regenerate();
activity()->causedBy(auth()->user())->log('login'); activity()->causedBy(auth()->user())->log('login');

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

@@ -31,8 +31,7 @@ class TerritorioFileController extends Controller
*/ */
public function pdfGruppo(Territorio $territorio): Response public function pdfGruppo(Territorio $territorio): Response
{ {
$gruppo = auth()->user()->gruppoSorvegliato(); $this->delPacchetto($territorio);
abort_unless($gruppo && $territorio->gruppo_servizio_id === $gruppo->id, 403);
return $this->pdf($territorio); return $this->pdf($territorio);
} }
@@ -42,6 +41,21 @@ class TerritorioFileController extends Controller
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

@@ -4,23 +4,18 @@ namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
* Due regole per gli utenti autenticati. * Il sorvegliante di gruppo vede solo il proprio gruppo e il proprio
*
* 1. Un'utenza disattivata (gruppo di servizio sciolto) non può più lavorare:
* anche una sessione già aperta viene chiusa alla richiesta successiva.
*
* 2. Il sorvegliante di gruppo vede solo il proprio gruppo e il proprio
* profilo. La Home lo porta a "Il mio gruppo"; ogni altra pagina è 403, * profilo. La Home lo porta a "Il mio gruppo"; ogni altra pagina è 403,
* anche scrivendone l'indirizzo a mano. Le azioni Livewire passano: i * anche scrivendone l'indirizzo a mano. Le azioni Livewire passano: i
* componenti che può aprire controllano da sé che territori e assegnazioni * componenti che può aprire controllano da sé che territori e assegnazioni
* siano del suo gruppo, e gli altri non può caricarli. * siano del suo gruppo, e gli altri non può caricarli.
* *
* Vale solo per chi ha il ruolo sorvegliante e nessun altro: un utente con * 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. * 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 class LimitaSorvegliante
{ {
@@ -33,7 +28,10 @@ class LimitaSorvegliante
'password.change.store', 'password.change.store',
'logout', 'logout',
'verification.*', 'verification.*',
'livewire.*', // 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 public function handle(Request $request, Closure $next): Response
@@ -43,14 +41,6 @@ class LimitaSorvegliante
return $next($request); return $next($request);
} }
if ($user->isDisattivato()) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->withErrors(['email' => 'Questa utenza è stata disattivata.']);
}
if (! $user->isSorvegliante() || $user->roles->count() > 1) { if (! $user->isSorvegliante() || $user->roles->count() > 1) {
return $next($request); return $next($request);
} }

View File

@@ -7,6 +7,7 @@ use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\GruppoServizio; 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;
@@ -76,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', 11); $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

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

@@ -9,7 +9,9 @@ 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
{ {
@@ -20,14 +22,44 @@ class Assegna extends Component
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).
@@ -101,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,
]); ]);
}); });
@@ -123,6 +156,13 @@ 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);
} }
@@ -169,6 +209,7 @@ class Assegna extends Component
'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,6 +26,13 @@ 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->autorizza($assegnazione);
@@ -34,6 +42,11 @@ class Rientra extends Component
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();
} }
@@ -132,6 +145,11 @@ 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($this->paginaPredefinita()), navigate: true); return $this->redirect($this->destinazione($this->paginaPredefinita()), navigate: true);
} }
@@ -164,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

@@ -12,9 +12,10 @@ use Livewire\Component;
/** /**
* Gruppi di servizio (solo amministratore): elenco e creazione. Creando un * Gruppi di servizio (solo amministratore): elenco e creazione. Creando un
* gruppo si sceglie il sorvegliante, che riceve un'utenza con l'email * gruppo si sceglie il sorvegliante, che riceve un'utenza con un'email
* indicata e una password temporanea, mostrata qui una volta sola. * fittizia ricavata dal suo nome (GruppiServizio::emailFittizia) e una
* Membri e pacchetto si gestiscono nella pagina del gruppo (GruppoShow). * password temporanea, mostrate qui una volta sola. Membri e pacchetto si
* gestiscono nella pagina del gruppo (GruppoShow).
*/ */
class GruppiIndex extends Component class GruppiIndex extends Component
{ {
@@ -24,8 +25,6 @@ class GruppiIndex extends Component
public ?int $sorveglianteId = null; public ?int $sorveglianteId = null;
public string $email = '';
/** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */ /** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */
#[Locked] #[Locked]
public ?int $creatoId = null; public ?int $creatoId = null;
@@ -38,19 +37,17 @@ class GruppiIndex extends Component
$this->authorize('settings.manage'); $this->authorize('settings.manage');
$this->validate([ $this->validate([
'nome' => 'required|string|max:100', 'nome' => 'required|string|max:100|unique:gruppi_servizio,nome',
'sorveglianteId' => 'required|integer|exists:proclamatori,id', 'sorveglianteId' => 'required|integer|exists:proclamatori,id',
'email' => 'required|email|max:255',
], [ ], [
'nome.unique' => 'Esiste già un gruppo con questo nome.',
'sorveglianteId.required' => 'Scegli il sorvegliante.', 'sorveglianteId.required' => 'Scegli il sorvegliante.',
'email.required' => "Serve l'email con cui il sorvegliante entrerà.",
]); ]);
try { try {
[$gruppo, $password] = app(GruppiServizio::class)->crea( [$gruppo, $password] = app(GruppiServizio::class)->crea(
$this->nome, $this->nome,
Proclamatore::findOrFail($this->sorveglianteId), Proclamatore::findOrFail($this->sorveglianteId),
trim($this->email),
); );
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$this->addError('sorveglianteId', $e->getMessage()); $this->addError('sorveglianteId', $e->getMessage());
@@ -58,7 +55,7 @@ class GruppiIndex extends Component
return; return;
} }
$this->reset(['nome', 'sorveglianteId', 'email']); $this->reset(['nome', 'sorveglianteId']);
$this->creatoId = $gruppo->id; $this->creatoId = $gruppo->id;
$this->passwordTemporanea = $password; $this->passwordTemporanea = $password;
} }
@@ -72,15 +69,14 @@ class GruppiIndex extends Component
{ {
$gruppi = GruppoServizio::with('sorvegliante') $gruppi = GruppoServizio::with('sorvegliante')
->withCount(['membri', 'territori']) ->withCount(['membri', 'territori'])
->orderByRaw('sciolto_at IS NOT NULL')
->orderBy('nome') ->orderBy('nome')
->get(); ->get();
// Possono diventare sorveglianti i proclamatori attivi senza gruppo e // Possono diventare sorveglianti i proclamatori attivi senza gruppo (e
// senza un'utenza attiva (quella di un gruppo sciolto si riattiva). // quindi senza utenza: sciolto il gruppo, la si elimina).
$candidati = Proclamatore::attivi() $candidati = Proclamatore::attivi()
->whereNull('gruppo_servizio_id') ->whereNull('gruppo_servizio_id')
->whereDoesntHave('utente', fn ($q) => $q->whereNull('disattivato_at')) ->whereDoesntHave('utente')
->get(); ->get();
return view('livewire.gruppi.gruppi-index', [ return view('livewire.gruppi.gruppi-index', [

View File

@@ -32,6 +32,16 @@ class GruppoShow extends Component
/** @var int[] territori da aggiungere al pacchetto */ /** @var int[] territori da aggiungere al pacchetto */
public array $nuoviTerritori = []; 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] #[Locked]
public ?string $passwordTemporanea = null; public ?string $passwordTemporanea = null;
@@ -91,6 +101,45 @@ class GruppoShow extends Component
$this->nuoviTerritori = []; $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 public function rimanda(int $territorioId): void
{ {
$territorio = Territorio::findOrFail($territorioId); $territorio = Territorio::findOrFail($territorioId);
@@ -102,24 +151,26 @@ class GruppoShow extends Component
{ {
$this->servizio(); $this->servizio();
$utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first(); $utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first();
if (! $utente || $this->gruppo->sciolto_at) { if (! $utente) {
$this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.'); $this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
return; return;
} }
$password = Str::password(12, symbols: false); $this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($utente);
$utente->forceFill(['password' => $password, 'password_change_required' => true])->save();
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
$this->passwordTemporanea = $password;
} }
/** Toglie il ruolo di sorvegliante: il gruppo si scioglie. */ /**
public function sciogli(): void * Toglie il ruolo di sorvegliante: il gruppo si scioglie e si elimina, si
* torna all'elenco dei gruppi.
*/
public function sciogli()
{ {
$this->esegui(fn () => $this->servizio()->sciogli($this->gruppo), "Gruppo {$this->gruppo->nome} sciolto."); $nome = $this->gruppo->nome;
$this->passwordTemporanea = null; $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() public function render()
@@ -129,21 +180,21 @@ class GruppoShow extends Component
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(); $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(); $territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
$attivo = ! $this->gruppo->sciolto_at; // 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', [ return view('livewire.gruppi.gruppo-show', [
'attivo' => $attivo,
'membri' => $membri, 'membri' => $membri,
'territori' => $territori, 'territori' => $territori,
// Si possono aggiungere i proclamatori attivi senza gruppo... // Si possono aggiungere i proclamatori attivi senza gruppo...
'candidatiMembri' => $attivo 'candidatiMembri' => Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(),
? Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values() // ...e i territori in reparto.
: collect(), 'totaleCandidati' => $candidati->count(),
// ...e i territori in reparto (attivi, non assegnati, fuori da ogni pacchetto). 'candidatiTerritori' => $this->candidatiFiltrati($candidati),
'candidatiTerritori' => $attivo 'zoneCandidati' => $candidati->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
? Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values() 'tipologieCandidati' => $candidati->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
: collect(),
'assegnazioniDelGruppo' => $this->gruppo->assegnazioni()->count(),
]); ]);
} }
} }

View File

@@ -12,6 +12,7 @@ use App\Services\GruppiServizio;
use Carbon\Carbon; use Carbon\Carbon;
use InvalidArgumentException; use InvalidArgumentException;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
/** /**
@@ -35,6 +36,23 @@ class MioGruppo extends Component
public string $dataAssegnazione = ''; 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 public function mount(): void
{ {
$this->dataAssegnazione = now()->format('Y-m-d'); $this->dataAssegnazione = now()->format('Y-m-d');
@@ -69,13 +87,14 @@ class MioGruppo extends Component
$this->territorioDelPacchetto($territorioId); $this->territorioDelPacchetto($territorioId);
$this->territorioDaAssegnare = $territorioId; $this->territorioDaAssegnare = $territorioId;
$this->membroId = null; $this->membroId = null;
$this->cartaceo = false;
$this->dataAssegnazione = now()->format('Y-m-d'); $this->dataAssegnazione = now()->format('Y-m-d');
$this->resetValidation(); $this->resetValidation();
} }
public function annullaAssegnazione(): void public function annullaAssegnazione(): void
{ {
$this->reset(['territorioDaAssegnare', 'membroId']); $this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
$this->resetValidation(); $this->resetValidation();
} }
@@ -100,14 +119,14 @@ class MioGruppo extends Component
} }
try { try {
$assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione)); $assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione), $this->cartaceo);
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$this->addError('membroId', $e->getMessage()); $this->addError('membroId', $e->getMessage());
return; return;
} }
$this->reset(['territorioDaAssegnare', 'membroId']); $this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
// Come dalla pagina Assegna: si apre l'invio del link. // Come dalla pagina Assegna: si apre l'invio del link.
$this->apriInvio($assegnazione->id); $this->apriInvio($assegnazione->id);
@@ -170,11 +189,25 @@ class MioGruppo extends Component
public function render() public function render()
{ {
$gruppo = $this->gruppo(); $gruppo = $this->gruppo()?->load('sorvegliante');
$territori = $gruppo // Prima quelli da dare, in ordine di priorità come "Da assegnare"
? $gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values() // nella Home (Territorio::ordinaPerPriorita); poi quelli già dati, dal
// più vecchio.
$pacchetto = $gruppo
? $gruppo->territori()->with(['zona', 'tipologia', 'ultimaAssegnazione', 'assegnazioneCorrente.proclamatore'])->get()
: collect(); : 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 $membri = $gruppo
? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values() ? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
@@ -182,9 +215,12 @@ class MioGruppo extends Component
return view('livewire.gruppi.mio-gruppo', [ return view('livewire.gruppi.mio-gruppo', [
'gruppo' => $gruppo, 'gruppo' => $gruppo,
'pacchetto' => $pacchetto,
'territori' => $territori, '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, 'membri' => $membri,
'opzioniMembri' => Proclamatore::opzioniScelta($membri), 'opzioniMembri' => Proclamatore::opzioniScelta($membri, conGruppo: false),
'invio' => $this->invioCorrente(), '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,7 +24,7 @@ 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 = [
@@ -36,6 +37,7 @@ class Privacy extends Component
'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à',
]; ];
@@ -78,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

@@ -58,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,14 +77,12 @@ class ProclamatoreIndex extends Component
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori."); $this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
return; return;
} }
if ($this->sorveglianteDiUnGruppo($proclamatore, 'spostarlo nel cestino')) {
return;
}
// Nel cestino non resta membro di un gruppo. // Nel cestino lascia il gruppo; se ne era il sorvegliante, il gruppo
$proclamatore->update(['gruppo_servizio_id' => null]); // 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)
@@ -86,25 +90,15 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage'); $this->authorize('settings.manage');
$proclamatore = Proclamatore::findOrFail($id); $proclamatore = Proclamatore::findOrFail($id);
if ($this->sorveglianteDiUnGruppo($proclamatore, 'anonimizzarlo')) { $gruppo = $proclamatore->gruppoSorvegliato();
return;
}
$proclamatore->anonimizza(); $proclamatore->anonimizza();
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)."); $this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)." . ($gruppo ? ' ' . self::gruppoSciolto($gruppo) : ''));
} }
/** Il sorvegliante di un gruppo attivo si toglie sciogliendo il gruppo, non da qui. */ /** L'avviso quando un sorvegliante esce di scena: anche per ProclamatoreEdit. */
private function sorveglianteDiUnGruppo(Proclamatore $proclamatore, string $azione): bool public static function gruppoSciolto(GruppoServizio $gruppo): string
{ {
$gruppo = GruppoServizio::attivi()->where('sorvegliante_id', $proclamatore->id)->first(); 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.";
if (! $gruppo) {
return false;
}
$this->avvisaErrore("{$proclamatore->nome_completo} è il sorvegliante del gruppo {$gruppo->nome}: per {$azione} va prima sciolto il gruppo (Gruppi di servizio).");
return true;
} }
public function render() public function render()
@@ -112,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'),
]); ]);
@@ -130,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
@@ -154,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
@@ -176,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,15 +27,17 @@ 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();
@@ -78,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']);
@@ -87,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

@@ -285,7 +285,7 @@ class Registro extends Component
public function render() public function render()
{ {
$query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna', 'gruppo']); $query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
if ($this->filtroAnno) { if ($this->filtroAnno) {
$query->where('anno_teocratico_id', $this->filtroAnno); $query->where('anno_teocratico_id', $this->filtroAnno);

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 = '';
@@ -115,7 +126,7 @@ class UsersIndex extends Component
} }
/** /**
* Le utenze dei sorveglianti di gruppo (attive o disattivate) si gestiscono * Le utenze dei sorveglianti di gruppo si gestiscono
* da Gruppi di servizio: cambiarle o cancellarle da qui lascerebbe un * da Gruppi di servizio: cambiarle o cancellarle da qui lascerebbe un
* gruppo senza sorvegliante, o un sorvegliante con un ruolo diverso. * gruppo senza sorvegliante, o un sorvegliante con un ruolo diverso.
*/ */
@@ -232,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();
@@ -271,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

@@ -527,6 +527,13 @@ class XmlExchange extends Component
$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;
@@ -575,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;
@@ -594,10 +602,7 @@ class XmlExchange extends Component
// Utenza di un sorvegliante: si collega al proclamatore dopo che // Utenza di un sorvegliante: si collega al proclamatore dopo che
// i proclamatori sono stati importati (vedi i gruppi di servizio). // i proclamatori sono stati importati (vedi i gruppi di servizio).
$utentiDaCollegare[$user->id] = [ $utentiDaCollegare[$user->id] = (string) ($userNode->legacy_proclamatore_id ?? '');
'proclamatore' => (string) ($userNode->legacy_proclamatore_id ?? ''),
'disattivato_at' => $this->normalizeDateTimeForDb((string) ($userNode->disattivato_at ?? '')),
];
$stats['utenti_importati']++; $stats['utenti_importati']++;
if ($legacyId !== '') { if ($legacyId !== '') {
@@ -657,10 +662,15 @@ class XmlExchange extends Component
$gruppoMap = []; $gruppoMap = [];
foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) { foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) {
$legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? ''); $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([ $g = GruppoServizio::create([
'nome' => (string) $gruppoNode->nome, 'nome' => (string) $gruppoNode->nome,
'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null, 'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null,
'sciolto_at' => $this->normalizeDateTimeForDb((string) ($gruppoNode->sciolto_at ?? '')),
]); ]);
$this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode); $this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode);
$legacyId = (string) ($gruppoNode['legacy_id'] ?? ''); $legacyId = (string) ($gruppoNode['legacy_id'] ?? '');
@@ -673,10 +683,9 @@ class XmlExchange extends Component
DB::table('proclamatori')->where('id', $idProclamatore)->update(['gruppo_servizio_id' => $gruppoMap[$legacyGruppo]]); DB::table('proclamatori')->where('id', $idProclamatore)->update(['gruppo_servizio_id' => $gruppoMap[$legacyGruppo]]);
} }
} }
foreach ($utentiDaCollegare as $idUtente => $collegamento) { foreach ($utentiDaCollegare as $idUtente => $legacyProclamatore) {
DB::table('users')->where('id', $idUtente)->update([ DB::table('users')->where('id', $idUtente)->update([
'proclamatore_id' => $proclamatoreMap[$collegamento['proclamatore']] ?? null, 'proclamatore_id' => $proclamatoreMap[$legacyProclamatore] ?? null,
'disattivato_at' => $collegamento['disattivato_at'],
]); ]);
} }
@@ -808,7 +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_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 ?? ''),
@@ -865,20 +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 e se l'utenza è disattivata. // Sorvegliante di gruppo: il proclamatore collegato.
'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '', 'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '',
'disattivato_at' => optional($u->disattivato_at)->toIso8601String() ?? '',
'created_at' => optional($u->created_at)->toIso8601String() ?? '', 'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '', 'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
])->all(), ])->all(),
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']), 'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']), 'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']), 'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']),
'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'sciolto_at', 'created_at', 'updated_at']), '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']), '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', 'gruppo_servizio_id', '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']),
]; ];
} }
@@ -1278,7 +1288,6 @@ class XmlExchange extends Component
$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('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
$node->addChild('disattivato_at', (string) ($user['disattivato_at'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? '')); $node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? '')); $node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles'); $rolesNode = $node->addChild('roles');
@@ -1352,7 +1361,6 @@ class XmlExchange extends Component
$this->addLegacyIdAttribute($node, $gruppo); $this->addLegacyIdAttribute($node, $gruppo);
$this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? '')); $this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? ''));
$node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? '')); $node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? ''));
$node->addChild('sciolto_at', (string) ($gruppo['sciolto_at'] ?? ''));
$this->addTimestampNodes($node, $gruppo); $this->addTimestampNodes($node, $gruppo);
} }
@@ -1381,7 +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'] ?? '')); $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

@@ -21,10 +21,13 @@ class TerritorioShow extends Component
public function render() public function render()
{ {
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id) $assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa', 'gruppo']) ->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,10 +22,12 @@ 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_servizio_id',
'gruppo_nome',
]; ];
/** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */ /** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */
@@ -42,6 +44,8 @@ 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', 'gruppo_servizio_id' => 'integer',
]; ];
} }

View File

@@ -17,9 +17,9 @@ use Spatie\Activitylog\Traits\LogsActivity;
* gruppo_servizio_id per dire da dove viene. Al rientro il territorio torna in * gruppo_servizio_id per dire da dove viene. Al rientro il territorio torna in
* reparto ed esce dal pacchetto. * reparto ed esce dal pacchetto.
* *
* Un gruppo sciolto (sciolto_at) non ha più membri né pacchetto, ma resta: le * Sciolto, il gruppo si elimina: non resta nello storico (decisione del 20
* assegnazioni nate da lui continuano a citarlo. Logica in * ottobre 2026). Le assegnazioni già date dal sorvegliante ne conservano solo
* App\Services\GruppiServizio. * il nome (assegnazioni.gruppo_nome). Logica in App\Services\GruppiServizio.
*/ */
class GruppoServizio extends Model class GruppoServizio extends Model
{ {
@@ -27,17 +27,17 @@ class GruppoServizio extends Model
protected $table = 'gruppi_servizio'; protected $table = 'gruppi_servizio';
protected $fillable = ['nome', 'sorvegliante_id', 'sciolto_at']; protected $fillable = ['nome', 'sorvegliante_id'];
protected function casts(): array protected function casts(): array
{ {
return ['sciolto_at' => 'datetime', 'sorvegliante_id' => 'integer']; return ['sorvegliante_id' => 'integer'];
} }
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
{ {
return LogOptions::defaults() return LogOptions::defaults()
->logOnly(['nome', 'sorvegliante_id', 'sciolto_at']) ->logOnly(['nome', 'sorvegliante_id'])
->logOnlyDirty() ->logOnlyDirty()
->dontSubmitEmptyLogs(); ->dontSubmitEmptyLogs();
} }
@@ -63,13 +63,23 @@ class GruppoServizio extends Model
return $this->hasMany(Assegnazione::class, 'gruppo_servizio_id'); return $this->hasMany(Assegnazione::class, 'gruppo_servizio_id');
} }
public function getScioltoAttribute(): bool /**
* 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
{ {
return $this->sciolto_at !== null; $pacchetto = $this->territori()->count();
} $dati = $this->territori()->whereHas('assegnazioni', fn ($q) => $q->whereNull('returned_at'))->count();
public function scopeAttivi($query) return [
{ 'membri' => $this->membri()->count(),
return $query->whereNull('sciolto_at'); 'pacchetto' => $pacchetto,
'da_dare' => $pacchetto - $dati,
'dati' => $dati,
'assegnazioni' => $this->assegnazioni()->count(),
];
} }
} }

View File

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

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

@@ -169,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ò
@@ -293,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,8 +26,8 @@ class User extends Authenticatable implements MustVerifyEmail
'email', 'email',
'password', 'password',
'password_change_required', 'password_change_required',
'password_temporanea',
'proclamatore_id', 'proclamatore_id',
'disattivato_at',
]; ];
protected $hidden = [ protected $hidden = [
@@ -39,7 +41,9 @@ class User extends Authenticatable implements MustVerifyEmail
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'password_change_required' => 'boolean', 'password_change_required' => 'boolean',
'disattivato_at' => 'datetime', // Il cambio obbligatorio viene da una password temporanea data
// dall'amministratore, non da un ripristino (pagina del cambio).
'password_temporanea' => 'boolean',
'proclamatore_id' => 'integer', 'proclamatore_id' => 'integer',
]; ];
} }
@@ -59,18 +63,46 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasRole('sorvegliante'); return $this->hasRole('sorvegliante');
} }
/** Il gruppo (attivo) di cui è sorvegliante, se c'è. */ /** Il gruppo di cui è sorvegliante (sciolto il gruppo, l'utenza non esiste più). */
public function gruppoSorvegliato(): ?GruppoServizio public function gruppoSorvegliato(): ?GruppoServizio
{ {
return $this->proclamatore_id return $this->proclamatore_id
? GruppoServizio::attivi()->where('sorvegliante_id', $this->proclamatore_id)->first() ? GruppoServizio::where('sorvegliante_id', $this->proclamatore_id)->first()
: null; : null;
} }
/** Utenza disattivata (gruppo sciolto): non può più entrare. */ /**
public function isDisattivato(): bool * 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
{ {
return $this->disattivato_at !== null; 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();
});
} }
/** /**

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

@@ -21,53 +21,54 @@ use InvalidArgumentException;
*/ */
class GruppiServizio 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: ruolo "sorvegliante", * Crea il gruppo e l'utenza del sorvegliante: email fittizia ricavata dal
* password temporanea da cambiare al primo accesso, nessuna conferma * suo nome (emailFittizia), ruolo "sorvegliante", password temporanea da
* dell'email. Restituisce il gruppo e la password temporanea, da mostrare * cambiare al primo accesso, nessuna conferma dell'email. Restituisce il
* una volta sola all'amministratore. * gruppo e la password temporanea, da mostrare una volta sola
* all'amministratore insieme all'email.
* *
* @param int[] $membri id dei proclamatori, oltre al sorvegliante * @param int[] $membri id dei proclamatori, oltre al sorvegliante
* @return array{0: GruppoServizio, 1: string} * @return array{0: GruppoServizio, 1: string}
*/ */
public function crea(string $nome, Proclamatore $sorvegliante, string $email, array $membri = []): array public function crea(string $nome, Proclamatore $sorvegliante, array $membri = []): array
{ {
$nome = trim($nome); $nome = trim($nome);
if ($nome === '') { if ($nome === '') {
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.'); 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()) { if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.'); throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
} }
if ($sorvegliante->gruppo_servizio_id) { if ($sorvegliante->gruppo_servizio_id) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo."); throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
} }
// Chi è stato sorvegliante di un gruppo sciolto ha già un'utenza, if ($sorvegliante->utente()->exists()) {
// disattivata: si riattiva quella (un proclamatore, un'utenza).
$esistente = $sorvegliante->utente()->first();
if ($esistente && ! $esistente->isDisattivato()) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza."); throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
} }
if (User::where('email', $email)->when($esistente, fn ($q) => $q->whereKeyNot($esistente->id))->exists()) {
throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente.");
}
$password = Str::password(12, symbols: false); $password = Str::password(12, symbols: false);
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password, $esistente) { $gruppo = DB::transaction(function () use ($nome, $sorvegliante, $membri, $password) {
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]); $gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]); $sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri); $this->aggiungiMembri($gruppo, $membri);
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]); $utente = User::create([
$utente->forceFill([
'name' => $sorvegliante->nome_completo, 'name' => $sorvegliante->nome_completo,
'email' => $email, 'email' => self::emailFittizia($sorvegliante),
'password' => $password, 'password' => $password,
'password_change_required' => true, 'password_change_required' => true,
'disattivato_at' => null, 'password_temporanea' => true,
])->save(); 'proclamatore_id' => $sorvegliante->id,
$utente->syncRoles(['sorvegliante']); ]);
$utente->assignRole('sorvegliante');
activity()->causedBy(auth()->user())->performedOn($gruppo) activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id]) ->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
@@ -79,6 +80,56 @@ class GruppiServizio
return [$gruppo, $password]; 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 */ /** @param int[] $ids */
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
{ {
@@ -164,7 +215,7 @@ class GruppiServizio
* nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il * nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il
* territorio resta nel pacchetto finché non rientra. * territorio resta nel pacchetto finché non rientra.
*/ */
public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data): Assegnazione public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data, bool $cartaceo = false): Assegnazione
{ {
$this->attivo($gruppo); $this->attivo($gruppo);
@@ -187,7 +238,7 @@ class GruppiServizio
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.'); throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
} }
$esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data) { $esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data, $cartaceo) {
$bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first(); $bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first();
if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) { if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) {
return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo."; return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo.";
@@ -205,7 +256,10 @@ class GruppiServizio
'assigned_at' => $data, 'assigned_at' => $data,
'created_by' => auth()->id(), 'created_by' => auth()->id(),
'gruppo_servizio_id' => $gruppo->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, 'link_da_inviare' => (bool) $territorio->pdf_path,
'cartaceo' => $cartaceo,
]); ]);
}); });
@@ -225,48 +279,69 @@ class GruppiServizio
} }
/** /**
* Scioglie il gruppo (si toglie il ruolo di sorvegliante): i territori del * Scioglie il gruppo (si toglie il ruolo di sorvegliante, o il
* pacchetto non ancora dati tornano in reparto, i membri restano senza * sorvegliante non è più attivo) e lo elimina: non resta nello storico.
* gruppo, l'utenza del sorvegliante si disattiva. Le assegnazioni già * I territori del pacchetto non ancora dati tornano in reparto; quelli
* fatte restano com'erano, con il loro riferimento al gruppo, che resta * già dati restano ai membri come assegnazioni qualsiasi, che conservano
* nello storico come sciolto. * 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): void public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void
{ {
if ($gruppo->sciolto_at) { if (! GruppoServizio::whereKey($gruppo->id)->exists()) {
return; return;
} }
DB::transaction(function () use ($gruppo) { DB::transaction(function () use ($gruppo, $motivo) {
// Esce dal pacchetto solo quello non ancora dato: un territorio $liberati = Territorio::withTrashed()
// assegnato resta del suo gruppo finché non rientra (Assegnazione
// lo libera al rientro), così non può essere ridato due volte.
$nonDati = Territorio::withTrashed()
->where('gruppo_servizio_id', $gruppo->id) ->where('gruppo_servizio_id', $gruppo->id)
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at')); ->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'))
$liberati = (clone $nonDati)->pluck('numero'); ->pluck('numero');
$nonDati->update(['gruppo_servizio_id' => null]); 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]); 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(); $utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
if ($utente) {
$utente->removeRole('sorvegliante');
$utente->forceFill(['disattivato_at' => now(), 'remember_token' => null])->save();
}
$gruppo->update(['sciolto_at' => now()]);
activity()->causedBy(auth()->user())->performedOn($gruppo) activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['territori_liberati' => $liberati->all(), 'utente_disattivato' => $utente?->id]) ->withProperties(array_filter([
'gruppo' => $gruppo->nome,
'territori_liberati' => $liberati->all(),
'utente_eliminato' => $utente?->id,
'motivo' => $motivo,
], fn ($v) => $v !== null))
->log('gruppo_sciolto'); ->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 private function attivo(GruppoServizio $gruppo): void
{ {
if ($gruppo->sciolto_at) { if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {
throw new InvalidArgumentException('Il gruppo è stato sciolto: creane uno nuovo.'); 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

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

@@ -26,6 +26,7 @@ class RolesAndPermissionsSeeder extends Seeder
'audit.view', 'audit.view',
'audit.export', 'audit.export',
'gruppo.gestisci', 'gruppo.gestisci',
'richieste.gestisci',
]; ];
foreach ($permissions as $permission) { foreach ($permissions as $permission) {
@@ -56,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

View File

@@ -18,6 +18,10 @@ services:
build: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
environment: environment:
@@ -46,6 +50,10 @@ services:
build: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
restart: unless-stopped restart: unless-stopped
user: www-data user: www-data
env_file: .env env_file: .env
@@ -89,6 +97,10 @@ services:
build: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
restart: unless-stopped restart: unless-stopped
user: www-data user: www-data
env_file: .env env_file: .env
@@ -207,6 +219,10 @@ services:
build: build:
context: . context: .
dockerfile: docker/php/Dockerfile dockerfile: docker/php/Dockerfile
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
# imposta solo per provare in locale una versione diversa.
args:
- PHP_VERSION
# Senza questo si costruirebbe l'immagine di produzione, che essendo # Senza questo si costruirebbe l'immagine di produzione, che essendo
# installata senza dipendenze di sviluppo non contiene phpunit. # installata senza dipendenze di sviluppo non contiene phpunit.
target: test target: test

View File

@@ -1,5 +1,21 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Versione di PHP, fissata alla patch. Va dichiarata prima del primo FROM per
# poterla usare nel FROM dello stage `base`.
#
# Fissata e non "8.3": quel tag si sposta, ma Docker tiene in cache la base già
# scaricata, e una ricostruzione senza --pull (anche su Dokploy) poteva uscire
# con un PHP vecchio senza che nessuno se ne accorgesse. Con la patch scritta
# qui, una build o prende questa versione o fallisce.
#
# 8.3.36: corregge CVE-2026-109618 e CVE-2026-109619 (esecuzione di codice da
# remoto nel core di PHP, CVSS 9.2, rilascio del 21/10/2026). Non scendere
# sotto. Aggiornala a ogni rilascio di sicurezza del ramo 8.3.
#
# Solo per provare in locale prima che l'immagine corretta sia su Docker Hub:
# PHP_VERSION=8.3.35 docker compose ... (mai per un deploy).
ARG PHP_VERSION=8.3.36
# ============================================================================= # =============================================================================
# Stage 1 — PHP dependencies (no dev, production autoloader) # Stage 1 — PHP dependencies (no dev, production autoloader)
# ============================================================================= # =============================================================================
@@ -65,7 +81,7 @@ RUN npm run build
# estensioni e sullo stesso php.ini della produzione: un test che passa qui # estensioni e sullo stesso php.ini della produzione: un test che passa qui
# non puo' passare grazie a un ambiente diverso da quello vero. # non puo' passare grazie a un ambiente diverso da quello vero.
# ============================================================================= # =============================================================================
FROM php:8.3-fpm AS base FROM php:${PHP_VERSION}-fpm AS base
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container # nginx + supervisor run in-image alongside php-fpm: a separate nginx container
# would need its own copy of public/ plus the storage volume just to serve # would need its own copy of public/ plus the storage volume just to serve
@@ -76,6 +92,11 @@ FROM php:8.3-fpm AS base
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so # are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
# purging means pinning package names that break whenever the php base image # purging means pinning package names that break whenever the php base image
# moves to a new Debian. A few tens of MB is worth not having that breakage. # moves to a new Debian. A few tens of MB is worth not having that breakage.
#
# phpredis si scarica da GitHub, versione fissata, e non con `pecl install`:
# il 21/10/2026, giorno della patch di sicurezza di PHP, pecl.php.net
# rispondeva 504 e la build falliva proprio quando bisognava aggiornare.
ARG PHPREDIS_VERSION=6.3.0
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
netcat-openbsd \ netcat-openbsd \
@@ -101,8 +122,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
intl \ intl \
zip \ zip \
opcache \ opcache \
&& pecl install redis \ && mkdir -p /usr/src/php/ext/redis \
&& docker-php-ext-enable redis \ && curl -fsSL --retry 5 --retry-delay 5 \
"https://github.com/phpredis/phpredis/archive/refs/tags/${PHPREDIS_VERSION}.tar.gz" \
| tar xz --strip-components=1 -C /usr/src/php/ext/redis \
&& echo redis >> /usr/src/php-available-exts \
&& docker-php-ext-install -j"$(nproc)" redis \
&& docker-php-source delete \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@@ -8,6 +8,16 @@ memory_limit = 256M
max_execution_time = 120 max_execution_time = 120
max_input_vars = 3000 max_input_vars = 3000
[Session]
; Mitigazione di CVE-2026-109618/109619 (RCE nel core di PHP, 21/10/2026),
; come indicato da Sansec in attesa della patch, e da tenere anche dopo:
; l'avanzamento degli upload nelle sessioni native di PHP non serve
; all'applicazione (Laravel ha le sue sessioni, in Redis, e Livewire non lo
; usa) ed è il punto da cui passano questi attacchi. Lo strict mode vale solo
; per le sessioni native, che qui non si usano: costa niente.
session.upload_progress.enabled = 0
session.use_strict_mode = 1
[opcache] [opcache]
opcache.enable = 1 opcache.enable = 1
opcache.memory_consumption = 128 opcache.memory_consumption = 128

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

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

View File

@@ -26,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
@@ -65,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)
@@ -80,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
<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> <p class="tm-assegnato-senza">Nessun PDF disponibile</p>
</div>
@endif @endif
</div> </div>
@endforeach @endforeach

View File

@@ -7,6 +7,13 @@
<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">

View File

@@ -12,7 +12,7 @@
<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> <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"> <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">Indirizzo</dt><dd><code>{{ route('login') }}</code></dd>
<dt class="text-green-700">Email</dt><dd><code>{{ $creato->sorvegliante?->utente?->email }}</code></dd> <dt class="text-green-700">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> <dt class="text-green-700">Password</dt><dd><code class="select-all">{{ $passwordTemporanea }}</code></dd>
</dl> </dl>
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
@@ -34,16 +34,9 @@
<div> <div>
<label for="sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Sorvegliante *</label> <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" /> <x-scelta id="sorvegliante" wire:model="sorveglianteId" :opzioni="$opzioniSorveglianti" segnaposto="Cognome o nome" :vuoto="null" />
<p class="text-xs text-gray-500 mt-1">Un proclamatore attivo che non fa già parte di un gruppo. Diventa anche membro del gruppo.</p> <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 @error('sorveglianteId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div>
<label for="email-sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Email per l'accesso *</label>
<input wire:model="email" id="email-sorvegliante" type="email" autocomplete="off"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<p class="text-xs text-gray-500 mt-1">Resta solo nell'utenza del sorvegliante (non nel proclamatore) e non va confermata.</p>
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<x-btn variante="primary" type="submit">Crea il gruppo</x-btn> <x-btn variante="primary" type="submit">Crea il gruppo</x-btn>
</form> </form>
</section> </section>
@@ -55,15 +48,12 @@
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-3"> <li class="px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<a data-ritorno href="{{ route('gruppi.show', $g) }}" class="font-semibold text-indigo-600 hover:underline">{{ $g->nome }}</a> <a data-ritorno href="{{ route('gruppi.show', $g) }}" class="font-semibold text-indigo-600 hover:underline">{{ $g->nome }}</a>
@if($g->sciolto_at)
<span class="ml-1 inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">sciolto il {{ $g->sciolto_at->format('d/m/Y') }}</span>
@endif
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
Sorvegliante: {{ $g->sorvegliante?->nome_completo ?? '—' }} Sorvegliante: {{ $g->sorvegliante?->nome_completo ?? '—' }}
@unless($g->sciolto_at) · {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto @endunless · {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto
</p> </p>
</div> </div>
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">{{ $g->sciolto_at ? 'Dettaglio' : 'Gestisci' }}</x-btn> <x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">Gestisci</x-btn>
</li> </li>
@empty @empty
<li class="px-4 py-8 text-center text-sm text-gray-500">Nessun gruppo di servizio.</li> <li class="px-4 py-8 text-center text-sm text-gray-500">Nessun gruppo di servizio.</li>

View File

@@ -7,15 +7,10 @@
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1> <h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong> Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong>
@if($attivo && $gruppo->sorvegliante?->utente) @if($gruppo->sorvegliante?->utente)
· accede con <code>{{ $gruppo->sorvegliante->utente->email }}</code> · accede con <code class="select-all">{{ $gruppo->sorvegliante->utente->email }}</code>
@endif @endif
</p> </p>
@unless($attivo)
<p class="mt-2 inline-flex rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700">
Sciolto il {{ $gruppo->sciolto_at->format('d/m/Y') }} · {{ $assegnazioniDelGruppo }} assegnazioni nate dal gruppo restano nello storico
</p>
@endunless
</div> </div>
@if($passwordTemporanea) @if($passwordTemporanea)
@@ -25,7 +20,6 @@
</div> </div>
@endif @endif
@if($attivo)
{{-- ─── Pacchetto ─────────────────────────────────────────── --}} {{-- ─── Pacchetto ─────────────────────────────────────────── --}}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-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"> <h2 id="titolo-pacchetto" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">
@@ -55,27 +49,65 @@
@endforelse @endforelse
</ul> </ul>
{{-- Assegnazione in blocco: si spuntano i territori in reparto --}} {{-- Assegnazione in blocco: si spuntano i territori in reparto, in
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile"> 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> <h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi territori al pacchetto</h3>
@if($candidatiTerritori->isEmpty()) @if($totaleCandidati === 0)
<p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p> <p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p>
@else @else
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i territori in reparto" <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 mb-2">
placeholder="Filtra per numero o zona…" <input wire:model.live.debounce.300ms="cercaTerritorio" type="search" inputmode="search" autocomplete="off" aria-label="Cerca tra i territori in reparto"
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500"> placeholder="Numero, zona o tipologia…"
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100"> class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
@foreach($candidatiTerritori as $c) <select wire:model.live="filtroZona" aria-label="Zona"
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40" class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->numero . ' ' . $c->zona?->nome)) }}" <option value="">Tutte le zone</option>
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-t-{{ $c->id }}"> @foreach($zoneCandidati as $id => $nome)
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviTerritori" class="h-5 w-5 rounded border-gray-300 text-indigo-600"> <option value="{{ $id }}">{{ $nome }}</option>
<span><strong>N° {{ $c->numero }}</strong> <span class="text-gray-500">{{ $c->zona?->nome }}</span></span>
</label>
@endforeach @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>
<div class="mt-3"> <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> <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> </div>
@endif @endif
</div> </div>
@@ -127,8 +159,7 @@
<x-btn variante="secondary" wire:click="nuovaPassword" <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> 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" <x-btn variante="danger" wire:click="sciogli"
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene disattivata. Le assegnazioni già fatte restano.">Togli il ruolo e sciogli il gruppo</x-btn> 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> </div>
</section> </section>
@endif
</div> </div>

View File

@@ -1,8 +1,11 @@
<div> <div x-data="{ anteprima: null }">
{{-- Il mio gruppo (MioGruppo): la sola pagina del sorvegliante di gruppo. {{-- Il mio gruppo (MioGruppo): la sola pagina del sorvegliante di gruppo.
Pensata prima per il telefono: schede una sotto l'altra, pulsanti Pensata prima per il telefono: schede una sotto l'altra, pulsanti
grandi. Copia, Condividi e ricerca vengono da elencoCercabile grandi. Copia, Condividi e ricerca vengono da elencoCercabile
(resources/js/app.js), l'invio del link dal partial _invio-link. --}} (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) @if(! $gruppo)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-sm text-gray-600"> <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. Non sei il sorvegliante di un gruppo di servizio attivo.
@@ -10,11 +13,14 @@
@else @else
<div class="mb-5"> <div class="mb-5">
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1> <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 {{-- In questa vista solo blocchi php…endphp: la forma in linea, se
viene prima di un blocco, confonde il compilatore di Blade. --}} viene prima di un blocco, confonde il compilatore di Blade. --}}
@php $daDare = $territori->filter(fn ($t) => ! $t->assegnazioneCorrente)->count(); @endphp @php $daDare = $pacchetto->filter(fn ($t) => ! $t->assegnazioneCorrente)->count(); @endphp
<p class="text-sm text-gray-500 mt-1"> <p class="text-sm text-gray-500 mt-1">
{{ $territori->count() }} {{ $territori->count() === 1 ? 'territorio' : 'territori' }} nel pacchetto · {{ $pacchetto->count() }} {{ $pacchetto->count() === 1 ? 'territorio' : 'territori' }} nel pacchetto ·
{{ $daDare }} da dare · {{ $membri->count() }} {{ $membri->count() === 1 ? 'membro' : 'membri' }} {{ $daDare }} da dare · {{ $membri->count() }} {{ $membri->count() === 1 ? 'membro' : 'membri' }}
</p> </p>
</div> </div>
@@ -29,14 +35,45 @@
<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> <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> </svg>
<div class="px-4 py-3 border-b border-gray-100 flex flex-wrap items-center justify-between gap-2"> <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> <h2 id="titolo-pacchetto" class="text-sm font-semibold text-gray-800">Territori del pacchetto</h2>
@if($territori->count() > 5) @if($pacchetto->count() > 5)
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto" <input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto"
placeholder="Cerca numero, zona o nome…" 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"> 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 @endif
</div> </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"> <div class="divide-y divide-gray-100">
@forelse($territori as $t) @forelse($territori as $t)
@@ -47,11 +84,16 @@
@endphp @endphp
<div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato"> <div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
<div class="tm-assegnato-testa"> <div class="tm-assegnato-testa">
<span><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif</span> <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) @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> <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 @else
<span class="tm-assegnato-info"><b>Da dare</b></span> <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 @endif
</div> </div>
@@ -84,6 +126,10 @@
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500"> 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 @error('dataAssegnazione') <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
</label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<x-btn variante="primary" wire:click="assegna">Assegna</x-btn> <x-btn variante="primary" wire:click="assegna">Assegna</x-btn>
<x-btn variante="ghost" wire:click="annullaAssegnazione">Annulla</x-btn> <x-btn variante="ghost" wire:click="annullaAssegnazione">Annulla</x-btn>
@@ -101,7 +147,9 @@
@endif @endif
</div> </div>
@empty @empty
<p class="px-4 py-8 text-center text-sm text-gray-500">Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo.</p> <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 @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> <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> </div>
@@ -111,7 +159,7 @@
<h2 id="titolo-membri" class="text-sm font-semibold text-gray-800 mb-2">Membri del gruppo</h2> <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"> <ul class="text-sm text-gray-700 space-y-1">
@foreach($membri as $m) @foreach($membri as $m)
@php $quanti = $territori->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp @php $quanti = $pacchetto->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp
<li class="flex justify-between gap-3"> <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>{{ $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> <span class="text-gray-500">{{ $quanti }} {{ $quanti === 1 ? 'territorio' : 'territori' }}</span>
@@ -119,5 +167,27 @@
@endforeach @endforeach
</ul> </ul>
</section> </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 @endif
</div> </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

@@ -18,8 +18,9 @@
<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 }}), se sono attivi e l'eventuale gruppo di servizio di cui fanno parte. 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, tranne per i sorveglianti di gruppo), password conservata solo come impronta non reversibile, ruolo. L'utenza di un sorvegliante è collegata al suo proclamatore e viene disattivata quando il gruppo si scioglie.</li> <li><strong>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. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p> <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, insieme a nome ed email dell'utenza se è stato sorvegliante di un gruppo.</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())
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
@else
<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 table-fixed text-sm table-cards">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th> <th class="w-[36%] 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="w-[15%] 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="w-[15%] 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="w-[9%] px-3 py-2 text-right 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> <th class="w-[25%] px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
</tr> </tr>
</thead> </thead>
@foreach($assegnazioniPerAnno as $annoLabel => $assegnazioni)
<tbody class="divide-y divide-gray-100"> <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) @foreach($assegnazioni as $assegnazione)
<tr> <tr>
<td data-label="Territorio" class="px-3 py-2"> <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"> <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
{{ $assegnazione->territorio?->numero ?? 'N/A' }}
</a>
</td> </td>
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td> <td data-label="Assegnato" class="px-3 py-2 tabular-nums">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2"> <td data-label="Rientrato" class="px-3 py-2 tabular-nums">
@if($assegnazione->returned_at) @if($assegnazione->returned_at)
{{ $assegnazione->returned_at->format('d/m/Y') }} {{ $assegnazione->returned_at->format('d/m/Y') }}
@else @else
<span class="text-amber-600 font-medium">In corso</span> <span class="text-amber-600 font-medium">In corso</span>
@endif @endif
</td> </td>
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</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"> <td data-label="Campagna" class="px-3 py-2">
@if($assegnazione->counted_in_campaign) @if($assegnazione->counted_in_campaign)
<span class="text-green-600">{{ $assegnazione->campagna?->descrizione }}</span> <span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
@elseif($assegnazione->counted_in_campaign === false)
<span class="text-gray-400">No</span>
@else @else
<span class="text-gray-400">-</span> <span class="text-gray-300">-</span>
@endif @endif
</td> </td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
@endforeach
</table> </table>
</div> </div>
</div> @endif
@empty
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
@endforelse
</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' }}@if($a->gruppo)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700" title="Assegnato dal sorvegliante del gruppo di servizio">via gruppo {{ $a->gruppo->nome }}</span>@endif</td> <td data-label="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">
@@ -130,10 +143,16 @@
</td> </td>
<td class="px-4 py-2 cell-actions"> <td class="px-4 py-2 cell-actions">
@if($user->proclamatore_id) @if($user->proclamatore_id)
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo. --}} {{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo;
<span class="text-xs text-gray-500"> da qui solo la password, se l'ha persa. --}}
{{ $user->disattivato_at ? 'Disattivata (gruppo sciolto)' : 'Sorvegliante: si gestisce da Gruppi di servizio' }} <div class="flex flex-wrap items-center gap-2">
</span> <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 @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>

View File

@@ -161,33 +161,41 @@
<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).
Ordine: dal più recente, per anno e per data di assegnazione. --}}
@if($assegnazioniPerAnno->isEmpty())
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
@else
<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 table-fixed text-sm table-cards">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th> <th class="w-[36%] 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="w-[15%] 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="w-[15%] 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="w-[9%] px-3 py-2 text-right 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> <th class="w-[25%] px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
</tr> </tr>
</thead> </thead>
@foreach($assegnazioniPerAnno as $annoLabel => $assegnazioni)
<tbody class="divide-y divide-gray-100"> <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) @foreach($assegnazioni as $assegnazione)
<tr> <tr>
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}@if($assegnazione->gruppo)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo->nome }}</span>@endif</td> <td data-label="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">{{ $assegnazione->assigned_at->format('d/m/Y') }}</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"> <td data-label="Rientrato" class="px-3 py-2 tabular-nums">
@if($assegnazione->returned_at) @if($assegnazione->returned_at)
{{ $assegnazione->returned_at->format('d/m/Y') }} {{ $assegnazione->returned_at->format('d/m/Y') }}
@else @else
<span class="text-amber-600 font-medium">In corso</span> <span class="text-amber-600 font-medium">In corso</span>
@endif @endif
</td> </td>
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</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"> <td data-label="Campagna" class="px-3 py-2">
@if($assegnazione->counted_in_campaign) @if($assegnazione->counted_in_campaign)
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span> <span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
@@ -200,11 +208,9 @@
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
@endforeach
</table> </table>
</div> </div>
</div> @endif
@empty
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
@endforelse
</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,6 +23,7 @@ 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\GruppiIndex;
use App\Livewire\Gruppi\GruppoShow; use App\Livewire\Gruppi\GruppoShow;
@@ -68,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
@@ -165,6 +172,7 @@ 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');
}); });
// Il rientro lo registra anche il sorvegliante di gruppo, ma solo per il // Il rientro lo registra anche il sorvegliante di gruppo, ma solo per il
// suo gruppo: il controllo fine è in Rientra::autorizza(). // suo gruppo: il controllo fine è in Rientra::autorizza().
@@ -178,6 +186,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('mio-gruppo', MioGruppo::class)->name('mio-gruppo'); Route::get('mio-gruppo', MioGruppo::class)->name('mio-gruppo');
Route::get('mio-gruppo/territori/{territorio}/pdf', [TerritorioFileController::class, 'pdfGruppo']) Route::get('mio-gruppo/territori/{territorio}/pdf', [TerritorioFileController::class, 'pdfGruppo'])
->name('mio-gruppo.pdf'); ->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

View File

@@ -68,12 +68,12 @@ class GruppiServizioTest extends TestCase
$rossi = $this->proclamatore('Rossi', 'Mario'); $rossi = $this->proclamatore('Rossi', 'Mario');
$verdi = $this->proclamatore('Verdi', 'Anna'); $verdi = $this->proclamatore('Verdi', 'Anna');
$bianchi = $this->proclamatore('Bianchi', 'Luca'); $bianchi = $this->proclamatore('Bianchi', 'Luca');
[$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, 'mario@example.test', [$verdi->id, $bianchi->id]); [$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, [$verdi->id, $bianchi->id]);
$territori = collect(['12', '14', '35'])->map(fn ($n) => $this->territorio($n)); $territori = collect(['12', '14', '35'])->map(fn ($n) => $this->territorio($n));
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all()); app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all());
$territori = $territori->map->fresh(); $territori = $territori->map->fresh();
$sorvegliante = User::where('email', 'mario@example.test')->sole(); $sorvegliante = User::where('email', 'mrossi@termanager.it')->sole();
// Il cambio password al primo accesso ha i suoi test: qui si lavora. // Il cambio password al primo accesso ha i suoi test: qui si lavora.
// E l'email non confermata, come nella realtà (TestCase le conferma). // E l'email non confermata, come nella realtà (TestCase le conferma).
$sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save(); $sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save();
@@ -87,15 +87,17 @@ class GruppiServizioTest extends TestCase
{ {
$rossi = $this->proclamatore('Rossi', 'Mario'); $rossi = $this->proclamatore('Rossi', 'Mario');
// L'email non si chiede: è fittizia, iniziale del nome e cognome.
$pagina = Livewire::test(GruppiIndex::class) $pagina = Livewire::test(GruppiIndex::class)
->assertDontSee('Email per l')
->set('nome', 'Zisa') ->set('nome', 'Zisa')
->set('sorveglianteId', $rossi->id) ->set('sorveglianteId', $rossi->id)
->set('email', 'mario@example.test')
->call('crea') ->call('crea')
->assertHasNoErrors(); ->assertHasNoErrors()
->assertSee('mrossi@termanager.it');
$gruppo = GruppoServizio::sole(); $gruppo = GruppoServizio::sole();
$utente = User::where('email', 'mario@example.test')->sole(); $utente = User::where('email', 'mrossi@termanager.it')->sole();
$this->assertSame($rossi->id, $gruppo->sorvegliante_id); $this->assertSame($rossi->id, $gruppo->sorvegliante_id);
$this->assertSame($gruppo->id, $rossi->fresh()->gruppo_servizio_id, 'Il sorvegliante è anche membro'); $this->assertSame($gruppo->id, $rossi->fresh()->gruppo_servizio_id, 'Il sorvegliante è anche membro');
@@ -114,10 +116,28 @@ class GruppiServizioTest extends TestCase
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->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 public function test_un_proclamatore_sta_in_un_solo_gruppo(): void
{ {
['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo(); ['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo();
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'), 'neri@example.test'); [$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'));
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiMembri($noce, [$verdi->id]); app(GruppiServizio::class)->aggiungiMembri($noce, [$verdi->id]);
@@ -135,6 +155,33 @@ class GruppiServizioTest extends TestCase
$this->assertNotNull($sorvegliante->fresh()); $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 ────────────────────────────────────────────── // ─── Pacchetto ──────────────────────────────────────────────
public function test_il_pacchetto_non_e_un_assegnazione_e_non_e_in_reparto(): void public function test_il_pacchetto_non_e_un_assegnazione_e_non_e_in_reparto(): void
@@ -199,15 +246,42 @@ class GruppiServizioTest extends TestCase
$this->get(route('dashboard'))->assertOk()->assertDontSee('Il mio gruppo')->assertSee('Gruppi di servizio'); $this->get(route('dashboard'))->assertOk()->assertDontSee('Il mio gruppo')->assertSee('Gruppi di servizio');
} }
public function test_il_sorvegliante_entra_senza_conferma_dell_email_e_va_al_suo_gruppo(): void 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->gruppo();
$this->flushSession(); $this->flushSession();
auth()->logout(); auth()->logout();
$password = 'una-password-nuova-1'; $password = 'una-password-nuova-1';
User::where('email', 'mario@example.test')->sole()->forceFill(['password' => $password])->save(); User::where('email', 'mrossi@termanager.it')->sole()->forceFill(['password' => $password])->save();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password]) $this->post(route('login.store'), ['email' => 'mrossi@termanager.it', 'password' => $password])
->assertRedirect(route('mio-gruppo')); ->assertRedirect(route('mio-gruppo'));
} }
@@ -232,6 +306,23 @@ class GruppiServizioTest extends TestCase
$this->assertTrue($a->link_da_inviare); $this->assertTrue($a->link_da_inviare);
$this->assertSame($gruppo->id, $territori[0]->fresh()->gruppo_servizio_id, 'Resta nel pacchetto finché non rientra'); $this->assertSame($gruppo->id, $territori[0]->fresh()->gruppo_servizio_id, 'Resta nel pacchetto finché non rientra');
$this->assertSame('assegnato', $territori[0]->fresh()->stato); $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 public function test_il_sorvegliante_puo_tenere_un_territorio_per_se(): void
@@ -305,6 +396,29 @@ class GruppiServizioTest extends TestCase
$this->assertSame('in_reparto', $territori[2]->fresh()->stato); $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 public function test_la_piantina_del_pacchetto_si_apre_solo_per_il_proprio_gruppo(): void
{ {
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo(); ['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
@@ -320,28 +434,47 @@ class GruppiServizioTest extends TestCase
public function test_togliere_il_ruolo_scioglie_il_gruppo(): void public function test_togliere_il_ruolo_scioglie_il_gruppo(): void
{ {
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo(); ['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()); $data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
auth()->setUser($this->admin);
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])->call('sciogli'); Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])
->call('sciogli')
->assertRedirect(route('gruppi.index'));
$gruppo->refresh(); // Il gruppo non resta nello storico: non esiste più.
$this->assertNotNull($gruppo->sciolto_at); $this->assertNull(GruppoServizio::find($gruppo->id));
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione normale. $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->assertNull($territori[1]->fresh()->gruppo_servizio_id);
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id); $this->assertSame('in_reparto', $territori[2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at); $this->assertNull($data->fresh()->returned_at);
$this->assertSame($gruppo->id, $data->fresh()->gruppo_servizio_id, 'Lo storico continua a dirlo'); $this->assertSame('assegnato', $territori[0]->fresh()->stato);
// Membri liberi, utenza disattivata e senza ruolo. $this->assertNull($data->fresh()->gruppo_servizio_id);
$this->assertSame(0, Proclamatore::where('gruppo_servizio_id', $gruppo->id)->count()); $this->assertSame('Zisa', $data->fresh()->gruppo_nome);
$this->assertTrue($s->fresh()->isDisattivato()); $this->get(route('territori.show', $territori[0]))->assertOk()->assertSee('via gruppo Zisa');
$this->assertFalse($s->fresh()->hasRole('sorvegliante')); // 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. // Per ripartire serve un gruppo nuovo.
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$territori[1]->id]); app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$territori[1]->id]);
} }
public function test_un_utenza_disattivata_non_entra_e_viene_buttata_fuori(): void public function test_sciolto_il_gruppo_l_ex_sorvegliante_non_entra_piu(): void
{ {
['gruppo' => $gruppo, 'sorvegliante' => $s] = $this->gruppo(); ['gruppo' => $gruppo, 'sorvegliante' => $s] = $this->gruppo();
$password = 'una-password-nuova-1'; $password = 'una-password-nuova-1';
@@ -350,63 +483,183 @@ class GruppiServizioTest extends TestCase
$this->flushSession(); $this->flushSession();
auth()->logout(); auth()->logout();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password]) $this->post(route('login.store'), ['email' => 'mrossi@termanager.it', 'password' => $password])
->assertSessionHasErrors('email'); ->assertSessionHasErrors('email');
$this->assertGuest(); $this->assertGuest();
$this->actingAs($s->fresh())->get(route('profile'))->assertRedirect(route('login'));
} }
public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo_con_la_stessa_utenza(): void public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo(): void
{ {
['gruppo' => $vecchio, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo(); ['gruppo' => $vecchio, 'rossi' => $rossi] = $this->gruppo();
app(GruppiServizio::class)->sciogli($vecchio); app(GruppiServizio::class)->sciogli($vecchio);
$pagina = Livewire::test(GruppiIndex::class); $pagina = Livewire::test(GruppiIndex::class);
$this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati'); $this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati');
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa 2', $rossi->fresh(), 'mario.nuovo@example.test'); // Sciolto, il nome del gruppo e l'email sono di nuovo liberi.
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi->fresh());
$utente = $s->fresh(); $utente = User::where('proclamatore_id', $rossi->id)->sole();
$this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza');
$this->assertFalse($utente->isDisattivato());
$this->assertTrue($utente->hasRole('sorvegliante')); $this->assertTrue($utente->hasRole('sorvegliante'));
$this->assertSame('mario.nuovo@example.test', $utente->email); $this->assertSame('mrossi@termanager.it', $utente->email);
$this->assertTrue($utente->password_change_required); $this->assertTrue($utente->password_change_required);
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password)); $this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
$this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id); $this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id);
} }
// ─── Proclamatori: cestino, anonimizzazione, registro ─────── // ─── Sorvegliante non più attivo: il gruppo si scioglie ─────
public function test_il_sorvegliante_di_un_gruppo_attivo_non_si_cestina_ne_si_anonimizza(): void /** Il gruppo è sciolto come da "Togli il ruolo": tutto tranne l'assegnazione già fatta. */
private function assertSciolto(array $g, Assegnazione $data): void
{ {
['rossi' => $rossi, 'verdi' => $verdi] = $this->gruppo(); $this->assertNull(GruppoServizio::find($g['gruppo']->id), 'Gruppo sciolto ed eliminato');
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class) $this->assertNull($g['territori'][1]->fresh()->gruppo_servizio_id, 'I non dati tornano in reparto');
->call('deleteProclamatore', $rossi->id) $this->assertSame('in_reparto', $g['territori'][2]->fresh()->stato);
->call('anonimizza', $rossi->id); $this->assertNull($data->fresh()->returned_at, 'Quello già dato resta al membro');
$this->assertFalse($rossi->fresh()->trashed()); $this->assertNull($g['bianchi']->fresh()->gruppo_servizio_id, 'Membri liberi');
$this->assertSame('Rossi', $rossi->fresh()->cognome); $this->assertNull(User::find($g['sorvegliante']->id), 'Utenza eliminata');
// Un membro qualsiasi sì, e lascia il gruppo.
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('deleteProclamatore', $verdi->id);
$this->assertTrue($verdi->fresh()->trashed());
$this->assertNull($verdi->fresh()->gruppo_servizio_id);
} }
public function test_anonimizzare_un_ex_sorvegliante_anonimizza_anche_la_sua_utenza(): void 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(); ['gruppo' => $gruppo, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
app(GruppiServizio::class)->sciogli($gruppo);
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id); Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id);
$utente = $s->fresh(); $this->assertNull(GruppoServizio::find($gruppo->id));
$this->assertSame('Anonimo', $utente->name); $this->assertNull(User::find($s->id), "Nome ed email dell'utenza spariscono con lei");
$this->assertStringEndsWith('@anonimo.invalid', $utente->email); $this->assertSame('Anonimo', Proclamatore::withTrashed()->find($rossi->id)->nome);
$this->assertTrue($utente->isDisattivato());
$this->assertCount(0, $utente->roles);
} }
public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void
@@ -423,6 +676,110 @@ class GruppiServizioTest extends TestCase
} }
} }
// ─── 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 ─────────────────────────────────── // ─── Backup e azzeramento ───────────────────────────────────
public function test_l_azzeramento_toglie_anche_gruppi_e_utenza_del_sorvegliante(): void public function test_l_azzeramento_toglie_anche_gruppi_e_utenza_del_sorvegliante(): void
@@ -443,17 +800,37 @@ class GruppiServizioTest extends TestCase
public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void
{ {
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo(); ['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now()); app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now(), cartaceo: true);
$xml = (new XmlExchange())->currentXmlBackup(); $xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml)); $this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$this->assertTrue(Assegnazione::sole()->cartaceo, 'Il segno del cartaceo passa per l\'XML');
$g = GruppoServizio::sole(); $g = GruppoServizio::sole();
$this->assertSame('Zisa', $g->nome); $this->assertSame('Zisa', $g->nome);
$this->assertSame('Rossi Mario', $g->sorvegliante->nome_completo); $this->assertSame('Rossi Mario', $g->sorvegliante->nome_completo);
$this->assertEqualsCanonicalizing(['Rossi Mario', 'Verdi Anna', 'Bianchi Luca'], $g->membri->map->nome_completo->all()); $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->assertEqualsCanonicalizing(['12', '14', '35'], $g->territori->pluck('numero')->all());
$this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id); $this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id);
$this->assertSame($g->sorvegliante_id, User::where('email', 'mario@example.test')->sole()->proclamatore_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'));
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
/**
* L'immagine deve partire da una versione di PHP fissata alla patch, e mai
* sotto quella che corregge CVE-2026-109618 e CVE-2026-109619 (esecuzione di
* codice da remoto nel core di PHP, 21/10/2026). Con un tag che si sposta
* ("8.3-fpm") la cache di Docker poteva riusare una base vulnerabile.
*/
class VersionePhpTest extends TestCase
{
private const MINIMA = '8.3.36';
private function dockerfile(): string
{
return (string) file_get_contents(dirname(__DIR__, 2) . '/docker/php/Dockerfile');
}
public function test_la_versione_di_php_e_fissata_e_corretta(): void
{
// \s* prima di $: nella copia di lavoro su Windows il file ha CRLF.
$this->assertMatchesRegularExpression('/^ARG PHP_VERSION=(\d+\.\d+\.\d+)\s*$/m', $this->dockerfile());
preg_match('/^ARG PHP_VERSION=(\d+\.\d+\.\d+)\s*$/m', $this->dockerfile(), $trovata);
$this->assertTrue(
version_compare($trovata[1], self::MINIMA, '>='),
"PHP {$trovata[1]} è vulnerabile a CVE-2026-109618/109619: serve almeno " . self::MINIMA . '.',
);
}
public function test_la_mitigazione_delle_sessioni_resta_nel_php_ini(): void
{
// Indicata da Sansec per CVE-2026-109618/109619; all'applicazione non
// serve, quindi resta anche dopo la patch.
$ini = (string) file_get_contents(dirname(__DIR__, 2) . '/docker/php/php.ini');
$this->assertMatchesRegularExpression('/^session\.upload_progress\.enabled\s*=\s*0\s*$/m', $ini);
$this->assertMatchesRegularExpression('/^session\.use_strict_mode\s*=\s*1\s*$/m', $ini);
}
public function test_l_immagine_usa_la_versione_fissata_e_non_un_tag_mobile(): void
{
preg_match_all('/^FROM php:(\S+)/m', $this->dockerfile(), $basi);
$this->assertNotEmpty($basi[1]);
foreach ($basi[1] as $tag) {
$this->assertStringStartsWith('${PHP_VERSION}', $tag, "FROM php:{$tag}: usa ARG PHP_VERSION, non un tag che si sposta.");
}
}
}