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
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
L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti,
redeploy, login, tutte le pagine, code e scheduler.
**Produzione** (`main` → compose `termanager2-core-production`) in esercizio
su Dokploy dal 19 ottobre 2026. Ultimo rilascio: `d3314a9` (proclamatori senza
contatti, il link si copia o si condivide). Backup fatti prima dei rilasci in
`/etc/dokploy/termanager2-backups/` sul server.
**Demo** (`dev` → compose `termanager2-core-demo`,
`demo-termanager.pyconetwork.it`): avanti di una ventina di commit, tutti
provati con la suite sul server e pubblicati sulla demo. **Non ancora in
produzione** — l'utente ha chiesto di aspettare il suo ok:
- [gruppi di servizio](#gruppi-di-servizio-19-20-ottobre-2026-solo-su-dev):
sorvegliante con email fittizia, pacchetto di territori, "Il mio gruppo",
scioglimento; migrazioni `2026_10_20_000010` … `000040`;
- priorità e filtri nel pacchetto, con la regola di priorità della Home in un
metodo solo (`Territorio::ordinaPerPriorita()`);
- pulsante **Rientro** nell'elenco dei territori assegnati;
- storico assegnazioni in una tabella sola, con le colonne in linea;
- *Assegna* aperta da "Assegna →" della Home senza l'elenco dei territori
assegnati.
**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 |
|-----------------------------|-------|
| 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 |
| Redeploy senza interventi | ✅ ~25 secondi |
| 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) |
| 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:
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.
La storia precedente (luglio-agosto: backup e ripristino, sotto-app Piantine,
sistema visivo, prima suite di test) è in [Da dove veniamo](#da-dove-veniamo).
---
## Lavori 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):
- `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); le ultime due righe
della tabella sono nel commit successivo.
Dal più recente. Le sezioni del 12-19 ottobre sono in produzione; quelle del
20 ottobre e i gruppi di servizio solo sulla demo.
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
### Assegna aperta dalla Home: senza l'elenco dei territori assegnati (20 ottobre 2026)
### 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
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante
"Invia email di prova". Se compilato prende il posto del `.env`
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era
un valore non valido per Laravel 11: ora vuoto.
- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome,
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
considera italiano).
- **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
dopo l'assegnazione" qui sotto*. Il riquadro in sessione
(`Assegna::DA_INVIARE`) non esiste più.
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`).
L'admin crea l'utente con la password come prima; parte l'email di conferma
(in italiano, `VerifyEmail::toMailUsing`); il link funziona anche senza
login. Cambiare email (profilo o admin) la rende da confermare e rimanda il
link. In Utenti: stato "da confermare" e "Reinvia conferma".
Raggiungibili senza conferma: profilo, cambio password, pagina di conferma
e — per l'amministratore — il server di posta.
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
creato da riga di comando nasce confermato.
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
`$confermaUtentiCreati`).
Richiesta dell'utente (screenshot della scheda territorio): le colonne dello
storico cambiavano posizione da un anno all'altro, perché ogni anno era una
tabella a sé dimensionata sul suo contenuto. Ora, nella scheda del territorio
e in quella del proclamatore, è **una tabella sola** con un'intestazione,
colonne a larghezza fissa (`table-fixed`, `w-[..%]`), e l'anno come riga di
separazione `tr.tm-riga-gruppo` (in `app.css`, su telefono resta un titolo e
non diventa una scheda). Ordine esplicito: anni dal più recente
(`sortKeysDesc`), dentro l'anno per data di assegnazione. Nella scheda del
proclamatore la colonna Campagna ora dice "Sì — …" / "No" come in quella del
territorio, e c'è il badge "via gruppo …". Test: `StoricoAssegnazioniTest`.
Provato nel browser in locale con mailpit come server configurato dall'app:
email di prova e di conferma arrivate, link aperto senza login, account
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
e dati di prova rimossi dopo.
### Rientro dall'elenco dei territori assegnati (20 ottobre 2026)
### 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,
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**
gruppo; il sorvegliante è anche membro e assegna solo ai membri, a sé
compreso.
- Il **pacchetto non è un'assegnazione**: "la media non deve essere alterata".
I territori del pacchetto sono "In gruppo …" (stato `in_gruppo`), fuori da
**Come funziona**
- **Chi**: un gruppo ha un nome (unico), un sorvegliante (proclamatore
attivo) e dei membri. Un proclamatore sta in **un solo** gruppo; il
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`
esclude `gruppo_servizio_id`).
- Quando il sorvegliante dà un territorio nasce un'**assegnazione normale**,
tracciata con `assegnazioni.gruppo_servizio_id` ("via gruppo …" nel
registro e nella scheda del territorio).
- Al **rientro** il territorio esce dal pacchetto e torna in reparto
(hook `updated` in `Assegnazione`, vale da qualunque pagina).
- Il sorvegliante: assegna, manda il link (copia/condividi), registra i
rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e
pacchetti li gestisce **solo l'amministratore**.
- Utenza: email e password temporanea da cambiare al primo accesso, **senza
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il
sorvegliante; anche cambiandola dal profilo).
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
non dati in reparto, membri liberi, utenza senza ruolo e disattivata
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I
territori già dati restano ai membri. Si riparte con un gruppo nuovo.
esclude `gruppo_servizio_id`). Si aggiungono in blocco dai territori in
reparto in ordine di priorità (`Territorio::ordinaPerPriorita()`, la regola
della Home), con ricerca, zona, tipologia, "solo prioritari" e "Seleziona
tutti quelli mostrati" (`GruppoShow`).
- **"Il mio gruppo"** (`MioGruppo`): sotto il titolo nome e cognome del
sorvegliante; prima i territori da dare in ordine di priorità, poi i già
dati dal più vecchio; filtri stato, zona, tipologia, "solo prioritari"
(nell'indirizzo). Il sorvegliante assegna, manda il link (copia o
condividi: trait `Concerns\InviaLink` + partial `assegnazioni/_invio-link`,
gli stessi di *Assegna*), registra i rientri, apre la piantina, rimanda in
reparto quelli non dati.
- **Assegnazione dal pacchetto**: un'**assegnazione normale**, con
`gruppo_servizio_id` e `gruppo_nome` ("via gruppo …" in registro e schede).
`GruppiServizio::assegna()` rilegge territorio e membro e ricontrolla il
pacchetto sotto lock. Al **rientro** il territorio esce dal pacchetto e
torna in reparto (hook `updated` in `Assegnazione`, da qualunque pagina).
- **Scioglimento**: "Togli il ruolo e sciogli il gruppo", oppure il
sorvegliante non è più attivo — reso non attivo, nel cestino o anonimizzato
(`Proclamatore::booted`, eventi `updated` su `attivo` e `deleted`; l'elenco
chiede conferma, la modifica avvisa sotto "Attivo"). `GruppiServizio::sciogli()`
**elimina il gruppo**: i territori solo nel pacchetto tornano in reparto;
quelli già dati restano ai membri e l'assegnazione ne conserva il nome
(confermato dall'utente); i membri restano senza gruppo; l'**utenza del
sorvegliante si elimina** con `User::eliminaConservandoStorico()` — la
stessa eliminazione della pagina *Utenti* — che passa le sue assegnazioni a
chi scioglie (o al primo amministratore) e lascia le sue voci nel registro
a nome "Sorvegliante del gruppo …". Il motivo va nel registro
(`gruppo_sciolto`, `motivo`). Un membro non attivo resta nel gruppo ma non
riceve territori; nel cestino ne esce.
- **Segnalazioni**: `Proclamatore::gruppo_etichetta` ("Gruppo Zisa" /
"Sorvegliante del gruppo Zisa") come badge nell'elenco dei proclamatori
(filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa alla
scheda; nelle opzioni di `<x-scelta>` come `dettaglio` (Assegna, Registro),
non come `motivo`: assegnare direttamente a un membro resta possibile. La
scheda del proclamatore mostra il gruppo che guida
(`GruppoServizio::riepilogo()`, email, password temporanea o no, ultimo
accesso) o quello di cui è membro; il *Profilo* del sorvegliante il suo
gruppo.
- **Dati personali**: nel registro delle attività `User::tapActivity()`
sostituisce nome ed email dell'utenza del sorvegliante con
"proclamatore #id", come per tutti i proclamatori; nella tabella `users`
restano in chiaro, come per ogni utente. Informativa privacy aggiornata.
- **Backup**: l'XML porta gruppi, membri, pacchetto, provenienza delle
assegnazioni (id e nome del gruppo) e il legame utenza ↔ proclamatore; da
un file del 19-20 ottobre non si importano i gruppi con `<sciolto_at>` né
le utenze con `<disattivato_at>`. L'azzeramento (*Zona pericolosa*)
cancella anche i gruppi.
Casi aggiunti di mio, da far confermare all'utente:
- Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la
sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id`
è unico), con email e password temporanea nuove.
- Il sorvegliante di un gruppo **attivo** non si sposta nel cestino né si
anonimizza (`ProclamatoreIndex`): prima si scioglie il gruppo. Un membro
spostato nel cestino lascia il gruppo. `Proclamatore::anonimizza()`
anonimizza anche l'utenza dell'ex sorvegliante (nome, email, password,
ruoli).
- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro
delle attività `User::tapActivity()` li sostituisce con "proclamatore #id",
come vale per tutti i proclamatori. Restano invece in chiaro nella tabella
`users`, come per ogni utente.
**Dove sta**
Dove sta:
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e
assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`,
permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`.
- Regole in `App\Services\GruppiServizio` (crea, aggiungiMembri, togliMembro,
aggiungiAlPacchetto, rimandaInReparto, assegna, sciogli); i componenti
`Gruppi\GruppiIndex`, `Gruppi\GruppoShow` (amministratore) e
`Gruppi\MioGruppo` (sorvegliante) le chiamano soltanto.
- `LimitaSorvegliante` (gruppo `web`): chi ha **solo** il ruolo sorvegliante
raggiunge `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo,
controllo in `Rientra::autorizza`), profilo e cambio password; la Home lo
rimanda al gruppo; il resto è 403.
- L'invio del link è diventato il trait `Concerns\InviaLink` + il partial
`assegnazioni/_invio-link`, usati da *Assegna* e da *Il mio gruppo*.
- Il ruolo sorvegliante non compare nella pagina *Utenti* e le utenze legate a
un proclamatore lì non si modificano né si eliminano: passano dal gruppo.
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
l'amministratore passa ogni `@can` (`Gate::before`).
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
assegnazioni e il legame utenza ↔ proclamatore.
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
del proclamatore).
- L'azzeramento dell'applicazione (*Zona pericolosa*) cancella anche i gruppi.
- Test: `tests/Feature/GruppiServizioTest.php`. Eseguiti sul server **prima**
del push, su una copia della checkout di dev con i commit portati da un
`git bundle` (la checkout di Dokploy è shallow: il bundle deve partire dal
commit che ha, `git log -1` lì dentro).
- Migrazioni: `2026_10_20_000010_gruppi_di_servizio` (tabella, colonne
`gruppo_servizio_id` su proclamatori, territori e assegnazioni,
`users.proclamatore_id` unico, permesso e ruolo; il seeder dà
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`),
`000020` (`assegnazioni.gruppo_nome`, via `sciolto_at` e i gruppi sciolti),
`000030` (via `users.username` e `users.disattivato_at` e le utenze
disattivate, email fittizia a chi non l'aveva, email di nuovo obbligatoria),
`000040` (`users.password_temporanea`).
- Regole in `App\Services\GruppiServizio`; i componenti `Gruppi\GruppiIndex`,
`Gruppi\GruppoShow` (amministratore) e `Gruppi\MioGruppo` (sorvegliante) le
chiamano soltanto.
- Test: `tests/Feature/GruppiServizioTest.php`.
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)
@@ -302,15 +312,17 @@ Proclamatore Semplificato" che non esistono).
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
NULL, non serve decifrare). `Privacy::REVISIONE` è la data del testo.
- Aggiunte: email/telefono cifrati, link alla piantina, invio via WhatsApp
(parte dal telefono di chi assegna) ed email (server di posta configurato),
mappe OpenStreetMap, conferma email, blocco dopo troppi tentativi.
- Aggiunte: link alla piantina, invio dal telefono di chi assegna, mappe
OpenStreetMap, conferma email, blocco dopo troppi tentativi. (Email e
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,
da far rivedere a chi ne ha la competenza.
- **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
telefono **decifrati**. L'informativa ora lo dice; valutare se cifrare
l'archivio (per esempio con una password scelta al download).
legge i proclamatori dal modello e quindi esporta nome e cognome
**decifrati**. L'informativa lo dice; valutare se cifrare l'archivio (per
esempio con una password scelta al download).
- `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli,
i conteggi senza nomi e l'accesso solo amministratore.
@@ -404,14 +416,14 @@ all'**elenco filtrato**, non alla scheda (scelta esplicita).
Richieste dell'utente:
- **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;
- *WhatsApp*: solo se il proclamatore ha un numero utilizzabile;
- *Email*: solo se ha un indirizzo **e** la posta funziona
(`ConfigurazionePosta::funzionante`). La manda l'app con
`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.
(`ConfigurazionePosta::funzionante`), inviata subito e mai in coda.
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
possibile. Solo dopo la risposta si ricarica la pagina.
@@ -423,17 +435,62 @@ Richieste dell'utente:
"Non inviato / Link inviato" dell'elenco.
- Colonne nuove (migrazione `2026_10_19_000010`): `link_da_inviare` (le
assegnazioni già esistenti restano false: niente avvisi a posteriori su
link di cui non si sa niente) e `link_canale` (`copia`/`whatsapp`/`email`,
`Assegnazione::CANALI_LINK`). Entrambe nell'XML.
link di cui non si sa niente) e `link_canale` (oggi `copia`/`condividi`,
`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`:
mai nome, numero o email. Email e telefono restano solo, cifrati, nel
proclamatore.
mai nomi.
- **"Nuovo territorio"** (elenco territori) apre il designer delle piantine
con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in
disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto.
L'editor è solo dell'amministratore: chi ha `territori.manage` ma non
`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
| 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.
### 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
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`) |
|--------------|--------|-------------------------------------|----------------------------|
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-vizcxr` |
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-vbekis` |
| Ambiente | Branch | Dominio | Compose (nome / `appName` / `composeId`) |
|--------------|--------|-------------------------------------|------------------------------------------|
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-production` / `termanager2-core-vizcxr` / `vh_NtgaPyfQintCbcB3p6` |
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-demo` / `termanager2-core-vbekis` / `20DoOKciTzmhlPWdcqcxz` |
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,
che segue `main`. È la separazione su cui contare quando si vuole provare
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
```
**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 |
|------|---------------|
@@ -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/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
`ZipArchive::addFile()` che copriva il messaggio d'errore vero, il `GREATEST()`
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
nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio.
- `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**:
l'ordine staging → database → file, il `failed()` che libera il posto, la
sopravvivenza dell'amministratore che ha chiesto l'azzeramento, il
`password_change_required` acceso su tutte le righe. È la logica il cui
errore si scopre solo quando è troppo tardi.
- `Livewire/Settings/XmlExchange`: il **round-trip** export → import senza
perdite, che è il presidio contro il tipo di bug già visto con `legacy_id`.
in parte coperti (`RipristinoBackupTest`, `ZonaPericolosaTest`,
`AzzeramentoPiantineTest`, l'azzeramento in `GruppiServizioTest`); restano
da verificare l'ordine staging → database → file e il `failed()` che libera
il posto. È la logica il cui errore si scopre solo quando è troppo tardi.
- `Livewire/Settings/XmlExchange`: il round-trip export → import è coperto per
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
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`,
priorità automatica, percentuale di percorrenza delle campagne.
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`.
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
eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È
scritto in tre posti (rotta, componente, vista) e un test di richiesta lo
verificherebbe tutto insieme.
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: resta solo
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
@@ -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
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
@@ -1683,7 +1844,8 @@ cp .env.example .env
# SESSION_SECURE_COOKIE=false, e le tre password
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
# 1. L'immagine builda

141
README.md
View File

@@ -1,8 +1,9 @@
# TerManager2
Applicazione web per la gestione dell'assegnazione e del rientro dei territori
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali
e registro storico completo.
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali,
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
operazione è tracciata in un **audit log** consultabile.
@@ -410,24 +411,27 @@ Redis, quindi puntarli su un driver diverso interrompe l'elaborazione dei job.
Configurabili dall'interfaccia (**Impostazioni**, permesso `settings.manage`),
non da variabili d'ambiente:
| Impostazione | Descrizione |
|--------------------------------|------------------------------------------------------|
| Nome congregazione | Mostrato nell'intestazione e nei PDF |
| Logo | Immagine nell'intestazione |
| URL pubblico base | Base dei link PDF condivisibili |
| Giorni giacenza "da assegnare" | Soglia oltre cui un territorio è da assegnare |
| Mesi giacenza "prioritari" | Soglia oltre cui un territorio diventa prioritario |
| Giorni per "smarrito" | Soglia oltre cui un'assegnazione è da rientrare |
| Elementi nelle liste della home| Quanti record mostrare nelle tre liste rapide |
| Validità link PDF (mesi) | Scadenza dei link pubblici alle cartoline |
| Download PDF visibile | Mostra o nasconde il pulsante di download |
| Retention audit log (giorni) | Dopo quanti giorni i log vengono cancellati |
| Impostazione | Descrizione |
|------------------------------------|------------------------------------------------------|
| Nome congregazione | Mostrato nell'intestazione, nei PDF e nell'informativa |
| Soglia da assegnare (giorni) | Dopo quanti giorni dal rientro un territorio è "da assegnare" |
| Soglia priorità (giorni) | Dopo quanti giorni dal rientro un territorio diventa prioritario |
| Soglia rientro (giorni) | Oltre questa un'assegnazione è "da rientrare"; è anche la durata del link alla piantina |
| Limite liste Home | Quanti territori mostrare nelle due liste della Home |
| "Scarica PDF" nel viewer | Mostra o nasconde il pulsante di download a chi apre il link |
| Conservazione audit (giorni) | Dopo quanti giorni le voci del registro delle attività si cancellano |
Il server di posta ha una pagina sua (*Impostazioni → Server di posta*). Il
logo nell'intestazione, se c'è, arriva da un backup: la pagina non lo carica.
---
## 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`
2. attende il database
@@ -687,9 +691,11 @@ docker compose --profile dev up -d --build
- Anno teocratico corrente (creato automaticamente, settembre → agosto)
- Media mensile di percorrenza e durata media delle assegnazioni
- Campagna attiva con barra di progresso
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
**da rientrare**; quando l'elenco è troncato, **"Vedi tutti"** è un pulsante
in testata che apre l'elenco territori già filtrato
- Due liste rapide con azione diretta: **da assegnare** (prima i prioritari,
con il badge ★, poi quelli fermi da più tempo) e **da rientrare**; quando
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
- CRUD completo con upload PDF e miniatura generata automaticamente
@@ -698,7 +704,9 @@ docker compose --profile dev up -d --build
il form)
- Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina
- 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
- Soft delete con cestino (ripristino o eliminazione definitiva)
- 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
può copiare o condividere il link
- 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
- **Prompt campagna**: se il territorio era fuori durante una campagna viene
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
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
anche membro
- Creando il gruppo il sorvegliante riceve un'**utenza**: l'email indicata e
una password temporanea, mostrata una volta sola e da cambiare al primo
accesso. **Nessuna conferma dell'email**. Se la perde, l'amministratore ne
genera un'altra dalla pagina del gruppo
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto.
- Creando il gruppo il sorvegliante riceve un'**utenza** con un'**email
fittizia** creata dall'applicazione: iniziale del nome e cognome sul
dominio `termanager.it` (Francesco Fontana → `ffontana@termanager.it`; se
è già presa, un numero in coda), e una password temporanea. Si vedono una
volta sola; la password va cambiata al primo accesso (la pagina dice
"Password temporanea", non "Ripristino da backup"). L'email non si
chiede, non riceve posta e non va confermata. Due gruppi non possono avere
lo stesso nome. Se perde la password, l'amministratore ne genera un'altra
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
cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo
(ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne
nasce un'**assegnazione normale**, che conta come le altre ed è segnata
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
apre la piantina e rimanda in reparto quelli non ancora dati
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
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante: i territori non
ancora dati tornano in reparto, i membri si liberano, l'utenza viene
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello
storico. I territori già dati restano ai membri. Per ripartire si crea un
gruppo nuovo; se il sorvegliante è lo stesso, si riattiva la sua utenza
- Il sorvegliante di un gruppo attivo non si sposta nel cestino né si
anonimizza: prima si scioglie il gruppo. Anonimizzando un ex sorvegliante
si anonimizza anche la sua utenza
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
gruppo**, che non resta nello storico: i territori solo nel pacchetto
tornano in reparto, i membri si liberano e l'**utenza del sorvegliante si
elimina** (anche una sessione aperta si chiude): torna un proclamatore come
gli altri. Le assegnazioni che aveva fatto passano a chi scioglie il
gruppo; nel registro delle attività le sue voci restano a nome
"Sorvegliante del gruppo …". I territori già dati restano ai membri come
assegnazioni qualsiasi, che del gruppo conservano solo il nome ("via
gruppo …"). Per ripartire si crea un gruppo nuovo
- **Un sorvegliante che non è più attivo scioglie il gruppo**, come togliergli
il ruolo: succede se lo si rende non attivo (dall'elenco o dalla modifica),
se lo si sposta nel cestino o lo si anonimizza. Prima di disattivarlo o
cestinarlo dall'elenco si chiede conferma; riattivarlo non ricostruisce il
gruppo. Un membro non attivo resta nel gruppo ma non riceve territori; nel
cestino esce dal gruppo
- **Chi fa parte di un gruppo è segnalato**: badge "Gruppo …" o
"Sorvegliante del gruppo …" nell'elenco dei proclamatori (con il filtro
*In un gruppo di servizio*) e in testa alla scheda; nella scelta del
proclamatore (Assegna, Registro) la nota "gruppo …" accanto al nome, che
resta comunque sceglibile
- **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante
(gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal
gruppo, email per entrare, stato della password e ultimo accesso) o membro; il
sorvegliante ritrova gli stessi numeri nel suo
*Profilo*
### Campagne
- CRUD con date di inizio e fine
@@ -873,16 +912,16 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| Tabella | Descrizione |
|-------------------|--------------------------------------------------------------|
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova; `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 |
| `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio |
| `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 |
| `anni_teocratici` | Periodi settembre → agosto |
| `campagne` | Periodi speciali con date e descrizione |
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `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 |
**Vincoli e indici principali**
@@ -916,10 +955,16 @@ Calcolato dinamicamente, non memorizzato:
| `inattivo` | Contrassegnato come non attivo |
### Priorità
Un territorio è prioritario se lo è per **flag manuale** o perché non viene
assegnato da più mesi della soglia configurata. La soglia automatica **vince
sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
quando la soglia è scattata.
Un territorio è prioritario se lo è per **flag manuale** o perché è fermo
(dall'ultimo rientro, o dalla creazione se mai assegnato) da più giorni della
soglia configurata. La soglia automatica **vince sempre** sul flag:
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
- **Denominatore**: tutti i territori attivi
@@ -967,14 +1012,16 @@ sempre.
```
TerManager2/
├── 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/Middleware/ # RequirePasswordChange
│ ├── Http/Middleware/ # RequirePasswordChange, LimitaSorvegliante
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
│ ├── Livewire/ # Tutti i componenti di interfaccia
│ ├── Models/ # 9 modelli Eloquent
│ ├── Livewire/ # Tutti i componenti di interfaccia (Gruppi/: gruppi di servizio)
│ ├── Models/ # 12 modelli Eloquent
│ ├── 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
├── config/ # Configurazione Laravel
├── database/
@@ -996,7 +1043,7 @@ TerManager2/
│ ├── components/btn.blade.php # L'unico pulsante dell'applicazione
│ └── livewire/ # Una vista per componente
├── 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
├── docker-compose.yml # Unico, produzione e sviluppo
├── .env.example # Tutta la configurazione

View File

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

View File

@@ -9,7 +9,9 @@ use Illuminate\Support\Facades\Cache;
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
* 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 e' stato dirottato dal middleware no.
'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
// 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.
@@ -71,6 +75,7 @@ class PasswordChangeController extends Controller
$user->update([
'password' => $validated['password'],
'password_change_required' => false,
'password_temporanea' => false,
]);
// La sessione corrente sopravvive al cambio, le altre no: dopo un

View File

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

View File

@@ -250,6 +250,9 @@ class RestoreBackupArchive implements ShouldQueue
{
DB::table('users')->update([
'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,
]);

View File

@@ -23,11 +23,19 @@ class Assegna extends Component
// Optional pre-selection from parent context
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)
{
$this->preselectedTerritorioId = $territorioId;
$this->territorio_id = $territorioId;
$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
// scheda del territorio).

View File

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

View File

@@ -32,6 +32,16 @@ class GruppoShow extends Component
/** @var int[] territori da aggiungere al pacchetto */
public array $nuoviTerritori = [];
// Filtri dei territori in reparto da aggiungere al pacchetto. L'ordine è
// quello di priorità (Territorio::ordinaPerPriorita), come nella Home.
public string $cercaTerritorio = '';
public string $filtroZona = '';
public string $filtroTipologia = '';
public bool $soloPrioritari = false;
#[Locked]
public ?string $passwordTemporanea = null;
@@ -91,6 +101,45 @@ class GruppoShow extends Component
$this->nuoviTerritori = [];
}
/** Tutti i territori che i filtri mostrano finiscono tra quelli da aggiungere. */
public function selezionaMostrati(): void
{
$this->nuoviTerritori = collect($this->nuoviTerritori)
->map(fn ($id) => (string) $id)
->merge($this->candidatiFiltrati()->pluck('id')->map(fn ($id) => (string) $id))
->unique()
->values()
->all();
}
public function deselezionaTutti(): void
{
$this->nuoviTerritori = [];
}
/** I territori in reparto, in ordine di priorità. */
private function candidati(): \Illuminate\Support\Collection
{
return Territorio::ordinaPerPriorita(
Territorio::inReparto()->with(['zona', 'tipologia', 'ultimaAssegnazione'])->get()
);
}
private function candidatiFiltrati(?\Illuminate\Support\Collection $candidati = null): \Illuminate\Support\Collection
{
$cerca = Str::lower(Str::ascii(trim($this->cercaTerritorio)));
return ($candidati ?? $this->candidati())
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
->when($cerca !== '', fn ($c) => $c->filter(fn (Territorio $t) => str_contains(
Str::lower(Str::ascii("{$t->numero} {$t->zona?->nome} {$t->tipologia?->nome}")),
$cerca
)))
->values();
}
public function rimanda(int $territorioId): void
{
$territorio = Territorio::findOrFail($territorioId);
@@ -102,24 +151,30 @@ class GruppoShow extends Component
{
$this->servizio();
$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.');
return;
}
$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');
$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.");
$this->passwordTemporanea = null;
$nome = $this->gruppo->nome;
$this->servizio()->sciogli($this->gruppo);
session()->flash('success', "Gruppo {$nome} sciolto: i territori non ancora dati sono tornati in reparto e l'utenza del sorvegliante è stata eliminata.");
return $this->redirect(route('gruppi.index'), navigate: true);
}
public function render()
@@ -129,21 +184,21 @@ class GruppoShow extends Component
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values();
$territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
$attivo = ! $this->gruppo->sciolto_at;
// 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', [
'attivo' => $attivo,
'membri' => $membri,
'territori' => $territori,
// Si possono aggiungere i proclamatori attivi senza gruppo...
'candidatiMembri' => $attivo
? Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
: collect(),
// ...e i territori in reparto (attivi, non assegnati, fuori da ogni pacchetto).
'candidatiTerritori' => $attivo
? Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values()
: collect(),
'assegnazioniDelGruppo' => $this->gruppo->assegnazioni()->count(),
'candidatiMembri' => Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(),
// ...e i territori in reparto.
'totaleCandidati' => $candidati->count(),
'candidatiTerritori' => $this->candidatiFiltrati($candidati),
'zoneCandidati' => $candidati->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
'tipologieCandidati' => $candidati->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
]);
}
}

View File

@@ -12,6 +12,7 @@ use App\Services\GruppiServizio;
use Carbon\Carbon;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
@@ -35,6 +36,20 @@ class MioGruppo extends Component
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
{
$this->dataAssegnazione = now()->format('Y-m-d');
@@ -170,11 +185,25 @@ class MioGruppo extends Component
public function render()
{
$gruppo = $this->gruppo();
$gruppo = $this->gruppo()?->load('sorvegliante');
$territori = $gruppo
? $gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values()
// Prima quelli da dare, in ordine di priorità come "Da assegnare"
// nella Home (Territorio::ordinaPerPriorita); poi quelli già dati, dal
// più vecchio.
$pacchetto = $gruppo
? $gruppo->territori()->with(['zona', 'tipologia', 'ultimaAssegnazione', 'assegnazioneCorrente.proclamatore'])->get()
: collect();
[$dati, $daDare] = $pacchetto->partition(fn (Territorio $t) => $t->assegnazioneCorrente !== null);
$pacchetto = Territorio::ordinaPerPriorita($daDare)
->concat($dati->sortBy(fn (Territorio $t) => $t->assegnazioneCorrente->assigned_at)->values());
$territori = $pacchetto
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
->when($this->filtroStato === 'da_dare', fn ($c) => $c->whereNull('assegnazioneCorrente'))
->when($this->filtroStato === 'dati', fn ($c) => $c->whereNotNull('assegnazioneCorrente'))
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
->values();
$membri = $gruppo
? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
@@ -182,9 +211,12 @@ class MioGruppo extends Component
return view('livewire.gruppi.mio-gruppo', [
'gruppo' => $gruppo,
'pacchetto' => $pacchetto,
'territori' => $territori,
'zone' => $pacchetto->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
'tipologie' => $pacchetto->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
'membri' => $membri,
'opzioniMembri' => Proclamatore::opzioniScelta($membri),
'opzioniMembri' => Proclamatore::opzioniScelta($membri, conGruppo: false),
'invio' => $this->invioCorrente(),
]);
}

View File

@@ -18,35 +18,10 @@ class Home extends Component
$this->authorize('territori.assign');
$settings = Setting::instance();
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$territori = Territorio::inReparto()
->with('zona', 'tipologia', 'ultimaAssegnazione')
->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();
$territori = Territorio::ordinaPerPriorita(
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
);
$pdf = Pdf::loadView('pdf.territori-lista', [
'titolo' => 'Territori da Assegnare',
@@ -90,7 +65,6 @@ class Home extends Component
{
$settings = Setting::instance();
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$annoCorrente = AnnoTeocratico::corrente();
$campagnaAttiva = Campagna::attiva();
@@ -149,46 +123,11 @@ class Home extends Component
];
}
// Quick lists
$territoriDaAssegnare = Territorio::inReparto()
->with('zona', 'tipologia', 'ultimaAssegnazione')
->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) {
$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();
// Quick lists: la logica di priorità sta in Territorio::ordinaPerPriorita
// (la usano anche il pacchetto dei gruppi di servizio e "Il mio gruppo").
$territoriDaAssegnare = Territorio::ordinaPerPriorita(
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
)->take($homeLimit)->values();
$daRientrare = Territorio::daRientrare()
->with(['zona', 'assegnazioneCorrente.proclamatore'])

View File

@@ -38,13 +38,17 @@ class ProclamatoreEdit extends Component
$this->validate();
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
$gruppo = ! $this->attivo ? $this->proclamatore->gruppoSorvegliato() : null;
$this->proclamatore->update([
'nome' => $this->nome,
'cognome' => $this->cognome,
'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);
}
@@ -53,6 +57,7 @@ class ProclamatoreEdit extends Component
return view('livewire.proclamatori.proclamatore-form', [
'titolo' => "Modifica {$this->proclamatore->nome_completo}",
'btnLabel' => 'Salva Modifiche',
'gruppoSorvegliato' => $this->proclamatore->gruppoSorvegliato(),
]);
}
}

View File

@@ -58,7 +58,13 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage');
$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]);
if ($gruppo) {
$this->avvisa("{$proclamatore->nome_completo} non è più attivo. " . self::gruppoSciolto($gruppo));
}
}
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.");
return;
}
if ($this->sorveglianteDiUnGruppo($proclamatore, 'spostarlo nel cestino')) {
return;
}
// Nel cestino non resta membro di un gruppo.
$proclamatore->update(['gruppo_servizio_id' => null]);
// Nel cestino lascia il gruppo; se ne era il sorvegliante, il gruppo
// si scioglie (Proclamatore::booted).
$gruppo = $proclamatore->gruppoSorvegliato();
$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)
@@ -86,25 +90,15 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage');
$proclamatore = Proclamatore::findOrFail($id);
if ($this->sorveglianteDiUnGruppo($proclamatore, 'anonimizzarlo')) {
return;
}
$gruppo = $proclamatore->gruppoSorvegliato();
$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. */
private function sorveglianteDiUnGruppo(Proclamatore $proclamatore, string $azione): bool
/** L'avviso quando un sorvegliante esce di scena: anche per ProclamatoreEdit. */
public static function gruppoSciolto(GruppoServizio $gruppo): string
{
$gruppo = GruppoServizio::attivi()->where('sorvegliante_id', $proclamatore->id)->first();
if (! $gruppo) {
return false;
}
$this->avvisaErrore("{$proclamatore->nome_completo} è il sorvegliante del gruppo {$gruppo->nome}: per {$azione} va prima sciolto il gruppo (Gruppi di servizio).");
return true;
return "Era il sorvegliante del gruppo {$gruppo->nome}: il gruppo è stato sciolto, i territori non ancora dati sono tornati in reparto e la sua utenza è stata eliminata.";
}
public function render()
@@ -112,7 +106,7 @@ class ProclamatoreIndex extends Component
// withCount rather than letting the view call
// ->assegnazioni()->aperte()->count() per row, which issued one
// 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'),
]);
@@ -130,6 +124,8 @@ class ProclamatoreIndex extends Component
$filtered = $filtered->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') {
$filtered = $filtered->where('attivo', false);
} elseif ($this->filtroStato === 'gruppo') {
$filtered = $filtered->whereNotNull('gruppo_servizio_id');
}
// Sort in-memory
@@ -154,6 +150,8 @@ class ProclamatoreIndex extends Component
$query->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') {
$query->where('attivo', false);
} elseif ($this->filtroStato === 'gruppo') {
$query->whereNotNull('gruppo_servizio_id');
}
// cognome/nome encrypted, so sort in-memory
@@ -176,6 +174,8 @@ class ProclamatoreIndex extends Component
return view('livewire.proclamatori.proclamatore-index', [
'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 App\Models\Proclamatore;
use App\Models\Assegnazione;
use Spatie\Activitylog\Models\Activity;
class ProclamatoreShow extends Component
{
@@ -23,8 +24,11 @@ class ProclamatoreShow extends Component
$assegnazioni = Assegnazione::where('proclamatore_id', $this->proclamatore->id)
->with(['territorio', 'annoTeocratico', 'campagna'])
->orderByDesc('assigned_at')
->orderByDesc('id')
->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 = [
'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', [
'assegnazioniPerAnno' => $assegnazioni,
'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();
// 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([
'name' => ['required', 'string', 'max:255'],
] + ($emailFissa ? [] : [
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
]);
]));
$user->fill([
'name' => $validated['name'],
'email' => $validated['email'],
]);
$user->fill(['name' => $validated['name']] + ($emailFissa ? [] : ['email' => $validated['email']]));
$emailCambiata = $user->isDirty('email');
$user->save();
@@ -78,6 +80,7 @@ class Profile extends Component
auth()->user()->update([
'password' => $this->password,
'password_change_required' => false,
'password_temporanea' => false,
]);
$this->reset(['current_password', 'password', 'password_confirmation']);
@@ -87,8 +90,13 @@ class Profile extends Component
public function render()
{
// Il sorvegliante vede qui i dettagli del suo gruppo di servizio.
$gruppo = auth()->user()->gruppoSorvegliato();
return view('livewire.profile', [
'user' => auth()->user(),
'gruppo' => $gruppo,
'riepilogoGruppo' => $gruppo?->riepilogo(),
]);
}
}

View File

@@ -285,7 +285,7 @@ class Registro extends Component
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) {
$query->where('anno_teocratico_id', $this->filtroAnno);

View File

@@ -3,13 +3,10 @@
namespace App\Livewire\Settings;
use App\Livewire\Concerns\Avvisa;
use App\Models\Assegnazione;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role;
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
* gruppo senza sorvegliante, o un sorvegliante con un ruolo diverso.
*/
@@ -232,37 +229,7 @@ class UsersIndex extends Component
return;
}
DB::transaction(function () use ($user, $currentUser) {
$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();
});
$user->eliminaConservandoStorico($currentUser, $user->name, $user->email);
if ($this->editingUserId === $userId) {
$this->cancelEdit();

View File

@@ -527,6 +527,13 @@ class XmlExchange extends Component
$email = trim((string) ($userNode->email ?? ''));
$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 === '') {
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
continue;
@@ -575,6 +582,7 @@ class XmlExchange extends Component
if ($passwordHash !== '' && ! $eAttore) {
$meta['password'] = $passwordHash;
$meta['password_change_required'] = true;
$meta['password_temporanea'] = false;
}
if (! $eAttore) {
$meta['remember_token'] = null;
@@ -594,10 +602,7 @@ class XmlExchange extends Component
// Utenza di un sorvegliante: si collega al proclamatore dopo che
// i proclamatori sono stati importati (vedi i gruppi di servizio).
$utentiDaCollegare[$user->id] = [
'proclamatore' => (string) ($userNode->legacy_proclamatore_id ?? ''),
'disattivato_at' => $this->normalizeDateTimeForDb((string) ($userNode->disattivato_at ?? '')),
];
$utentiDaCollegare[$user->id] = (string) ($userNode->legacy_proclamatore_id ?? '');
$stats['utenti_importati']++;
if ($legacyId !== '') {
@@ -657,10 +662,15 @@ class XmlExchange extends Component
$gruppoMap = [];
foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) {
$legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? '');
// Nei file del 19-20 ottobre 2026 un gruppo sciolto c'era ancora
// (<sciolto_at>): ora un gruppo sciolto non esiste più, quindi
// non si importa.
if (trim((string) ($gruppoNode->sciolto_at ?? '')) !== '') {
continue;
}
$g = GruppoServizio::create([
'nome' => (string) $gruppoNode->nome,
'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null,
'sciolto_at' => $this->normalizeDateTimeForDb((string) ($gruppoNode->sciolto_at ?? '')),
]);
$this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode);
$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]]);
}
}
foreach ($utentiDaCollegare as $idUtente => $collegamento) {
foreach ($utentiDaCollegare as $idUtente => $legacyProclamatore) {
DB::table('users')->where('id', $idUtente)->update([
'proclamatore_id' => $proclamatoreMap[$collegamento['proclamatore']] ?? null,
'disattivato_at' => $collegamento['disattivato_at'],
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore] ?? null,
]);
}
@@ -809,6 +818,7 @@ class XmlExchange extends Component
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null,
'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,
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
'note' => (string) ($assegnazioneNode->note ?? ''),
@@ -865,20 +875,19 @@ class XmlExchange extends Component
// remember_token no: all'import si azzera comunque, e nel file
// sarebbe solo una credenziale in più per chi lo tiene.
'roles' => $u->roles->pluck('name')->all(),
// Sorvegliante di gruppo: il proclamatore collegato e se l'utenza è disattivata.
// Sorvegliante di gruppo: il proclamatore collegato.
'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '',
'disattivato_at' => optional($u->disattivato_at)->toIso8601String() ?? '',
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
])->all(),
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', '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']),
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', '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'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
$node->addChild('disattivato_at', (string) ($user['disattivato_at'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles');
@@ -1352,7 +1360,6 @@ class XmlExchange extends Component
$this->addLegacyIdAttribute($node, $gruppo);
$this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? ''));
$node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? ''));
$node->addChild('sciolto_at', (string) ($gruppo['sciolto_at'] ?? ''));
$this->addTimestampNodes($node, $gruppo);
}
@@ -1382,6 +1389,7 @@ class XmlExchange extends Component
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? ''));
$this->addXmlText($node, 'gruppo_nome', (string) ($assegnazione['gruppo_nome'] ?? ''));
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));

View File

@@ -21,10 +21,13 @@ class TerritorioShow extends Component
public function render()
{
$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('id')
->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', [
'activeAssignment' => $this->territorio->assegnazioneCorrente,

View File

@@ -26,6 +26,7 @@ class Assegnazione extends Model
'created_by',
'returned_by',
'gruppo_servizio_id',
'gruppo_nome',
];
/** 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
* reparto ed esce dal pacchetto.
*
* Un gruppo sciolto (sciolto_at) non ha più membri né pacchetto, ma resta: le
* assegnazioni nate da lui continuano a citarlo. Logica in
* App\Services\GruppiServizio.
* Sciolto, il gruppo si elimina: non resta nello storico (decisione del 20
* ottobre 2026). Le assegnazioni già date dal sorvegliante ne conservano solo
* il nome (assegnazioni.gruppo_nome). Logica in App\Services\GruppiServizio.
*/
class GruppoServizio extends Model
{
@@ -27,17 +27,17 @@ class GruppoServizio extends Model
protected $table = 'gruppi_servizio';
protected $fillable = ['nome', 'sorvegliante_id', 'sciolto_at'];
protected $fillable = ['nome', 'sorvegliante_id'];
protected function casts(): array
{
return ['sciolto_at' => 'datetime', 'sorvegliante_id' => 'integer'];
return ['sorvegliante_id' => 'integer'];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['nome', 'sorvegliante_id', 'sciolto_at'])
->logOnly(['nome', 'sorvegliante_id'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
@@ -63,13 +63,23 @@ class GruppoServizio extends Model
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 $query->whereNull('sciolto_at');
return [
'membri' => $this->membri()->count(),
'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');
}
/**
* 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
{
return [
@@ -63,9 +103,13 @@ class Proclamatore extends Model
*/
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([
'valore' => (string) $this->id,
'etichetta' => $this->nome_completo,
'dettaglio' => $gruppo !== null ? lcfirst($gruppo) : null,
'chiavi' => [(string) $this->cognome, (string) $this->nome],
'motivo' => $motivo,
], 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.
* 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)
->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome))
->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo'))
@@ -84,6 +134,22 @@ class Proclamatore extends Model
->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()
{
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
@@ -109,21 +175,9 @@ class Proclamatore extends Model
$this->cognome = 'Proclamatore #' . $this->id;
$this->attivo = false;
$this->gruppo_servizio_id = null;
// Se era sorvegliante, il salvataggio (non più attivo) scioglie il
// gruppo ed elimina la sua utenza: booted().
$this->save();
$this->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;
}
/**
* 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
* 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\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Models\Activity;
use Illuminate\Support\Facades\DB;
/**
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
@@ -24,8 +26,8 @@ class User extends Authenticatable implements MustVerifyEmail
'email',
'password',
'password_change_required',
'password_temporanea',
'proclamatore_id',
'disattivato_at',
];
protected $hidden = [
@@ -39,7 +41,9 @@ class User extends Authenticatable implements MustVerifyEmail
'email_verified_at' => 'datetime',
'password' => 'hashed',
'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',
];
}
@@ -59,18 +63,46 @@ class User extends Authenticatable implements MustVerifyEmail
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
{
return $this->proclamatore_id
? GruppoServizio::attivi()->where('sorvegliante_id', $this->proclamatore_id)->first()
? GruppoServizio::where('sorvegliante_id', $this->proclamatore_id)->first()
: 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
{
/** 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",
* password temporanea da cambiare al primo accesso, nessuna conferma
* dell'email. Restituisce il gruppo e la password temporanea, da mostrare
* una volta sola all'amministratore.
* Crea il gruppo e l'utenza del sorvegliante: email fittizia ricavata dal
* suo nome (emailFittizia), ruolo "sorvegliante", password temporanea da
* cambiare al primo accesso, nessuna conferma dell'email. Restituisce il
* gruppo e la password temporanea, da mostrare una volta sola
* all'amministratore insieme all'email.
*
* @param int[] $membri id dei proclamatori, oltre al sorvegliante
* @return array{0: GruppoServizio, 1: string}
*/
public function crea(string $nome, Proclamatore $sorvegliante, string $email, array $membri = []): array
public function crea(string $nome, Proclamatore $sorvegliante, array $membri = []): array
{
$nome = trim($nome);
if ($nome === '') {
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
}
if (GruppoServizio::where('nome', $nome)->exists()) {
throw new InvalidArgumentException("Esiste già un gruppo «{$nome}».");
}
if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
}
if ($sorvegliante->gruppo_servizio_id) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
}
// Chi è stato sorvegliante di un gruppo sciolto ha già un'utenza,
// disattivata: si riattiva quella (un proclamatore, un'utenza).
$esistente = $sorvegliante->utente()->first();
if ($esistente && ! $esistente->isDisattivato()) {
if ($sorvegliante->utente()->exists()) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
}
if (User::where('email', $email)->when($esistente, fn ($q) => $q->whereKeyNot($esistente->id))->exists()) {
throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente.");
}
$password = Str::password(12, symbols: false);
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password, $esistente) {
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $membri, $password) {
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri);
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
$utente->forceFill([
$utente = User::create([
'name' => $sorvegliante->nome_completo,
'email' => $email,
'email' => self::emailFittizia($sorvegliante),
'password' => $password,
'password_change_required' => true,
'disattivato_at' => null,
])->save();
$utente->syncRoles(['sorvegliante']);
'password_temporanea' => true,
'proclamatore_id' => $sorvegliante->id,
]);
$utente->assignRole('sorvegliante');
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
@@ -79,6 +80,30 @@ class GruppiServizio
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 */
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
{
@@ -205,6 +230,8 @@ class GruppiServizio
'assigned_at' => $data,
'created_by' => auth()->id(),
'gruppo_servizio_id' => $gruppo->id,
// Il nome resta anche quando il gruppo, sciolto, non c'è più.
'gruppo_nome' => $gruppo->nome,
'link_da_inviare' => (bool) $territorio->pdf_path,
]);
});
@@ -225,48 +252,69 @@ class GruppiServizio
}
/**
* Scioglie il gruppo (si toglie il ruolo di sorvegliante): i territori del
* pacchetto non ancora dati tornano in reparto, i membri restano senza
* gruppo, l'utenza del sorvegliante si disattiva. Le assegnazioni già
* fatte restano com'erano, con il loro riferimento al gruppo, che resta
* nello storico come sciolto.
* Scioglie il gruppo (si toglie il ruolo di sorvegliante, o il
* sorvegliante non è più attivo) e lo elimina: non resta nello storico.
* I territori del pacchetto non ancora dati tornano in reparto; quelli
* già dati restano ai membri come assegnazioni qualsiasi, che conservano
* solo il nome del gruppo (gruppo_nome). I membri restano senza gruppo.
* L'utenza del sorvegliante si elimina: torna un proclamatore come gli
* altri. Le assegnazioni che aveva fatto o fatto rientrare passano a chi
* scioglie il gruppo; nel registro le sue voci restano, a nome
* "Sorvegliante del gruppo …", senza dati personali.
*/
public function sciogli(GruppoServizio $gruppo): void
public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void
{
if ($gruppo->sciolto_at) {
if (! GruppoServizio::whereKey($gruppo->id)->exists()) {
return;
}
DB::transaction(function () use ($gruppo) {
// Esce dal pacchetto solo quello non ancora dato: un territorio
// assegnato resta del suo gruppo finché non rientra (Assegnazione
// lo libera al rientro), così non può essere ridato due volte.
$nonDati = Territorio::withTrashed()
DB::transaction(function () use ($gruppo, $motivo) {
$liberati = Territorio::withTrashed()
->where('gruppo_servizio_id', $gruppo->id)
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
$liberati = (clone $nonDati)->pluck('numero');
$nonDati->update(['gruppo_servizio_id' => null]);
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'))
->pluck('numero');
Territorio::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
Assegnazione::where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
if ($utente) {
$utente->removeRole('sorvegliante');
$utente->forceFill(['disattivato_at' => now(), 'remember_token' => null])->save();
}
$gruppo->update(['sciolto_at' => now()]);
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['territori_liberati' => $liberati->all(), 'utente_disattivato' => $utente?->id])
->withProperties(array_filter([
'gruppo' => $gruppo->nome,
'territori_liberati' => $liberati->all(),
'utente_eliminato' => $utente?->id,
'motivo' => $motivo,
], fn ($v) => $v !== null))
->log('gruppo_sciolto');
if ($utente) {
$utente->eliminaConservandoStorico($this->subentrante($utente), "Sorvegliante del gruppo {$gruppo->nome}", null);
}
$gruppo->delete();
});
}
/**
* Chi prende le assegnazioni fatte dal sorvegliante quando la sua utenza
* si elimina: chi sta sciogliendo il gruppo, altrimenti il primo
* amministratore.
*/
private function subentrante(User $sorvegliante): User
{
$chi = auth()->user();
if ($chi instanceof User && $chi->id !== $sorvegliante->id) {
return $chi;
}
return User::role('amministratore')->orderBy('id')->firstOrFail();
}
private function attivo(GruppoServizio $gruppo): void
{
if ($gruppo->sciolto_at) {
throw new InvalidArgumentException('Il gruppo è stato sciolto: creane uno nuovo.');
if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {
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;
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
width and block, so their inner layout isn't squeezed by the flex row. */
table.table-cards td[colspan] {

View File

@@ -21,7 +21,15 @@
</div>
@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">
<strong>Ripristino da backup eseguito.</strong>
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
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 />
@endunless
</div>

View File

@@ -40,7 +40,9 @@
@endphp
{{-- Riga compatta: classi brevi (tm-assegnato-*, in app.css), copia con
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 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>
@@ -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>
@endif
@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>
<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). --}}
@@ -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>
</div>
@else
<p class="tm-assegnato-senza">Nessun PDF disponibile</p>
<div class="tm-assegnato-link">
@can('territori.return')<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a, false)">Rientro</x-btn>@endcan
<p class="tm-assegnato-senza">Nessun PDF disponibile</p>
</div>
@endif
</div>
@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>
<dl class="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt class="text-green-700">Indirizzo</dt><dd><code>{{ route('login') }}</code></dd>
<dt class="text-green-700">Email</dt><dd><code>{{ $creato->sorvegliante?->utente?->email }}</code></dd>
<dt class="text-green-700">Email</dt><dd><code class="select-all">{{ $creato->sorvegliante?->utente?->email }}</code></dd>
<dt class="text-green-700">Password</dt><dd><code class="select-all">{{ $passwordTemporanea }}</code></dd>
</dl>
<div class="mt-3 flex flex-wrap gap-2">
@@ -34,16 +34,9 @@
<div>
<label for="sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Sorvegliante *</label>
<x-scelta id="sorvegliante" wire:model="sorveglianteId" :opzioni="$opzioniSorveglianti" segnaposto="Cognome o nome" :vuoto="null" />
<p class="text-xs text-gray-500 mt-1">Un proclamatore attivo che non fa già parte di un gruppo. Diventa anche membro del gruppo.</p>
<p class="text-xs text-gray-500 mt-1">Un proclamatore attivo che non fa già parte di un gruppo. Diventa anche membro del gruppo, e riceve un'utenza con un'email fittizia: iniziale del nome e cognome (es. Francesco Fontana → <code>ffontana@{{ \App\Services\GruppiServizio::DOMINIO_EMAIL }}</code>).</p>
@error('sorveglianteId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="email-sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Email per l'accesso *</label>
<input wire:model="email" id="email-sorvegliante" type="email" autocomplete="off"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<p class="text-xs text-gray-500 mt-1">Resta solo nell'utenza del sorvegliante (non nel proclamatore) e non va confermata.</p>
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<x-btn variante="primary" type="submit">Crea il gruppo</x-btn>
</form>
</section>
@@ -55,15 +48,12 @@
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div>
<a data-ritorno href="{{ route('gruppi.show', $g) }}" class="font-semibold text-indigo-600 hover:underline">{{ $g->nome }}</a>
@if($g->sciolto_at)
<span class="ml-1 inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">sciolto il {{ $g->sciolto_at->format('d/m/Y') }}</span>
@endif
<p class="text-xs text-gray-500">
Sorvegliante: {{ $g->sorvegliante?->nome_completo ?? '—' }}
@unless($g->sciolto_at) · {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto @endunless
· {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto
</p>
</div>
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">{{ $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>
@empty
<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>
<p class="text-sm text-gray-500 mt-1">
Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong>
@if($attivo && $gruppo->sorvegliante?->utente)
· accede con <code>{{ $gruppo->sorvegliante->utente->email }}</code>
@if($gruppo->sorvegliante?->utente)
· accede con <code class="select-all">{{ $gruppo->sorvegliante->utente->email }}</code>
@endif
</p>
@unless($attivo)
<p class="mt-2 inline-flex rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700">
Sciolto il {{ $gruppo->sciolto_at->format('d/m/Y') }} · {{ $assegnazioniDelGruppo }} assegnazioni nate dal gruppo restano nello storico
</p>
@endunless
</div>
@if($passwordTemporanea)
@@ -25,7 +20,6 @@
</div>
@endif
@if($attivo)
{{-- ─── Pacchetto ─────────────────────────────────────────── --}}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-pacchetto">
<h2 id="titolo-pacchetto" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">
@@ -55,27 +49,65 @@
@endforelse
</ul>
{{-- Assegnazione in blocco: si spuntano i territori in reparto --}}
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile">
{{-- Assegnazione in blocco: si spuntano i territori in reparto, in
ordine di priorità (come "Da assegnare" nella Home), filtrabili. --}}
<div class="border-t border-gray-100 bg-gray-50 p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi territori al pacchetto</h3>
@if($candidatiTerritori->isEmpty())
@if($totaleCandidati === 0)
<p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p>
@else
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i territori in reparto"
placeholder="Filtra per numero o zona…"
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
@foreach($candidatiTerritori as $c)
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40"
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->numero . ' ' . $c->zona?->nome)) }}"
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-t-{{ $c->id }}">
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviTerritori" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
<span><strong>N° {{ $c->numero }}</strong> <span class="text-gray-500">{{ $c->zona?->nome }}</span></span>
</label>
@endforeach
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 mb-2">
<input wire:model.live.debounce.300ms="cercaTerritorio" type="search" inputmode="search" autocomplete="off" aria-label="Cerca tra i territori in reparto"
placeholder="Numero, zona o tipologia…"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<select wire:model.live="filtroZona" aria-label="Zona"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le zone</option>
@foreach($zoneCandidati as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<select wire:model.live="filtroTipologia" aria-label="Tipologia"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le tipologie</option>
@foreach($tipologieCandidati as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<label class="flex items-center gap-2 text-sm text-gray-700 px-1">
<input type="checkbox" wire:model.live="soloPrioritari" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
Solo prioritari
</label>
</div>
<div class="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>
<span class="text-xs text-gray-500" x-data>Selezionati: <strong x-text="$wire.nuoviTerritori.length">{{ count($nuoviTerritori) }}</strong></span>
</div>
@endif
</div>
@@ -127,8 +159,7 @@
<x-btn variante="secondary" wire:click="nuovaPassword"
wire:confirm="Generare una nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
<x-btn variante="danger" wire:click="sciogli"
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene disattivata. Le assegnazioni già fatte restano.">Togli il ruolo e sciogli il gruppo</x-btn>
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie e viene eliminato: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene eliminata. Le assegnazioni già fatte restano ai membri.">Togli il ruolo e sciogli il gruppo</x-btn>
</div>
</section>
@endif
</div>

View File

@@ -10,11 +10,14 @@
@else
<div class="mb-5">
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
@if($gruppo->sorvegliante)
<p class="text-sm text-gray-700 mt-0.5">Sorvegliante: <strong>{{ trim($gruppo->sorvegliante->nome . ' ' . $gruppo->sorvegliante->cognome) }}</strong></p>
@endif
{{-- In questa vista solo blocchi php…endphp: la forma in linea, se
viene prima di un blocco, confonde il compilatore di Blade. --}}
@php $daDare = $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">
{{ $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' }}
</p>
</div>
@@ -29,12 +32,43 @@
<symbol id="tm-i-avviso" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></symbol>
</svg>
<div class="px-4 py-3 border-b border-gray-100 flex flex-wrap items-center justify-between gap-2">
<h2 id="titolo-pacchetto" class="text-sm font-semibold text-gray-800">Territori del pacchetto</h2>
@if($territori->count() > 5)
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto"
placeholder="Cerca numero, zona o nome…"
class="w-full sm:w-64 rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div class="px-4 py-3 border-b border-gray-100 space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 id="titolo-pacchetto" class="text-sm font-semibold text-gray-800">Territori del pacchetto</h2>
@if($pacchetto->count() > 5)
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto"
placeholder="Cerca numero, zona o nome…"
class="w-full sm:w-64 rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
@endif
</div>
{{-- Filtri; l'ordine è di priorità: prima i prioritari e quelli fermi da più tempo --}}
@if($pacchetto->isNotEmpty())
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<select wire:model.live="filtroStato" aria-label="Stato"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutti</option>
<option value="da_dare">Da dare</option>
<option value="dati">Già dati</option>
</select>
<select wire:model.live="filtroZona" aria-label="Zona"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le zone</option>
@foreach($zone as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<select wire:model.live="filtroTipologia" aria-label="Tipologia"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le tipologie</option>
@foreach($tipologie as $id => $nome)
<option value="{{ $id }}">{{ $nome }}</option>
@endforeach
</select>
<label class="flex items-center gap-2 text-sm text-gray-700 px-1">
<input type="checkbox" wire:model.live="soloPrioritari" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
Solo prioritari
</label>
</div>
@endif
</div>
@@ -47,11 +81,12 @@
@endphp
<div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
<div class="tm-assegnato-testa">
<span><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif</span>
<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)
<span class="tm-assegnato-info"><b>{{ $a->proclamatore?->nome_completo }}</b> · dal {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
@else
<span class="tm-assegnato-info"><b>Da dare</b></span>
<span class="tm-assegnato-info"><b>Da dare</b>@if($t->tipologia?->nome) · {{ $t->tipologia->nome }}@endif @if($t->giacenza_giorni > 0) · fermo da {{ $t->giacenza_giorni }} gg @endif</span>
@endif
</div>
@@ -101,7 +136,9 @@
@endif
</div>
@empty
<p class="px-4 py-8 text-center text-sm text-gray-500">Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo.</p>
<p class="px-4 py-8 text-center text-sm text-gray-500">
{{ $pacchetto->isEmpty() ? "Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo." : 'Nessun territorio con questi filtri.' }}
</p>
@endforelse
<p x-show="nessuno" x-cloak class="px-4 py-8 text-center text-sm text-gray-400">Nessun risultato per «<span x-text="q"></span>».</p>
</div>
@@ -111,7 +148,7 @@
<h2 id="titolo-membri" class="text-sm font-semibold text-gray-800 mb-2">Membri del gruppo</h2>
<ul class="text-sm text-gray-700 space-y-1">
@foreach($membri as $m)
@php $quanti = $territori->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp
@php $quanti = $pacchetto->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp
<li class="flex justify-between gap-3">
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
<span class="text-gray-500">{{ $quanti }} {{ $quanti === 1 ? 'territorio' : 'territori' }}</span>

View File

@@ -110,19 +110,19 @@
<div>
<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>
@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>
@endif
</div>
<p class="text-xs text-gray-500">
{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}
@if($t->home_giorni_giacenza > 0)
— <span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto
@if($t->giacenza_giorni > 0)
— <span class="font-medium">{{ $t->giacenza_giorni }} gg</span> in reparto
@endif
</p>
</div>
@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
</li>
@empty

View File

@@ -18,8 +18,8 @@
<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>Territori e piantine</strong>: numero, zona, tipologia, confini, mappe e PDF — nessun dato personale.</li>
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio, con date di assegnazione e rientro, conteggio nelle campagne, codice del link alla piantina e stato del suo invio (inviato o no, e con quale canale).</li>
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata, 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>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), 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>
</ul>
</section>
@@ -58,7 +58,7 @@
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
@endforeach
</ul>
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. 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>
@@ -100,7 +100,7 @@
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
<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>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>

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">
<label for="attivo" class="text-sm text-gray-700">Attivo</label>
</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">
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>

View File

@@ -26,6 +26,7 @@
<option value="">Tutti</option>
<option value="attivo">Attivi</option>
<option value="inattivo">Inattivi</option>
<option value="gruppo">In un gruppo di servizio</option>
</select>
</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="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">
<div class="flex flex-wrap gap-1">
@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>
@else
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
@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 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">
@@ -73,6 +79,15 @@
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>
</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
wire:click="toggleActive({{ $proclamatore->id }})"
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>
@endif
</x-btn>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
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">
<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>

View File

@@ -6,6 +6,9 @@
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
</a>
<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>
@can('settings.manage')
<x-btn data-ritorno variante="secondary" :href="route('proclamatori.edit', $proclamatore)">
@@ -36,56 +39,118 @@
</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 --}}
<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>
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni)
<div class="mb-6">
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $assegnazione)
<tr>
<td data-label="Territorio" class="px-3 py-2">
<a data-ritorno href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">
{{ $assegnazione->territorio?->numero ?? 'N/A' }}
</a>
</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2">
@if($assegnazione->returned_at)
{{ $assegnazione->returned_at->format('d/m/Y') }}
@else
<span class="text-amber-600 font-medium">In corso</span>
@endif
</td>
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</td>
<td data-label="Campagna" class="px-3 py-2">
@if($assegnazione->counted_in_campaign)
<span class="text-green-600">{{ $assegnazione->campagna?->descrizione }}</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@empty
{{-- Come nella scheda del territorio: una tabella sola, colonne fisse,
l'anno come riga di separazione (tm-riga-gruppo), dal più recente. --}}
@if($assegnazioniPerAnno->isEmpty())
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
@endforelse
@else
<div class="overflow-x-auto">
<table class="min-w-full table-fixed text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="w-[36%] px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
<th class="w-[9%] px-3 py-2 text-right text-xs font-medium text-gray-500">Giorni</th>
<th class="w-[25%] px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
</tr>
</thead>
@foreach($assegnazioniPerAnno as $annoLabel => $assegnazioni)
<tbody class="divide-y divide-gray-100">
<tr class="tm-riga-gruppo">
<th colspan="5" scope="colgroup" class="px-3 pt-5 pb-2 text-left text-sm font-semibold text-indigo-600 bg-white">Anno Teocratico {{ $annoLabel }}</th>
</tr>
@foreach($assegnazioni as $assegnazione)
<tr>
<td data-label="Territorio" class="px-3 py-2">
<a data-ritorno href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $assegnazione->territorio?->numero ?? 'N/A' }}</a>@if($assegnazione->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo_nome }}</span>@endif
</td>
<td data-label="Assegnato" class="px-3 py-2 tabular-nums">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2 tabular-nums">
@if($assegnazione->returned_at)
{{ $assegnazione->returned_at->format('d/m/Y') }}
@else
<span class="text-amber-600 font-medium">In corso</span>
@endif
</td>
<td data-label="Giorni" class="px-3 py-2 text-right tabular-nums">{{ $assegnazione->giorni }}</td>
<td data-label="Campagna" class="px-3 py-2">
@if($assegnazione->counted_in_campaign)
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
@elseif($assegnazione->counted_in_campaign === false)
<span class="text-gray-400">No</span>
@else
<span class="text-gray-300">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
@endforeach
</table>
</div>
@endif
</div>
</div>

View File

@@ -1,8 +1,8 @@
<div>
<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>
Torna alla home
{{ $gruppo ? 'Torna al mio gruppo' : 'Torna alla home' }}
</a>
<h1 class="text-2xl font-bold text-gray-900">Il mio profilo</h1>
</div>
@@ -25,10 +25,41 @@
</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 --}}
<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>
<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'))
<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">
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</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>
<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">
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@endif
<div class="flex justify-end">
<x-btn variante="primary" type="submit">Salva modifiche</x-btn>
</div>

View File

@@ -87,7 +87,7 @@
</div>
</div>
</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="Rientrato" class="px-3 py-2">
@if($a->returned_at)

View File

@@ -132,7 +132,7 @@
@if($user->proclamatore_id)
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo. --}}
<span class="text-xs text-gray-500">
{{ $user->disattivato_at ? 'Disattivata (gruppo sciolto)' : 'Sorvegliante: si gestisce da Gruppi di servizio' }}
Sorvegliante: si gestisce da Gruppi di servizio
</span>
@else
<div class="flex gap-2">

View File

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

View File

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

View File

@@ -68,12 +68,12 @@ class GruppiServizioTest extends TestCase
$rossi = $this->proclamatore('Rossi', 'Mario');
$verdi = $this->proclamatore('Verdi', 'Anna');
$bianchi = $this->proclamatore('Bianchi', 'Luca');
[$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, 'mario@example.test', [$verdi->id, $bianchi->id]);
[$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, [$verdi->id, $bianchi->id]);
$territori = collect(['12', '14', '35'])->map(fn ($n) => $this->territorio($n));
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all());
$territori = $territori->map->fresh();
$sorvegliante = User::where('email', '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.
// E l'email non confermata, come nella realtà (TestCase le conferma).
$sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save();
@@ -87,15 +87,17 @@ class GruppiServizioTest extends TestCase
{
$rossi = $this->proclamatore('Rossi', 'Mario');
// L'email non si chiede: è fittizia, iniziale del nome e cognome.
$pagina = Livewire::test(GruppiIndex::class)
->assertDontSee('Email per l')
->set('nome', 'Zisa')
->set('sorveglianteId', $rossi->id)
->set('email', 'mario@example.test')
->call('crea')
->assertHasNoErrors();
->assertHasNoErrors()
->assertSee('mrossi@termanager.it');
$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($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));
}
public function test_l_email_fittizia_viene_da_iniziale_del_nome_e_cognome(): void
{
$email = fn (string $cognome, string $nome) => GruppiServizio::emailFittizia($this->proclamatore($cognome, $nome));
$this->assertSame('ffontana@termanager.it', $email('Fontana', 'Francesco'));
$this->assertSame('fdangelo@termanager.it', $email("D'Angelo", 'Francesco'));
$this->assertSame('ndeluca@termanager.it', $email('De Luca', 'Niccolò'));
// Già presa da un altro utente: un numero in coda.
User::create(['name' => 'Francesco Fontana', 'email' => 'ffontana@termanager.it', 'password' => 'password-di-prova']);
$this->assertSame('ffontana2@termanager.it', $email('Fontana', 'Federica'));
// Due gruppi con lo stesso nome no.
app(GruppiServizio::class)->crea('Zisa', $this->proclamatore('Gialli'));
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->crea('Zisa', $this->proclamatore('Blu'));
}
public function test_un_proclamatore_sta_in_un_solo_gruppo(): void
{
['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo();
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'), 'neri@example.test');
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'));
$this->expectException(InvalidArgumentException::class);
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');
}
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->flushSession();
auth()->logout();
$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'));
}
@@ -320,28 +367,47 @@ class GruppiServizioTest extends TestCase
public function test_togliere_il_ruolo_scioglie_il_gruppo(): void
{
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
// L'assegnazione la fa il sorvegliante, come nella realtà.
auth()->setUser($s);
$data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
auth()->setUser($this->admin);
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])->call('sciogli');
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])
->call('sciogli')
->assertRedirect(route('gruppi.index'));
$gruppo->refresh();
$this->assertNotNull($gruppo->sciolto_at);
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione normale.
// Il gruppo non resta nello storico: non esiste più.
$this->assertNull(GruppoServizio::find($gruppo->id));
$this->get(route('gruppi.index'))->assertOk()->assertSee('Nessun gruppo di servizio');
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione
// normale, che del gruppo conserva solo il nome.
$this->assertNull($territori[1]->fresh()->gruppo_servizio_id);
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id);
$this->assertSame('in_reparto', $territori[2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at);
$this->assertSame($gruppo->id, $data->fresh()->gruppo_servizio_id, 'Lo storico continua a dirlo');
// Membri liberi, utenza disattivata e senza ruolo.
$this->assertSame(0, Proclamatore::where('gruppo_servizio_id', $gruppo->id)->count());
$this->assertTrue($s->fresh()->isDisattivato());
$this->assertFalse($s->fresh()->hasRole('sorvegliante'));
$this->assertSame('assegnato', $territori[0]->fresh()->stato);
$this->assertNull($data->fresh()->gruppo_servizio_id);
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
$this->get(route('territori.show', $territori[0]))->assertOk()->assertSee('via gruppo Zisa');
// Membri liberi; l'utenza del sorvegliante non c'è più, lui torna un
// proclamatore come gli altri.
$this->assertSame(0, Proclamatore::whereNotNull('gruppo_servizio_id')->count());
$this->assertNull(User::find($s->id));
$this->assertNotNull(Proclamatore::find($s->proclamatore_id));
$this->assertSame(0, \Illuminate\Support\Facades\DB::table('model_has_roles')->where('model_id', $s->id)->count());
// Quello che aveva fatto passa a chi ha sciolto il gruppo; nel registro
// le sue voci restano, senza dati personali.
$this->assertSame($this->admin->id, $data->fresh()->created_by);
$voce = \Spatie\Activitylog\Models\Activity::where('description', 'assigned')->sole();
$this->assertNull($voce->causer);
$this->assertSame('Sorvegliante del gruppo Zisa', $voce->properties['causer_name']);
$this->assertNull($voce->properties['causer_email']);
// Per ripartire serve un gruppo nuovo.
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$territori[1]->id]);
}
public function test_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();
$password = 'una-password-nuova-1';
@@ -350,63 +416,183 @@ class GruppiServizioTest extends TestCase
$this->flushSession();
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');
$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);
$pagina = Livewire::test(GruppiIndex::class);
$this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati');
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa 2', $rossi->fresh(), 'mario.nuovo@example.test');
// Sciolto, il nome del gruppo e l'email sono di nuovo liberi.
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi->fresh());
$utente = $s->fresh();
$this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza');
$this->assertFalse($utente->isDisattivato());
$utente = User::where('proclamatore_id', $rossi->id)->sole();
$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(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
$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();
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
->call('deleteProclamatore', $rossi->id)
->call('anonimizza', $rossi->id);
$this->assertFalse($rossi->fresh()->trashed());
$this->assertSame('Rossi', $rossi->fresh()->cognome);
// Un membro qualsiasi sì, e lascia il gruppo.
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('deleteProclamatore', $verdi->id);
$this->assertTrue($verdi->fresh()->trashed());
$this->assertNull($verdi->fresh()->gruppo_servizio_id);
$this->assertNull(GruppoServizio::find($g['gruppo']->id), 'Gruppo sciolto ed eliminato');
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
$this->assertNull($g['territori'][1]->fresh()->gruppo_servizio_id, 'I non dati tornano in reparto');
$this->assertSame('in_reparto', $g['territori'][2]->fresh()->stato);
$this->assertNull($data->fresh()->returned_at, 'Quello già dato resta al membro');
$this->assertNull($g['bianchi']->fresh()->gruppo_servizio_id, 'Membri liberi');
$this->assertNull(User::find($g['sorvegliante']->id), 'Utenza eliminata');
}
public function test_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();
app(GruppiServizio::class)->sciogli($gruppo);
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id);
$utente = $s->fresh();
$this->assertSame('Anonimo', $utente->name);
$this->assertStringEndsWith('@anonimo.invalid', $utente->email);
$this->assertTrue($utente->isDisattivato());
$this->assertCount(0, $utente->roles);
$this->assertNull(GruppoServizio::find($gruppo->id));
$this->assertNull(User::find($s->id), "Nome ed email dell'utenza spariscono con lei");
$this->assertSame('Anonimo', Proclamatore::withTrashed()->find($rossi->id)->nome);
}
public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void
@@ -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 ───────────────────────────────────
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(['12', '14', '35'], $g->territori->pluck('numero')->all());
$this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id);
$this->assertSame($g->sorvegliante_id, User::where('email', 'mario@example.test')->sole()->proclamatore_id);
$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'))
->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'));
}
}