Compare commits

..

17 Commits

Author SHA1 Message Date
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
48 changed files with 2014 additions and 756 deletions

View File

@@ -4,204 +4,214 @@ 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: **20 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 è **Produzione** (`main` → compose `termanager2-core-production`) in esercizio
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti, su Dokploy dal 19 ottobre 2026. Ultimo rilascio: `d3314a9` (proclamatori senza
redeploy, login, tutte le pagine, code e scheduler. 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.
**Per portarli in produzione**: backup del database di produzione, `main`
avanti fino a `dev` (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: le quattro 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 | ✅ **577 test PHP + 13 JavaScript, verdi** (20/10/2026); copertura ancora parziale |
| Prove nel browser | ⚠️ le funzioni del 19-20 ottobre sono verificate dai test e sul server; nel browser le ha usate l'utente sulla demo |
**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 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 ### Assegna aperta dalla Home: senza l'elenco dei territori assegnati (20 ottobre 2026)
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
### Posta, contatti dei proclamatori, conferma email (12-13 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.
Richieste dell'utente, con le sue scelte: ### Storico assegnazioni in linea (20 ottobre 2026)
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta Richiesta dell'utente (screenshot della scheda territorio): le colonne dello
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura storico cambiavano posizione da un anno all'altro, perché ogni anno era una
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante tabella a sé dimensionata sul suo contenuto. Ora, nella scheda del territorio
"Invia email di prova". Se compilato prende il posto del `.env` e in quella del proclamatore, è **una tabella sola** con un'intestazione,
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail colonne a larghezza fissa (`table-fixed`, `w-[..%]`), e l'anno come riga di
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la separazione `tr.tm-riga-gruppo` (in `app.css`, su telefono resta un titolo e
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era non diventa una scheda). Ordine esplicito: anni dal più recente
un valore non valido per Laravel 11: ora vuoto. (`sortKeysDesc`), dentro l'anno per data di assegnazione. Nella scheda del
- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome, proclamatore la colonna Campagna ora dice "Sì — …" / "No" come in quella del
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre territorio, e c'è il badge "via gruppo …". Test: `StoricoAssegnazioniTest`.
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: ### Rientro dall'elenco dei territori assegnati (20 ottobre 2026)
email di prova e di conferma arrivate, link aperto senza login, account
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
e dati di prova rimossi dopo.
### Gruppi di servizio (19 ottobre 2026, solo su dev) Richiesta dell'utente ("un 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.
- **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.
sorvegliante; anche cambiandola dal profilo). - **Assegnazione dal pacchetto**: un'**assegnazione normale**, con
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`): `gruppo_servizio_id` e `gruppo_nome` ("via gruppo …" in registro e schede).
non dati in reparto, membri liberi, utenza senza ruolo e disattivata `GruppiServizio::assegna()` rilegge territorio e membro e ricontrolla il
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da pacchetto sotto lock. Al **rientro** il territorio esce dal pacchetto e
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I torna in reparto (hook `updated` in `Assegnazione`, da qualunque pagina).
territori già dati restano ai membri. Si riparte con un gruppo nuovo. - **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 +312,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 +416,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 +435,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 +545,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 +1605,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 — 577 test PHP e 13 JavaScript, tutti verdi (20/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 +1624,21 @@ 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), `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 +1653,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 +1738,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 +1844,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

139
README.md
View File

@@ -1,8 +1,9 @@
# 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) 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.
@@ -411,23 +412,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
@@ -687,9 +691,11 @@ 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
### Territori ### Territori
- CRUD completo con upload PDF e miniatura generata automaticamente - CRUD completo con upload PDF e miniatura generata automaticamente
@@ -698,7 +704,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,7 +736,9 @@ 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"
@@ -741,27 +751,56 @@ docker compose --profile dev up -d --build
**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
dalla pagina del gruppo
- 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
- 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
@@ -873,16 +912,16 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| Tabella | Descrizione | | Tabella | Descrizione |
|-------------------|--------------------------------------------------------------| |-------------------|--------------------------------------------------------------|
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova; `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 |
| `activity_log` | Audit trail | | `activity_log` | Audit trail |
**Vincoli e indici principali** **Vincoli e indici principali**
@@ -916,10 +955,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
@@ -967,14 +1012,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 +1043,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

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

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

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

@@ -23,11 +23,19 @@ class Assegna extends Component
// 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');
// ?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).

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,30 @@ 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); $password = Str::password(12, symbols: false);
$utente->forceFill(['password' => $password, 'password_change_required' => true])->save(); $utente->forceFill(['password' => $password, 'password_change_required' => true, 'password_temporanea' => true])->save();
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata'); activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
$this->passwordTemporanea = $password; $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 +184,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,20 @@ class MioGruppo extends Component
public string $dataAssegnazione = ''; public string $dataAssegnazione = '';
// 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');
@@ -170,11 +185,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 +211,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

@@ -18,35 +18,10 @@ class Home extends Component
$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 +65,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 +123,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'])

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

@@ -3,13 +3,10 @@
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\User; use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
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
@@ -115,7 +112,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 +229,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();

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'],
]); ]);
} }
@@ -809,6 +818,7 @@ class XmlExchange extends Component
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1, 'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1, 'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null, '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 +875,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', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']),
]; ];
} }
@@ -1278,7 +1287,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 +1360,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);
} }
@@ -1382,6 +1389,7 @@ class XmlExchange extends Component
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0))); $node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0))); $node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? '')); $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

@@ -26,6 +26,7 @@ class Assegnazione extends Model
'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). */

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();
}
} }
} }

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ò

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

@@ -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,30 @@ class GruppiServizio
return [$gruppo, $password]; return [$gruppo, $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
{ {
@@ -205,6 +230,8 @@ 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,
]); ]);
}); });
@@ -225,48 +252,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,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

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

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

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

@@ -40,7 +40,9 @@
@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. --}}
<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</span>
@@ -50,7 +52,7 @@
<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 +61,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

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

@@ -10,11 +10,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 +32,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 +81,12 @@
@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><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @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</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>
@@ -101,7 +136,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 +148,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>

View File

@@ -110,19 +110,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,8 @@
<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) 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>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>
@@ -58,7 +58,7 @@
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li> <li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
@endforeach @endforeach
</ul> </ul>
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. 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>
</section> </section>
<section> <section>
@@ -100,7 +100,7 @@
<section> <section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2> <h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile, 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>

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

@@ -132,7 +132,7 @@
@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"> <span class="text-xs text-gray-500">
{{ $user->disattivato_at ? 'Disattivata (gruppo sciolto)' : 'Sorvegliante: si gestisce da Gruppi di servizio' }} Sorvegliante: si gestisce da Gruppi di servizio
</span> </span>
@else @else
<div class="flex gap-2"> <div class="flex gap-2">

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

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

@@ -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]);
@@ -199,15 +219,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'));
} }
@@ -320,28 +367,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 +416,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 +609,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
@@ -454,6 +744,25 @@ class GruppiServizioTest extends TestCase
$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,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'));
}
}