Compare commits

..

36 Commits

Author SHA1 Message Date
Francesco Picone
9999a3b031 ++ gruppi: l'assegnazione dal pacchetto rilegge territorio e membro, e ricontrolla il pacchetto sotto lock
HANDOFF: trappola di Blade sul @php in linea, test sul server prima del push.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:51:37 +02:00
Francesco Picone
5d0ae06eca ++ Update gitignore 2026-10-19 17:11:30 +02:00
Francesco Picone
d3314a9d17 ++ HANDOFF: rilascio in produzione dei proclamatori senza contatti
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:07:47 +02:00
Francesco Picone
7384a4b39d ++ proclamatori senza email e telefono; il link si copia o si condivide
- Migrazione: tolte le colonne email e telefono dei proclamatori (vuote
  in produzione). Tolti da form, scheda, elenco, XML (un file vecchio si
  importa ignorandoli).
- Invio del link: solo "Copia link" e "Condividi…" (menu di condivisione
  del telefono, navigator.share): il destinatario lo sceglie chi usa
  l'app. Via WhatsApp diretto, canale email, notifica LinkTerritorio,
  componente x-whatsapp.
- Elenco assegnati: "Condividi" al posto di WhatsApp; a condivisione
  fatta il link risulta inviato.
- Informativa privacy, README, HANDOFF e test aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:57:23 +02:00
Francesco Picone
3000ca9939 ++ HANDOFF: cookie di sessione secure in produzione, nota sull'autodeploy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:19:07 +02:00
Francesco Picone
3451663cfb ++ HANDOFF: rilascio in produzione del 19 ottobre 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:14:43 +02:00
Francesco Picone
e4549f8ff8 ++ tolto S-12_I.pdf dalla radice (copia non usata di resources/pdf/S-12_I.pdf)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:10:16 +02:00
Francesco Picone
d8c519542b Merge di main in dev: allineamento prima del passaggio in produzione
Da main arrivano S-12_I.pdf e una riga di .gitignore. Nei 5 conflitti
(README, CampagnaShow, Registro, Assegnazione, vista campagna) tenuta la
versione di dev, che contiene già le correzioni sulle campagne fatte su
main. HANDOFF: analisi d'impatto e procedura per prod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:07:19 +02:00
Francesco Picone
034329471b ++ informativa privacy aggiornata, valori dalle impostazioni
- Dati trattati attuali: email e telefono cifrati, link alla piantina,
  stato dell'invio, conferma email, log senza nomi.
- Valori letti al momento: congregazione, conservazione del log, scadenza
  del link, download del PDF, server di posta, cifratura, durata della
  sessione, indirizzo/HTTPS, ruoli con i permessi veri, conteggi dei dati.
- Servizi esterni: WhatsApp, posta, OpenStreetMap.
- Dichiarato che backup ed export XML contengono i dati in chiaro.
README e HANDOFF aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:58:35 +02:00
Francesco Picone
840fbe4c16 ++ elenco assegnati: pulsanti di nuovo con <x-btn>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:53:43 +02:00
Francesco Picone
a10d06aa6c ++ elenco assegnati: righe compatte (classi brevi, copia condivisa)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:51:57 +02:00
Francesco Picone
81a8f2ba33 ++ pagina Assegna più leggera; "Vedi tutti" in testata nella Home
Assegna pesava 387 KB e Livewire li rimandava a ogni interazione.
- Elenco dei territori assegnati in un componente lazy a sé
  (ElencoAssegnati): il form compare subito e non lo ridisegna.
- Ricerca nell'elenco e anteprima della miniatura nel browser: nessuna
  richiesta al server mentre si scrive o si sceglie il territorio.
- Icone SVG come simboli richiamati con <use>.
- Assegnazione::giorni: cast a int (Carbon 3 restituisce un float).

Home: "Vedi tutti" diventa un pulsante nella testata dei riquadri.
README e HANDOFF aggiornati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:49:08 +02:00
Francesco Picone
fda428f242 ++ scelta di territori e proclamatori con ricerca
Al posto delle tendine (Assegna, Registro, editor delle piantine) un
campo in cui si scrive e compaiono le proposte più vicine: uguale, inizia
con, contiene, errore di battitura ("forse cercavi"); parole in qualsiasi
ordine, senza badare a maiuscole e accenti. Campo vuoto: la tendina
completa. Fatto anche per il telefono (16px, voci da 44px, scelta al tocco).

- Logica pura in resources/js/scelta.js con test node (tests/js).
- Opzioni dal server: Territorio/Proclamatore::opzioneScelta(); i
  territori già assegnati si trovano ("già assegnato a …") ma non si
  scelgono. Nomi cifrati ordinati e cercati decifrati, mai in SQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:35:56 +02:00
Francesco Picone
2873424c3c ++ Update gitignore 2026-10-19 15:25:56 +02:00
Francesco Picone
bc465f53f5 ++ dopo un'operazione si torna alla pagina di partenza, con i suoi filtri
- Gli elenchi tengono ricerca, filtri e ordinamento nell'indirizzo
  (#[Url]); la pagina c'era già.
- I link verso schede e operazioni (data-ritorno) portano ?da= con la
  pagina di partenza, o il da già ricevuto: si risale all'origine
  (elenco -> scheda -> Rientra -> elenco filtrato).
- Ritorno::valida accetta solo percorsi interni: niente rimandi esterni.
- Trait RicordaRitorno: redirect dopo il salvataggio, Annulla e
  "Torna indietro" usano la pagina di partenza. Senza da, tutto come prima.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:04:42 +02:00
Francesco Picone
0c530b65b3 ++ fix test: flash subito prima del redirect, apostrofo nel testo statico
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:39:56 +02:00
Francesco Picone
f8539b58bd ++ invio del link dopo l'assegnazione; nuovo territorio dal designer
- Dopo l'assegnazione si apre una finestra con i canali (copia link
  sempre; WhatsApp ed email solo se il proclamatore ha numero /
  indirizzo, l'email solo con la posta configurata) e la domanda "Hai
  inviato il link?". Si va avanti solo dopo la risposta.
- L'email la manda l'app con notifyNow, mai in coda: l'indirizzo è
  cifrato nel database e non deve finire in chiaro nei job.
- Procedura interrotta o "non ancora": assegnazioni.link_da_inviare
  resta vero e "Link ancora da inviare" compare nell'elenco Assegna e
  nella scheda territorio, con "Invia link" per riprendere.
- Colonne link_da_inviare e link_canale (anche nell'XML); log con
  canale e proclamatore_id, senza dati personali.
- "Nuovo territorio" apre il designer delle piantine già in disegno, con
  la prima piantina pronta a creare il territorio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:36:44 +02:00
Francesco Picone
bde7a4a8a8 ++ update gitignore 2026-10-19 12:33:20 +02:00
Francesco Picone
f8911afe64 Revert "++ amministratori esenti dalla conferma email"
Annullata su richiesta: anche gli amministratori devono confermare
l'email. Torna valido il comportamento di fc8623f.

This reverts commit ac78703.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:28:58 +02:00
Francesco Picone
ac787039c3 ++ amministratori esenti dalla conferma email
Chi ha il ruolo amministratore non deve confermare l'indirizzo
(User::esenteDaConfermaEmail): non riceve l'email di conferma né alla
creazione né al cambio d'indirizzo, in Utenti compare "esente". Se perde
il ruolo torna a valere email_verified_at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:19:29 +02:00
Francesco Picone
2f42cf937c ++ rientro: avviso tempo di percorrenza elevato nel banner campagna
Se il territorio è rimasto fuori oltre la soglia "giorni_per_smarrito",
il banner della campagna mostra un avviso (non bloccante). Giorni e
avviso vengono salvati anche nell'evento "campagna_percorso".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:43 +02:00
Francesco Picone
1708890d48 ++ audit log: evento "campagna_percorso" per la scelta sul conteggio in campagna
Registrato al rientro quando viene proposta una campagna (Sì/No) e nel
Registro quando l'amministratore imposta o modifica il conteggio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:43 +02:00
Francesco Picone
097aa4ff30 ++ campagne: "In corso" solo per territori assegnati nel periodo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:08 +02:00
Francesco Picone
6ef301cb14 ++ fix statistiche campagne
- Dettaglio campagna: mostra tutti i territori conteggiati (stessa base
  della percentuale), non solo quelli assegnati dopo l'inizio.
- Rientro: se più campagne corrispondono propone la più recente invece
  della prima creata; testo del banner chiarito.
- Dettaglio campagna: nuova sezione "In corso" con i territori fuori
  durante la campagna e non ancora rientrati.
- README: regola di conteggio documentata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:07 +02:00
Francesco Picone
358cbfc81c ++ rientro: avviso tempo di percorrenza elevato nel banner campagna
Se il territorio è rimasto fuori oltre la soglia "giorni_per_smarrito",
il banner della campagna mostra un avviso (non bloccante). Giorni e
avviso vengono salvati anche nell'evento "campagna_percorso".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:54:07 +02:00
Francesco Picone
40b4ca0b0b ++ audit log: evento "campagna_percorso" per la scelta sul conteggio in campagna
Registrato al rientro quando viene proposta una campagna (Sì/No) e nel
Registro quando l'amministratore imposta o modifica il conteggio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:37:37 +02:00
Francesco Picone
2f0a24bca4 ++ campagne: "In corso" solo per territori assegnati nel periodo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:33:07 +02:00
Francesco Picone
223d44300c ++ fix statistiche campagne
- Dettaglio campagna: mostra tutti i territori conteggiati (stessa base
  della percentuale), non solo quelli assegnati dopo l'inizio.
- Rientro: se più campagne corrispondono propone la più recente invece
  della prima creata; testo del banner chiarito.
- Dettaglio campagna: nuova sezione "In corso" con i territori fuori
  durante la campagna e non ancora rientrati.
- README: regola di conteggio documentata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:18:09 +02:00
754adf9878 ++ Add S-12 Template 2026-08-31 12:28:53 +02:00
Francesco Picone
76ea127d5b Merge branch 'main' of https://git.pyconetwork.it/francesco/termanager2
# Conflicts:
#	HANDOFF.md
2026-07-26 00:16:13 +02:00
Francesco Picone
3ec2422fbc ++ update HANDOFF.md 2026-07-25 19:53:12 +02:00
Francesco Picone
6b11ab7028 ++ fix permessi assegnazioni 2026-07-25 19:43:29 +02:00
86 changed files with 5244 additions and 848 deletions

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@
docker-compose.override.yml docker-compose.override.yml
db_data/ db_data/
redis_data/ redis_data/
.claude

View File

@@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
può dare: perché le cose sono come sono, e cosa resta aperto. può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui). Ultimo aggiornamento: **19 ottobre 2026** (gruppi di servizio, solo su dev) — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
--- ---
@@ -106,11 +106,9 @@ Richieste dell'utente, con le sue scelte:
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
considera italiano). considera italiano).
- **Dopo l'assegnazione** si resta sulla pagina Assegna: in cima un riquadro - **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
per ogni assegnazione appena fatta con il link del PDF, "Invia su WhatsApp" dopo l'assegnazione" qui sotto*. Il riquadro in sessione
(messaggio già scritto con il link) e la scelta "Sì, inviato / Non ancora", (`Assegna::DA_INVIARE`) non esiste più.
che imposta `link_sent`. Resta finché non si sceglie
(`Assegna::DA_INVIARE` in sessione).
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già - **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`). 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 L'admin crea l'utente con la password come prima; parte l'email di conferma
@@ -129,6 +127,313 @@ email di prova e di conferma arrivate, link aperto senza login, account
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
e dati di prova rimossi dopo. e dati di prova rimossi dopo.
### Gruppi di servizio (19 ottobre 2026, solo su dev)
Richiesta dell'utente: un **gruppo di servizio** con un sorvegliante che,
entrato con una sua utenza, distribuisce ai membri un pacchetto di territori
e non vede nient'altro. Scelte dell'utente:
- Il gruppo ha **membri** (proclamatori); un proclamatore sta in **un solo**
gruppo; il sorvegliante è anche membro e assegna solo ai membri, a sé
compreso.
- Il **pacchetto non è un'assegnazione**: "la media non deve essere alterata".
I territori del pacchetto sono "In gruppo …" (stato `in_gruppo`), fuori da
"Da assegnare" e dalla pagina *Assegna* (`Territorio::scopeInReparto`
esclude `gruppo_servizio_id`).
- Quando il sorvegliante dà un territorio nasce un'**assegnazione normale**,
tracciata con `assegnazioni.gruppo_servizio_id` ("via gruppo …" nel
registro e nella scheda del territorio).
- Al **rientro** il territorio esce dal pacchetto e torna in reparto
(hook `updated` in `Assegnazione`, vale da qualunque pagina).
- Il sorvegliante: assegna, manda il link (copia/condividi), registra i
rientri, apre la piantina, rimanda in reparto i non dati. Gruppi, membri e
pacchetti li gestisce **solo l'amministratore**.
- Utenza: email e password temporanea da cambiare al primo accesso, **senza
conferma dell'email** (`User::hasVerifiedEmail()` è vero per il
sorvegliante; anche cambiandola dal profilo).
- **Togliere il ruolo = sciogliere il gruppo** (`GruppiServizio::sciogli`):
non dati in reparto, membri liberi, utenza senza ruolo e disattivata
(`users.disattivato_at`: login rifiutato, sessione aperta chiusa da
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I
territori già dati restano ai membri. Si riparte con un gruppo nuovo.
Casi aggiunti di mio, da far confermare all'utente:
- Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la
sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id`
è unico), con email e password temporanea nuove.
- Il sorvegliante di un gruppo **attivo** non si sposta nel cestino né si
anonimizza (`ProclamatoreIndex`): prima si scioglie il gruppo. Un membro
spostato nel cestino lascia il gruppo. `Proclamatore::anonimizza()`
anonimizza anche l'utenza dell'ex sorvegliante (nome, email, password,
ruoli).
- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro
delle attività `User::tapActivity()` li sostituisce con "proclamatore #id",
come vale per tutti i proclamatori. Restano invece in chiaro nella tabella
`users`, come per ogni utente.
Dove sta:
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e
assegnazioni, `users.proclamatore_id` (unico) e `users.disattivato_at`,
permesso `gruppo.gestisci` e ruolo `sorvegliante`. Il seeder dà
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`.
- Regole in `App\Services\GruppiServizio` (crea, aggiungiMembri, togliMembro,
aggiungiAlPacchetto, rimandaInReparto, assegna, sciogli); i componenti
`Gruppi\GruppiIndex`, `Gruppi\GruppoShow` (amministratore) e
`Gruppi\MioGruppo` (sorvegliante) le chiamano soltanto.
- `LimitaSorvegliante` (gruppo `web`): chi ha **solo** il ruolo sorvegliante
raggiunge `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo,
controllo in `Rientra::autorizza`), profilo e cambio password; la Home lo
rimanda al gruppo; il resto è 403.
- L'invio del link è diventato il trait `Concerns\InviaLink` + il partial
`assegnazioni/_invio-link`, usati da *Assegna* e da *Il mio gruppo*.
- Il ruolo sorvegliante non compare nella pagina *Utenti* e le utenze legate a
un proclamatore lì non si modificano né si eliminano: passano dal gruppo.
- La voce di menu "Il mio gruppo" guarda il **ruolo**, non `@can`:
l'amministratore passa ogni `@can` (`Gate::before`).
- XML: si esportano e reimportano gruppi, membri, pacchetto, provenienza delle
assegnazioni e il legame utenza ↔ proclamatore.
- Informativa privacy aggiornata (utenza del sorvegliante, gruppo tra i dati
del proclamatore).
- L'azzeramento dell'applicazione (*Zona pericolosa*) cancella anche i gruppi.
- Test: `tests/Feature/GruppiServizioTest.php`. Eseguiti sul server **prima**
del push, su una copia della checkout di dev con i commit portati da un
`git bundle` (la checkout di Dokploy è shallow: il bundle deve partire dal
commit che ha, `git log -1` lì dentro).
Non provato nel browser. **In produzione non c'è**: serve l'ok dell'utente.
### Proclamatori senza contatti; il link si copia o si condivide (19 ottobre 2026)
Decisione dell'utente, dopo il rilascio: i proclamatori **non hanno più email
né telefono**, e WhatsApp ed email spariscono come canali. Restano:
- **Copia link**;
- **Condividi…**: `navigator.share()` con il messaggio già scritto
(`Assegnazione::messaggioLink()`), cioè il menu di condivisione del telefono
(WhatsApp, Telegram, SMS…), da cui chi usa l'app sceglie app e contatto.
Compare solo dove il browser lo offre (telefoni, alcuni computer); il canale
si annota a condivisione fatta, non se la si annulla. Nell'elenco dei
territori assegnati "Condividi" andato a buon fine segna il link come
inviato (`ElencoAssegnati::segnaCondiviso`).
Cosa è cambiato:
- Migrazione `2026_10_19_000020_togli_contatti_proclamatori`: elimina le
colonne `email` e `telefono`. In produzione erano vuote (0 su 111) al
momento della decisione; su dev c'era un solo valore di prova.
- Tolti dal form, dalla scheda e dall'elenco dei proclamatori; tolti
`Proclamatore::telefonoInternazionale()/whatsappUrl()`, il componente
`<x-whatsapp>`, gli stili `tm-whatsapp`, la notifica `LinkTerritorio` e
`Assegna::inviaEmail()`.
- XML: non si esportano più; un file vecchio che li contiene si importa
ignorandoli.
- `Assegnazione::CANALI_ATTIVI` = copia, condividi. In `CANALI_LINK` restano
anche "whatsapp" ed "email" solo per le assegnazioni già inviate così.
- La posta resta, ma solo per le email agli utenti (conferma dell'indirizzo).
- Informativa privacy, README e test (`InvioLinkTest`, al posto di
`ContattiProclamatoriTest`) aggiornati.
**In produzione dal 19 ottobre 2026, ore 17:10** (`main` = `7384a4b`). Prima:
ricontrollato che email e telefono fossero vuoti (0 su 111) e backup in
`/etc/dokploy/termanager2-backups/prod-20261019-150408/`. Dopo: migrazione
applicata, colonne tolte, conteggi identici a prima (1130 assegnazioni, 77
aperte), 111/111 proclamatori decifrati, 272/272 PDF, 4/4 utenti confermati.
L'autodeploy non era partito entro 2 minuti dal push: deploy avviato a mano.
### Allineamento di main in dev e passaggio in produzione (19 ottobre 2026)
`main` è stato unito in `dev` (merge, non rebase): da `main` arrivano solo
`S-12_I.pdf` nella radice e una riga di `.gitignore`. Quel PDF, copia
identica di `resources/pdf/S-12_I.pdf` e non usato dal codice, è stato poi
tolto su richiesta dell'utente; il modulo usato da `PiantinaPdfService` è
quello in `resources/pdf/` e resta. I 5 conflitti (README,
`CampagnaShow`, `Registro`, `Assegnazione`, vista della campagna) nascevano
dalle correzioni sulle campagne fatte su `main` la mattina e già portate su
`dev`: si è tenuta la versione di `dev`, che le contiene già (con
`proclamatore_id` nel log al posto del nome). Dopo, `main` va portato avanti
fino a `dev` senza un nuovo merge, così in produzione va esattamente il codice
provato su dev.
Analisi d'impatto per prod (fatta sui dati di prod copiati in dev):
- 11 migrazioni nuove, provate sulla copia: nessun errore, conteggi identici.
Due toccano dati esistenti: `nomi_proclamatori_fuori_dal_log` (irreversibile,
solo da backup) e `posta_contatti_e_conferma_email`, che **azzera la
conferma email di tutti gli utenti**.
- In prod non c'è un server di posta: subito dopo il deploy va eseguito
`php artisan utenti:conferma-email <email>` per ogni utente, altrimenti
nessuno riesce a lavorare. La posta si configura poi dall'app.
- `MAIL_SCHEME=tls` nell'ambiente di prod non è valido per Laravel 11:
svuotarlo. Prod è in HTTPS con `SESSION_SECURE_COOKIE=false`: valutare `true`.
- Il nuovo controllo dell'entrypoint su `APP_KEY` passa: chiave d'ambiente e
`storage/app/.app_key` coincidono.
- Ritorno indietro: ripristino del dump fatto prima del deploy e deploy del
`main` precedente (`bde7a4a`).
**Rilasciato in produzione il 19 ottobre 2026 alle 16:13** (`main` =
`e4549f8`, fast-forward da `dev`). Prima: backup in
`/etc/dokploy/termanager2-backups/prod-20261019-141137/` sul server (database,
file, conteggi) e copia del dump sul PC dell'utente; `MAIL_SCHEME` svuotato
nell'ambiente di prod. Deploy di 32 secondi, 11 migrazioni applicate senza
errori, email dei 4 utenti confermata con `utenti:conferma-email`. Verifica:
conteggi identici a prima del deploy, 111/111 proclamatori decifrati, 272/272
PDF presenti, campagna di settembre a 13 conteggiati (4,7%), nessun nome in
chiaro rimasto nel log. Subito dopo, su decisione dell'utente,
`SESSION_SECURE_COOKIE=true` nell'ambiente di prod (nuovo deploy: i cookie di
sessione e XSRF ora sono `secure; httponly; samesite=lax`). Resta da fare:
configurare la posta dall'app (*Impostazioni → Server di posta*) per poter
usare il canale Email.
Deploy: su Dokploy l'autodeploy da push è attivo (`triggerType=push`, nessun
filtro di percorso) e secondo l'utente funziona. Le variabili d'ambiente
invece diventano effettive solo con un nuovo deploy, che una loro modifica
non avvia da sola.
### Informativa privacy aggiornata e dinamica (19 ottobre 2026)
Richiesta dell'utente: aggiornare la sezione privacy con i dati attuali e
prendere i parametri dalle impostazioni. Il testo era fermo a una versione
precedente (solo nome e cognome, "tre ruoli: Admin, Servitore di Territorio,
Proclamatore Semplificato" che non esistono).
- `App\Livewire\Privacy` legge al momento: congregazione,
`audit_retention_days`, `giorni_per_smarrito` (scadenza del link),
`pdf_viewer_show_download`, server di posta (`ConfigurazionePosta`),
`config('app.cipher')`, `config('session.lifetime')`, `APP_URL` (avvisa se
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.
- 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).
- `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli,
i conteggi senza nomi e l'accesso solo amministratore.
### Pagina Assegna più leggera; "Vedi tutti" in testata nella Home (19 ottobre 2026)
Segnalazione dell'utente: la pagina Assegnazioni era lenta. Misurato su dev
con i dati di prod (79 assegnazioni aperte): il server la genera in ~80 ms
con 10 query, ma l'HTML pesava **387 KB** (299 l'elenco dei territori
assegnati, di cui 58 di icone SVG ripetute; 75 le opzioni dei campi di scelta)
e Livewire lo rimandava **per intero a ogni interazione**: scegliere un
territorio (anteprima con `wire:model.live`), scrivere nella ricerca
dell'elenco (`linkSearch`), aprire l'invio del link. Rimedi:
- L'elenco è un componente a sé, `Assegnazioni\ElencoAssegnati`, **lazy**: la
pagina mostra subito il form e le sue azioni non lo ridisegnano. "Invia
link" nell'elenco chiede alla pagina di aprire l'invio con l'evento
`apri-invio` (`Assegna::apriInvio` ha `#[On('apri-invio')]`);
`toggleLinkSent` sta nell'elenco.
- La ricerca nell'elenco filtra nel browser (`data-cerca` senza accenti,
Alpine `elencoCercabile`): nessuna richiesta mentre si scrive.
- Il territorio va a Livewire con `wire:model` (non più `.live`): l'anteprima
della miniatura segue la scelta nel browser (evento `scelta` di
`<x-scelta>`, URL della miniatura nelle opzioni).
- Righe compatte: icone come simboli SVG definiti una volta e richiamati con
`<use>`, classi brevi `tm-assegnato-*` in `app.css` al posto delle catene
di utility, copia del link con il metodo condiviso `copia()` di
`elencoCercabile`. I pulsanti restano `<x-btn>` (lo impone
`StileUnificatoTest`). Il ramo "link scaduto" dell'elenco (solo per non autenticati, cioè
mai su questa pagina) è stato tolto.
- `AssegnaPrestazioniTest` tiene fermi questi punti, compreso un tetto al
peso per riga dell'elenco.
- Corretto anche `Assegnazione::giorni`: con Carbon 3 `diffInDays()` è un
float e PHP segnalava una conversione implicita a ogni riga.
Home: "Vedi tutti" di *Da assegnare* e *Da rientrare* è ora un pulsante nella
testata del riquadro, accanto al PDF, invece che in fondo all'elenco. Compare
alle stesse condizioni di prima (elenco troncato al limite della Home).
### Scelta di territori e proclamatori con ricerca (19 ottobre 2026)
Al posto delle tendine di territorio e proclamatore (Assegna, finestra del
Registro, editor delle piantine) c'è `<x-scelta>`: si scrive e compaiono le
proposte più vicine; campo vuoto o appena aperto = la tendina completa.
- Ordine: uguale → inizia con → contiene → a una lettera di distanza ("forse
cercavi", due lettere dalle 7 in su; mai sui numeri sotto le 4 cifre, così
"35" non trova "36"). Le parole scritte valgono in qualsiasi ordine ("mario
ros"); maiuscole e accenti non contano. Logica pura in
`resources/js/scelta.js`, provata da `tests/js/scelta.test.mjs`
(`npm run test:js`, oppure `node --test tests/js/`).
- Le opzioni le prepara il server (`Territorio::opzioneScelta()`,
`Proclamatore::opzioneScelta()/opzioniScelta()`). Con `motivo` un'opzione
compare cercando ma non si sceglie: in Assegna i territori già assegnati
("già assegnato a …"), nel Registro territori e proclamatori disattivati.
- Nomi e cognomi sono cifrati: si ordinano e si cercano decifrati, nella
pagina. Prima il Registro li ordinava in SQL, cioè a caso.
- Telefono: testo a 16px sotto i 640px (iOS non ingrandisce), voci alte 44px,
elenco in mezzo schermo, scelta al tocco senza perdere il focus; dopo la
scelta col dito il campo perde il focus e la tastiera si chiude.
- Con Livewire: `wire:model` sul componente (x-modelable). `vuoto` è il valore
di "nessuna scelta": `null` per le proprietà `?int` (Assegna), `''` per le
stringhe. Il componente si ridisegna quando cambiano le opzioni (wire:key
con l'impronta), perché x-data le legge una volta sola.
- Il filtro "Filtra territori" di Assegna non serve più ed è stato tolto.
### Si torna da dove si è partiti (19 ottobre 2026)
Regola dell'utente: dopo un'operazione — confermata **o annullata** — si torna
alla pagina da cui il percorso era partito, con i suoi filtri, ricerca,
ordinamento e pagina. Elenco filtrato → scheda → Rientra → conferma riporta
all'**elenco filtrato**, non alla scheda (scelta esplicita).
- Gli elenchi (territori, proclamatori, registro) tengono ricerca, filtri e
ordinamento nell'indirizzo con `#[Url]`; la pagina c'era già (paginazione).
- I link verso schede e operazioni hanno `data-ritorno`: `resources/js/app.js`
gli aggiunge `?da=<percorso>` al momento dell'uso (pointerdown/click/menu
contestuale/focus, prima di wire:navigate). Se la pagina corrente ha già un
`da`, passa quello: si risale sempre all'origine.
- `App\Support\Ritorno::valida()` accetta solo percorsi interni (niente
`https://…`, `//…`, `\`, caratteri di controllo, `/livewire/…`): il
parametro non può diventare un rimando verso l'esterno.
- Le pagine di scheda e operazione usano il trait `RicordaRitorno`
(`$ritorno`, `destinazione()`): redirect dopo il salvataggio, "Annulla" e
"Torna…" (che con un ritorno diventa "Torna indietro").
- Aperte senza `da` (menu laterale, link salvato) tutto resta com'era.
- Link nuovi verso una scheda o un'operazione: metterci `data-ritorno`;
pagine nuove di operazione: usare `RicordaRitorno`.
### Invio del link dopo l'assegnazione, nuovo territorio dal designer (19 ottobre 2026)
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
- *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.
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
possibile. Solo dopo la risposta si ricarica la pagina.
- **Se la procedura non si conclude** (scheda chiusa) o si risponde *No*, resta
`assegnazioni.link_da_inviare = true` e compare "Link ancora da inviare"
nell'elenco della pagina Assegna (con "Invia link", che riapre la finestra)
e nella scheda del territorio (che rimanda a `assegnazioni/assegna?invia=<id>`).
L'avviso sparisce quando il link risulta inviato, anche dal pulsante
"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.
- 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.
- **"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.
### Revisione profonda: blocchi 2 e 3 chiusi ### Revisione profonda: blocchi 2 e 3 chiusi
| Problema | Correzione | | Problema | Correzione |
@@ -1327,6 +1632,14 @@ stringa invece di proteggere l'apostrofo, perché fra il file e l'attributo non
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco, c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
da nessuna parte; apostrofi italiani solo dentro backtick. da nessuna parte; apostrofi italiani solo dentro backtick.
**In una vista Blade la forma in linea `@php(...)` non va messa prima di un
blocco `@php … @endphp`.** Blade estrae i blocchi `@php … @endphp` per primi,
con un'espressione che parte dalla prima `@php` che trova: se è quella in
linea, ingoia tutto fino al primo `@endphp` e la vista non compila più
("unexpected token class"). Successo in *Il mio gruppo* il 19/10/2026. E
vale anche dentro i commenti `{{-- --}}`, che Blade toglie *dopo*: lì la
parola `@php` non va scritta.
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 — **rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire. provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.

156
README.md
View File

@@ -338,6 +338,16 @@ Cosa serve sapere:
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va `CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
provato sullo stack vero, non qui. provato sullo stack vero, non qui.
La logica JavaScript senza DOM (la ricerca nei campi di scelta,
`resources/js/scelta.js`) ha i suoi test in `tests/js/`, con il test runner di
Node:
```bash
npm run test:js
# oppure, senza Node installato:
docker run --rm -v "$PWD":/app -w /app node:20-alpine node --test tests/js/
```
--- ---
## Variabili d'ambiente ## Variabili d'ambiente
@@ -678,11 +688,15 @@ docker compose --profile dev up -d --build
- Media mensile di percorrenza e durata media delle assegnazioni - Media mensile di percorrenza e durata media delle assegnazioni
- Campagna attiva con barra di progresso - Campagna attiva con barra di progresso
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**, - Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
**da rientrare** **da rientrare**; quando l'elenco è troncato, **"Vedi tutti"** è un pulsante
in testata che apre l'elenco territori già filtrato
### Territori ### Territori
- CRUD completo con upload PDF e miniatura generata automaticamente - CRUD completo con upload PDF e miniatura generata automaticamente
- Filtri per zona, tipologia e stato - **"Nuovo territorio"** apre il designer delle piantine già in disegno: il
territorio si crea dal confine disegnato (per chi non è amministratore resta
il form)
- Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina
- Badge di stato colorati e indicatore di priorità (manuale o automatica) - Badge di stato colorati e indicatore di priorità (manuale o automatica)
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico - Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico
- Link PDF pubblico condivisibile, a scadenza configurabile - Link PDF pubblico condivisibile, a scadenza configurabile
@@ -696,12 +710,58 @@ docker compose --profile dev up -d --build
- Soft delete con cestino - Soft delete con cestino
### Assegnazione e rientro ### Assegnazione e rientro
- Selezione territorio disponibile e proclamatore attivo - **Scelta con ricerca** di territorio e proclamatore (anche nel Registro e nel
designer delle piantine): si scrive e compaiono le proposte più vicine —
uguale, inizia con, contiene, errore di battitura ("forse cercavi") — con
le parole in qualsiasi ordine, senza badare a maiuscole e accenti. Campo
vuoto: l'elenco completo. Pensata anche per il telefono
- I territori già assegnati si trovano cercando ("già assegnato a …") ma non
si possono scegliere; l'anteprima della miniatura segue la scelta
- Anno teocratico rilevato automaticamente dalla data - Anno teocratico rilevato automaticamente dalla data
- Validazione: territorio non già assegnato, entrambi attivi - Validazione: territorio non già assegnato, entrambi attivi
- **Invio del link dopo l'assegnazione**: una finestra chiede il canale —
*Copia link*, oppure *Condividi…* (il menu di condivisione del telefono:
WhatsApp, Telegram, SMS…, dove il browser lo offre) — e se il link è stato
inviato. Il destinatario lo sceglie chi usa l'app dai propri contatti: i
proclamatori non hanno numero né email. Solo dopo la risposta si va avanti;
altrimenti l'assegnazione resta segnalata **"Link ancora da inviare"**, con
"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
- Rientro con calcolo automatico dei giorni - Rientro con calcolo automatico dei giorni
- **Prompt campagna**: se il rientro ricade nel periodo di una campagna, viene - **Prompt campagna**: se il territorio era fuori durante una campagna viene
chiesto se conteggiarlo chiesto se è stato percorso per la campagna; oltre la soglia "da rientrare"
compare un avviso di tempo di percorrenza molto alto
- **Dopo una conferma o un annulla si torna alla pagina di partenza**, con i
suoi filtri, ricerca, ordinamento e pagina (aperta dal menu: come prima)
### Gruppi di servizio
- Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome,
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
anche membro
- Creando il gruppo il sorvegliante riceve un'**utenza**: l'email indicata e
una password temporanea, mostrata una volta sola e da cambiare al primo
accesso. **Nessuna conferma dell'email**. Se la perde, l'amministratore ne
genera un'altra dalla pagina del gruppo
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto.
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo
(ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne
nasce un'**assegnazione normale**, che conta come le altre ed è segnata
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
apre la piantina e rimanda in reparto quelli non ancora dati
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante: i territori non
ancora dati tornano in reparto, i membri si liberano, l'utenza viene
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello
storico. I territori già dati restano ai membri. Per ripartire si crea un
gruppo nuovo; se il sorvegliante è lo stesso, si riattiva la sua utenza
- Il sorvegliante di un gruppo attivo non si sposta nel cestino né si
anonimizza: prima si scioglie il gruppo. Anonimizzando un ex sorvegliante
si anonimizza anche la sua utenza
### Campagne ### Campagne
- CRUD con date di inizio e fine - CRUD con date di inizio e fine
@@ -738,24 +798,31 @@ docker compose --profile dev up -d --build
## Ruoli e permessi ## Ruoli e permessi
Tre ruoli con permessi granulari (spatie/laravel-permission): Quattro ruoli con permessi granulari (spatie/laravel-permission):
| Permesso | Amministratore | Assistente | Operatore | | Permesso | Amministratore | Assistente | Operatore | Sorvegliante |
|-----------------------|:--------------:|:----------:|:---------:| |-----------------------|:--------------:|:----------:|:---------:|:------------:|
| `settings.manage` | ✅ | | | | `settings.manage` | ✅ | | | |
| `territori.manage` | ✅ | | | | `territori.manage` | ✅ | | | |
| `proclamatori.manage` | ✅ | ✅ | | | `proclamatori.manage` | ✅ | ✅ | | |
| `campagne.manage` | ✅ | ✅ | | | `campagne.manage` | ✅ | ✅ | | |
| `registro.view` | ✅ | ✅ | | | `registro.view` | ✅ | ✅ | | |
| `registro.export` | ✅ | | | | `registro.export` | ✅ | | | |
| `territori.assign` | ✅ | ✅ | ✅ | | `territori.assign` | ✅ | ✅ | ✅ | |
| `territori.return` | ✅ | ✅ | ✅ | | `territori.return` | ✅ | ✅ | ✅ | |
| `audit.view` | ✅ | | | | `audit.view` | ✅ | | | |
| `audit.export` | ✅ | | | | `audit.export` | ✅ | | | |
| `gruppo.gestisci` | | | | ✅ |
L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`). L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
Il menu laterale mostra solo le voci per cui l'utente ha i permessi. Il menu laterale mostra solo le voci per cui l'utente ha i permessi.
Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando
un gruppo di servizio e sparisce sciogliendolo. Chi ha solo quel ruolo è
confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro
dei territori del gruppo e al profilo; le sue azioni ricontrollano che
territorio e assegnazione siano del suo gruppo.
I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi
una revoca ha effetto immediato senza bisogno di ricaricare la pagina. una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
@@ -764,8 +831,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
## Sicurezza e GDPR ## Sicurezza e GDPR
### Sicurezza ### Sicurezza
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con AES-256-CBC - **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con
tramite `APP_KEY` AES-256-CBC tramite `APP_KEY`
- **Password**: hash bcrypt - **Password**: hash bcrypt
- **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle - **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle
richieste Livewire richieste Livewire
@@ -781,13 +848,24 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
restituisce 404 restituisce 404
### GDPR ### GDPR
- **Minimizzazione**: dei proclamatori si conservano solo nome, cognome e stato - **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
cambiare — congregazione, conservazione del log, scadenza dei link,
download del PDF, server di posta, durata della sessione, indirizzo e HTTPS,
ruoli con i loro permessi, quanti dati sono conservati — si leggono al
momento da impostazioni e configurazione (`App\Livewire\Privacy`)
- **Minimizzazione**: dei proclamatori si conservano solo nome, cognome e
stato. Niente telefono né email: il link lo manda chi assegna, scegliendo il
destinatario dai propri contatti, e l'app non vede quei contatti
- **Cifratura a riposo**: i dati sono illeggibili anche con accesso diretto al DB - **Cifratura a riposo**: i dati sono illeggibili anche con accesso diretto al DB
- **Audit trail**: registro completo delle operazioni - **Audit trail**: registro completo delle operazioni
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile - **Diritto all'oblio**: funzione di anonimizzazione irreversibile
- **Retention**: cancellazione automatica dei log oltre il periodo configurato - **Retention**: cancellazione automatica dei log oltre il periodo configurato
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi - **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
in chiaro in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome
di un proclamatore)
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
in un luogo protetto e cancellati quando non servono
--- ---
@@ -795,15 +873,16 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| Tabella | Descrizione | | Tabella | Descrizione |
|-------------------|--------------------------------------------------------------| |-------------------|--------------------------------------------------------------|
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova | | `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova; `proclamatore_id` lega l'utenza del sorvegliante al suo proclamatore, `disattivato_at` la chiude allo scioglimento del gruppo |
| `settings` | Configurazione singleton | | `settings` | Configurazione singleton |
| `zone` | Zone territoriali | | `zone` | Zone territoriali |
| `tipologie` | Tipologie di territorio | | `tipologie` | Tipologie di territorio |
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete | | `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete | | `gruppi_servizio` | Gruppi di servizio: nome, sorvegliante, `sciolto_at` (sciolti restano per lo storico) |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo |
| `anni_teocratici` | Periodi settembre → agosto | | `anni_teocratici` | Periodi settembre → agosto |
| `campagne` | Periodi speciali con date e descrizione | | `campagne` | Periodi speciali con date e descrizione |
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna | | `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo |
| `activity_log` | Audit trail | | `activity_log` | Audit trail |
**Vincoli e indici principali** **Vincoli e indici principali**
@@ -830,7 +909,8 @@ Calcolato dinamicamente, non memorizzato:
| Stato | Condizione | | Stato | Condizione |
|----------------|-----------------------------------------------------| |----------------|-----------------------------------------------------|
| `in_reparto` | Attivo, nessuna assegnazione aperta | | `in_reparto` | Attivo, nessuna assegnazione aperta, fuori dai pacchetti dei gruppi |
| `in_gruppo` | Nel pacchetto di un gruppo di servizio, non ancora dato a un membro |
| `assegnato` | Ha un'assegnazione con `returned_at` nullo | | `assegnato` | Ha un'assegnazione con `returned_at` nullo |
| `da_rientrare` | Assegnato da più giorni della soglia configurata | | `da_rientrare` | Assegnato da più giorni della soglia configurata |
| `inattivo` | Contrassegnato come non attivo | | `inattivo` | Contrassegnato come non attivo |
@@ -842,8 +922,26 @@ sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
quando la soglia è scattata. quando la soglia è scattata.
### Percentuale di percorrenza delle campagne ### Percentuale di percorrenza delle campagne
- **Denominatore**: tutti i territori assegnati nel periodo della campagna - **Denominatore**: tutti i territori attivi
- **Numeratore**: le assegnazioni contrassegnate come conteggiate - **Numeratore**: i territori attivi distinti con almeno un'assegnazione
collegata alla campagna e contrassegnata come conteggiata al rientro (un
territorio percorso due volte conta una volta sola)
Al rientro la campagna viene proposta se il territorio era fuori almeno in
parte del periodo: assegnato durante la campagna, rientrato durante la
campagna, oppure fuori per tutta la sua durata. Se sia stato davvero percorso
lo decide l'operatore con la spunta. Se più campagne corrispondono viene
proposta la più recente. I territori assegnati durante la campagna e non
ancora rientrati compaiono nel dettaglio come "in corso".
### Gruppi di servizio e statistiche
Mettere un territorio nel pacchetto di un gruppo **non** è un'assegnazione:
non crea righe in `assegnazioni`, quindi non tocca medie, durate, percorrenza
delle campagne né il registro. Conta solo quando il sorvegliante lo dà a un
membro: da lì è un'assegnazione come le altre (con `gruppo_servizio_id` per
sapere da dove viene). Il tempo passato nel pacchetto prima di essere dato non
viene contato. La priorità automatica guarda l'ultima assegnazione, come
sempre.
--- ---

View File

@@ -40,10 +40,25 @@ class LoginController extends Controller
->withInput($request->only('email', 'remember')); ->withInput($request->only('email', 'remember'));
} }
// Utenza disattivata (gruppo di servizio sciolto): la password è giusta
// ma non si entra.
if (Auth::user()->isDisattivato()) {
Auth::logout();
return back()
->withErrors(['email' => 'Questa utenza è stata disattivata.'])
->withInput($request->only('email', 'remember'));
}
RateLimiter::clear($throttleKey); RateLimiter::clear($throttleKey);
$request->session()->regenerate(); $request->session()->regenerate();
activity()->causedBy(auth()->user())->log('login'); activity()->causedBy(auth()->user())->log('login');
// Il sorvegliante di gruppo va dritto al suo gruppo.
if (Auth::user()->isSorvegliante() && Auth::user()->roles->count() === 1) {
return redirect()->route('mio-gruppo');
}
return redirect()->intended(route('dashboard')); return redirect()->intended(route('dashboard'));
} }
} }

View File

@@ -25,6 +25,18 @@ class TerritorioFileController extends Controller
return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf'); return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf');
} }
/**
* Il PDF di un territorio del pacchetto, per il sorvegliante di gruppo:
* solo se il territorio è nel pacchetto del gruppo che sorveglia.
*/
public function pdfGruppo(Territorio $territorio): Response
{
$gruppo = auth()->user()->gruppoSorvegliato();
abort_unless($gruppo && $territorio->gruppo_servizio_id === $gruppo->id, 403);
return $this->pdf($territorio);
}
public function miniatura(Territorio $territorio): Response public function miniatura(Territorio $territorio): Response
{ {
return $this->servi($territorio->thumbnail_path, null, null); return $this->servi($territorio->thumbnail_path, null, null);

View File

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

View File

@@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\GruppoServizio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
@@ -75,7 +76,7 @@ class ResetApplication implements ShouldQueue
$stato->avanza($this->operationId, 1); $stato->avanza($this->operationId, 1);
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.'); $stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10); $stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 11);
DB::transaction(function () use ($stato) { DB::transaction(function () use ($stato) {
$passi = [ $passi = [
@@ -87,6 +88,7 @@ class ResetApplication implements ShouldQueue
'Piantine' => fn () => TerritorioMappa::query()->delete(), 'Piantine' => fn () => TerritorioMappa::query()->delete(),
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(), 'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(), 'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
'Gruppi di servizio' => fn () => GruppoServizio::query()->delete(),
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(), 'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
'Tipologie' => fn () => Tipologia::query()->delete(), 'Tipologie' => fn () => Tipologia::query()->delete(),
'Zone' => fn () => Zona::query()->delete(), 'Zone' => fn () => Zona::query()->delete(),

View File

@@ -2,8 +2,9 @@
namespace App\Livewire\Assegnazioni; namespace App\Livewire\Assegnazioni;
use App\Livewire\Concerns\InviaLink;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use Livewire\Attributes\Computed;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
@@ -12,14 +13,12 @@ use Illuminate\Support\Facades\DB;
class Assegna extends Component class Assegna extends Component
{ {
/** Chiave di sessione delle assegnazioni appena fatte, in attesa della scelta sul link. */ use RicordaRitorno;
public const DA_INVIARE = 'assegnazioni_da_inviare'; use InviaLink;
public ?int $territorio_id = null; public ?int $territorio_id = null;
public ?int $proclamatore_id = null; public ?int $proclamatore_id = null;
public string $assigned_at = ''; public string $assigned_at = '';
public string $territorioSearch = '';
public string $linkSearch = '';
// Optional pre-selection from parent context // Optional pre-selection from parent context
public ?int $preselectedTerritorioId = null; public ?int $preselectedTerritorioId = null;
@@ -29,6 +28,12 @@ class Assegna extends Component
$this->preselectedTerritorioId = $territorioId; $this->preselectedTerritorioId = $territorioId;
$this->territorio_id = $territorioId; $this->territorio_id = $territorioId;
$this->assigned_at = now()->format('Y-m-d'); $this->assigned_at = now()->format('Y-m-d');
// ?invia=<id>: riprende l'invio di un link rimasto in sospeso (dalla
// scheda del territorio).
if ($invia = request()->integer('invia')) {
$this->apriInvio($invia);
}
} }
protected function rules(): array protected function rules(): array
@@ -59,6 +64,12 @@ class Assegna extends Component
return; return;
} }
// Nel pacchetto di un gruppo di servizio: lo assegna il sorvegliante.
if ($territorio->gruppo_servizio_id) {
$this->addError('territorio_id', "Il territorio {$territorio->numero} è nel pacchetto del gruppo {$territorio->gruppo?->nome}: lo assegna il sorvegliante, oppure va prima tolto dal pacchetto.");
return;
}
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id); $proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
if (!$proclamatore->attivo) { if (!$proclamatore->attivo) {
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo."); $this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
@@ -88,6 +99,8 @@ class Assegna extends Component
'anno_teocratico_id' => $annoTeocratico->id, 'anno_teocratico_id' => $annoTeocratico->id,
'assigned_at' => $assignedDate, 'assigned_at' => $assignedDate,
'created_by' => auth()->id(), 'created_by' => auth()->id(),
// C'è un link da mandare solo se il territorio ha il PDF.
'link_da_inviare' => (bool) $territorio->pdf_path,
]); ]);
}); });
@@ -110,112 +123,52 @@ class Assegna extends Component
]) ])
->log('assigned'); ->log('assigned');
// Si resta qui: in cima compare il riquadro con il link da mandare al // Non si va avanti: si apre l'invio del link (vedi in cima).
// proclamatore (WhatsApp) e la scelta "inviato / non ancora". La lista $this->apriInvio($assegnazione->id);
// sta in sessione, così il riquadro resta finché non si sceglie, anche
// ricaricando la pagina o assegnando altri territori nel frattempo.
$daInviare = session(self::DA_INVIARE, []);
$daInviare[] = $assegnazione->id;
session([self::DA_INVIARE => array_values(array_unique($daInviare))]);
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}.");
return $this->redirect(route('assegnazioni.assegna'), navigate: true);
} }
/** La scelta nel riquadro: il link è stato mandato, oppure non ancora. */ // ─── Invio del link (trait InviaLink) ──────────────────────
public function esitoInvio(int $assegnazioneId, bool $inviato): void
protected function puoInviare(Assegnazione $assegnazione): bool
{ {
$this->authorize('territori.assign'); return auth()->user()->can('territori.assign');
$assegnazione = Assegnazione::find($assegnazioneId);
$assegnazione?->forceFill(['link_sent' => $inviato])->saveQuietly();
session([self::DA_INVIARE => array_values(array_diff(session(self::DA_INVIARE, []), [$assegnazioneId]))]);
} }
public function toggleLinkSent(int $assegnazioneId): void protected function paginaDopoInvio(): string
{ {
$this->authorize('territori.assign'); return $this->destinazione(route('assegnazioni.assegna'));
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly();
}
#[Computed]
public function selectedThumbnailUrl(): ?string
{
if (!$this->territorio_id) {
return null;
}
$t = Territorio::find($this->territorio_id);
return $t?->miniaturaUrl();
} }
public function render() public function render()
{ {
$territoriQuery = Territorio::where('attivo', true) // Le opzioni dei campi di scelta con ricerca (<x-scelta>). Ci sono anche
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte()) // i territori già assegnati, che non si possono scegliere: cercandone
->with(['zona', 'tipologia']); // uno si legge a chi è, invece di un "nessun risultato". La miniatura
// serve all'anteprima, che si aggiorna nel browser.
if (trim($this->territorioSearch) !== '') { // Quelli nel pacchetto di un gruppo di servizio li assegna il
$term = trim($this->territorioSearch); // sorvegliante: qui si trovano ma non si scelgono.
$territoriQuery->where(function ($q) use ($term) { $territori = Territorio::attivi()
$q->where('numero', 'like', "%{$term}%") ->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'gruppo'])
->orWhereHas('zona', fn($z) => $z->where('nome', 'like', "%{$term}%"))
->orWhereHas('tipologia', fn($t) => $t->where('nome', 'like', "%{$term}%"));
});
}
$territoriDisponibili = $territoriQuery
->orderBy('numero')
->get();
$proclamatoriAttivi = Proclamatore::attivi()
->get() ->get()
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome)); ->sortBy('numero', SORT_NATURAL);
// All currently assigned territories with links $opzioniTerritori = $territori
$assegnazioniAperte = Assegnazione::aperte() ->map(fn (Territorio $t) => $t->opzioneScelta(match (true) {
->with(['territorio.zona', 'proclamatore']) (bool) $t->assegnazioneCorrente => 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—'),
->get() (bool) $t->gruppo_servizio_id => 'nel pacchetto del gruppo ' . ($t->gruppo?->nome ?? ''),
->sortBy(fn($a) => (int) $a->territorio?->numero); default => null,
}) + array_filter(['miniatura' => $t->miniaturaUrl()]))
->values()
->all();
// Ricerca rapida nella lista dei link: numero territorio, zona, $opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
// proclamatore in un solo campo. In memoria perché nome/cognome del
// proclamatore sono cifrati e non filtrabili via SQL.
$assegnazioniAperteVisibili = $assegnazioniAperte;
if (trim($this->linkSearch) !== '') {
$term = mb_strtolower(trim($this->linkSearch));
$assegnazioniAperteVisibili = $assegnazioniAperte->filter(function ($a) use ($term) {
$haystack = mb_strtolower(
($a->territorio?->numero ?? '') . ' ' .
($a->territorio?->zona?->nome ?? '') . ' ' .
($a->proclamatore?->nome_completo ?? '')
);
return str_contains($haystack, $term);
})->values();
}
// Il link scade alla soglia rientro (giorni_per_smarrito).
$sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
// I riquadri in evidenza: solo le assegnazioni ancora aperte (una
// rientrata o cancellata nel frattempo non ha più un link da mandare).
$idDaInviare = session(self::DA_INVIARE, []);
$daInviare = $idDaInviare === [] ? collect() : Assegnazione::aperte()
->whereIn('id', $idDaInviare)
->with(['territorio', 'proclamatore'])
->get();
if ($daInviare->count() !== count($idDaInviare)) {
session([self::DA_INVIARE => $daInviare->pluck('id')->all()]);
}
// L'elenco dei territori assegnati sta in ElencoAssegnati.
return view('livewire.assegnazioni.assegna', [ return view('livewire.assegnazioni.assegna', [
'daInviare' => $daInviare, 'invio' => $this->invioCorrente(),
'territoriDisponibili' => $territoriDisponibili, 'opzioniTerritori' => $opzioniTerritori,
'proclamatoriAttivi' => $proclamatoriAttivi, 'opzioniProclamatori' => $opzioniProclamatori,
'assegnazioniAperte' => $assegnazioniAperte, 'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili,
'sogliaRientro' => $sogliaRientro,
]); ]);
} }
} }

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Livewire\Assegnazioni;
use App\Models\Assegnazione;
use App\Models\Setting;
use Illuminate\Support\Str;
use Livewire\Attributes\Lazy;
use Livewire\Component;
/**
* L'elenco dei territori assegnati della pagina Assegna, separato dal form.
*
* Prima stava nello stesso componente: con ~80 righe la pagina pesava quasi
* 400 KB e Livewire li rimandava tutti a ogni interazione (scegliere un
* territorio, scrivere nella ricerca, aprire l'invio del link). Ora:
* - è un componente a sé, caricato subito dopo la pagina (#[Lazy]): il form
* compare subito e le sue azioni non ridisegnano l'elenco;
* - la ricerca filtra le righe nel browser (data-cerca), senza richieste;
* - le icone sono simboli SVG definiti una volta e richiamati con <use>.
*/
#[Lazy]
class ElencoAssegnati extends Component
{
public function toggleLinkSent(int $assegnazioneId): void
{
$this->authorize('territori.assign');
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
$assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale);
}
/** "Condividi" dall'elenco andato a buon fine: il link risulta inviato. */
public function segnaCondiviso(int $assegnazioneId): void
{
$this->authorize('territori.assign');
Assegnazione::aperte()->find($assegnazioneId)?->registraInvioLink(true, 'condividi');
}
public function placeholder()
{
return view('livewire.assegnazioni.elenco-assegnati-attesa');
}
public function render()
{
$assegnazioni = Assegnazione::aperte()
->with(['territorio.zona', 'proclamatore'])
->get()
->sortBy(fn ($a) => (string) $a->territorio?->numero, SORT_NATURAL)
->values();
// Il testo su cui filtra la ricerca nel browser: numero, zona e nome
// (decifrato qui: in SQL non si potrebbe cercare), senza accenti.
$cerca = $assegnazioni->mapWithKeys(fn ($a) => [$a->id => Str::lower(Str::ascii(implode(' ', array_filter([
$a->territorio?->numero,
$a->territorio?->zona?->nome,
$a->proclamatore?->nome_completo,
]))))]);
return view('livewire.assegnazioni.elenco-assegnati', [
'assegnazioni' => $assegnazioni,
'cerca' => $cerca,
// Il link scade alla soglia rientro (giorni_per_smarrito).
'sogliaRientro' => max(1, (int) Setting::getValue('giorni_per_smarrito', 120)),
]);
}
}

View File

@@ -3,12 +3,15 @@
namespace App\Livewire\Assegnazioni; namespace App\Livewire\Assegnazioni;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
class Rientra extends Component class Rientra extends Component
{ {
use RicordaRitorno;
public Assegnazione $assegnazione; public Assegnazione $assegnazione;
public string $returned_at = ''; public string $returned_at = '';
public bool $showCampaignPrompt = false; public bool $showCampaignPrompt = false;
@@ -24,6 +27,7 @@ class Rientra extends Component
public function mount(Assegnazione $assegnazione) public function mount(Assegnazione $assegnazione)
{ {
$this->autorizza($assegnazione);
$this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']); $this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']);
if ($assegnazione->returned_at) { if ($assegnazione->returned_at) {
@@ -66,8 +70,28 @@ class Rientra extends Component
]; ];
} }
/**
* Chi ha territori.return registra qualsiasi rientro. Il sorvegliante di
* gruppo (gruppo.gestisci) solo quelli del suo gruppo: assegnazioni nate
* dal gruppo o territori del suo pacchetto.
*/
private function autorizza(Assegnazione $assegnazione): void
{
$utente = auth()->user();
if ($utente->can('territori.return')) {
return;
}
$gruppo = $utente->can('gruppo.gestisci') ? $utente->gruppoSorvegliato() : null;
abort_unless(
$gruppo && ($assegnazione->gruppo_servizio_id === $gruppo->id || $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id),
403
);
}
public function save() public function save()
{ {
$this->autorizza($this->assegnazione);
$this->validate(); $this->validate();
// mount() rejects an already-returned assignment, but the component // mount() rejects an already-returned assignment, but the component
@@ -98,16 +122,48 @@ class Rientra extends Component
]) ])
->log('returned'); ->log('returned');
// The operator answered the campaign prompt: record the choice
// (counted or not) as its own audit entry.
if ($this->showCampaignPrompt && $this->campagna_id) {
$this->assegnazione->logCampagnaPercorso(
Campagna::find($this->campagna_id),
$this->counted_in_campaign,
'Rientro'
);
}
session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni."); session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni.");
return $this->redirect(route('territori.show', $territorio), navigate: true); return $this->redirect($this->destinazione($this->paginaPredefinita()), navigate: true);
}
/**
* Dove si torna senza una pagina di partenza: la scheda del territorio,
* o "Il mio gruppo" per il sorvegliante, che la scheda non la vede.
*/
public function paginaPredefinita(): string
{
return auth()->user()->can('territori.assign')
? route('territori.show', $this->assegnazione->territorio_id)
: route('mio-gruppo');
} }
public function render() public function render()
{ {
$campagna = $this->campagna_id ? Campagna::find($this->campagna_id) : null; $campagna = $this->campagna_id ? Campagna::find($this->campagna_id) : null;
// Warning shown inside the campaign prompt only: holding time up to
// the selected return date above the "da rientrare" threshold.
$giorniPercorrenza = null;
$sogliaPercorrenza = null;
if ($campagna && $this->returned_at) {
$giorniPercorrenza = $this->assegnazione->giorniPercorrenza(\Carbon\Carbon::parse($this->returned_at));
$sogliaPercorrenza = Assegnazione::sogliaPercorrenzaElevata();
}
return view('livewire.assegnazioni.rientra', [ return view('livewire.assegnazioni.rientra', [
'campagna' => $campagna, 'campagna' => $campagna,
'giorniPercorrenza' => $giorniPercorrenza,
'sogliaPercorrenza' => $sogliaPercorrenza,
]); ]);
} }
} }

View File

@@ -2,11 +2,14 @@
namespace App\Livewire\Campagne; namespace App\Livewire\Campagne;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Campagna; use App\Models\Campagna;
class CampagnaCreate extends Component class CampagnaCreate extends Component
{ {
use RicordaRitorno;
public string $descrizione = ''; public string $descrizione = '';
public string $start_date = ''; public string $start_date = '';
public string $end_date = ''; public string $end_date = '';
@@ -34,7 +37,7 @@ class CampagnaCreate extends Component
]); ]);
session()->flash('success', "Campagna '{$campagna->descrizione}' creata."); session()->flash('success', "Campagna '{$campagna->descrizione}' creata.");
return $this->redirect(route('campagne.index'), navigate: true); return $this->redirect($this->destinazione(route('campagne.index')), navigate: true);
} }
public function render() public function render()

View File

@@ -2,11 +2,14 @@
namespace App\Livewire\Campagne; namespace App\Livewire\Campagne;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Campagna; use App\Models\Campagna;
class CampagnaEdit extends Component class CampagnaEdit extends Component
{ {
use RicordaRitorno;
public Campagna $campagna; public Campagna $campagna;
public string $descrizione = ''; public string $descrizione = '';
public string $start_date = ''; public string $start_date = '';
@@ -47,7 +50,7 @@ class CampagnaEdit extends Component
]); ]);
session()->flash('success', "Campagna aggiornata."); session()->flash('success', "Campagna aggiornata.");
return $this->redirect(route('campagne.index'), navigate: true); return $this->redirect($this->destinazione(route('campagne.index')), navigate: true);
} }
public function render() public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Campagne; namespace App\Livewire\Campagne;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\Assegnazione; use App\Models\Assegnazione;
@@ -9,6 +10,8 @@ use App\Models\Territorio;
class CampagnaShow extends Component class CampagnaShow extends Component
{ {
use RicordaRitorno;
public Campagna $campagna; public Campagna $campagna;
public function mount(Campagna $campagna) public function mount(Campagna $campagna)
@@ -28,6 +31,18 @@ class CampagnaShow extends Component
->orderBy('returned_at') ->orderBy('returned_at')
->get(); ->get();
// Open assignments can still be counted for this campaign only while
// no later campaign has started: on return the most recent one is
// offered (see Assegnazione::campagnaApplicabile()).
$superata = Campagna::where('start_date', '>', $this->campagna->start_date)
->where('start_date', '<=', now()->toDateString())
->exists();
$inCorso = $superata ? null : $this->campagna->assegnazioniInCorso()
->with(['territorio', 'proclamatore'])
->orderBy('assigned_at')
->get();
// All assignments that were active during this campaign range // All assignments that were active during this campaign range
$assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date) $assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date)
->where(function ($q) { ->where(function ($q) {
@@ -38,6 +53,7 @@ class CampagnaShow extends Component
return view('livewire.campagne.campagna-show', [ return view('livewire.campagne.campagna-show', [
'conteggiate' => $conteggiate, 'conteggiate' => $conteggiate,
'inCorso' => $inCorso,
'assegnateNelRange' => $assegnateNelRange, 'assegnateNelRange' => $assegnateNelRange,
]); ]);
} }

View File

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

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Livewire\Concerns;
use App\Support\Ritorno;
use Livewire\Attributes\Locked;
/**
* Per le pagine che si aprono da un'altra (schede e operazioni): ricorda da
* dove si è partiti (?da=…, vedi App\Support\Ritorno) e ci torna dopo la
* conferma. Annulla e "Torna…" nella vista usano $ritorno allo stesso modo.
*
* Aperta senza `da` (menu laterale, link salvato) la pagina si comporta come
* prima: ogni componente passa la sua destinazione predefinita.
*/
trait RicordaRitorno
{
/** URL della pagina di partenza; null se aperta direttamente. */
#[Locked]
public ?string $ritorno = null;
/** Hook di Livewire: gira al mount di ogni componente che usa il trait. */
public function mountRicordaRitorno(): void
{
$this->ritorno = Ritorno::daRichiesta();
}
/** Dove andare dopo la conferma: la pagina di partenza, o quella predefinita. */
protected function destinazione(string $predefinita): string
{
return $this->ritorno ?? $predefinita;
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Livewire\Gruppi;
use App\Livewire\Concerns\Avvisa;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Services\GruppiServizio;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Gruppi di servizio (solo amministratore): elenco e creazione. Creando un
* gruppo si sceglie il sorvegliante, che riceve un'utenza con l'email
* indicata e una password temporanea, mostrata qui una volta sola.
* Membri e pacchetto si gestiscono nella pagina del gruppo (GruppoShow).
*/
class GruppiIndex extends Component
{
use Avvisa;
public string $nome = '';
public ?int $sorveglianteId = null;
public string $email = '';
/** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */
#[Locked]
public ?int $creatoId = null;
#[Locked]
public ?string $passwordTemporanea = null;
public function crea(): void
{
$this->authorize('settings.manage');
$this->validate([
'nome' => 'required|string|max:100',
'sorveglianteId' => 'required|integer|exists:proclamatori,id',
'email' => 'required|email|max:255',
], [
'sorveglianteId.required' => 'Scegli il sorvegliante.',
'email.required' => "Serve l'email con cui il sorvegliante entrerà.",
]);
try {
[$gruppo, $password] = app(GruppiServizio::class)->crea(
$this->nome,
Proclamatore::findOrFail($this->sorveglianteId),
trim($this->email),
);
} catch (InvalidArgumentException $e) {
$this->addError('sorveglianteId', $e->getMessage());
return;
}
$this->reset(['nome', 'sorveglianteId', 'email']);
$this->creatoId = $gruppo->id;
$this->passwordTemporanea = $password;
}
public function chiudiPassword(): void
{
$this->reset(['creatoId', 'passwordTemporanea']);
}
public function render()
{
$gruppi = GruppoServizio::with('sorvegliante')
->withCount(['membri', 'territori'])
->orderByRaw('sciolto_at IS NOT NULL')
->orderBy('nome')
->get();
// Possono diventare sorveglianti i proclamatori attivi senza gruppo e
// senza un'utenza attiva (quella di un gruppo sciolto si riattiva).
$candidati = Proclamatore::attivi()
->whereNull('gruppo_servizio_id')
->whereDoesntHave('utente', fn ($q) => $q->whereNull('disattivato_at'))
->get();
return view('livewire.gruppi.gruppi-index', [
'gruppi' => $gruppi,
'opzioniSorveglianti' => Proclamatore::opzioniScelta($candidati),
'creato' => $this->creatoId ? GruppoServizio::with('sorvegliante.utente')->find($this->creatoId) : null,
]);
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Livewire\Gruppi;
use App\Livewire\Concerns\Avvisa;
use App\Livewire\Concerns\RicordaRitorno;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use App\Services\GruppiServizio;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Un gruppo di servizio, lato amministratore: membri (aggiungere/togliere),
* pacchetto (aggiungere in blocco, rimandare in reparto quelli non dati),
* nuova password temporanea per il sorvegliante, scioglimento.
*/
class GruppoShow extends Component
{
use Avvisa;
use RicordaRitorno;
public GruppoServizio $gruppo;
/** @var int[] proclamatori da aggiungere */
public array $nuoviMembri = [];
/** @var int[] territori da aggiungere al pacchetto */
public array $nuoviTerritori = [];
#[Locked]
public ?string $passwordTemporanea = null;
public function mount(GruppoServizio $gruppo): void
{
$this->gruppo = $gruppo;
}
private function servizio(): GruppiServizio
{
$this->authorize('settings.manage');
return app(GruppiServizio::class);
}
private function esegui(callable $azione, string $ok): void
{
try {
$azione();
} catch (InvalidArgumentException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->gruppo->refresh();
$this->avvisa($ok);
}
public function aggiungiMembri(): void
{
$ids = array_map('intval', $this->nuoviMembri);
if ($ids === []) {
$this->avvisaErrore('Spunta almeno un proclamatore.');
return;
}
$this->esegui(fn () => $this->servizio()->aggiungiMembri($this->gruppo, $ids), count($ids) . ' membri aggiunti.');
$this->nuoviMembri = [];
}
public function togliMembro(int $proclamatoreId): void
{
$membro = Proclamatore::findOrFail($proclamatoreId);
$this->esegui(fn () => $this->servizio()->togliMembro($this->gruppo, $membro), "{$membro->nome_completo} tolto dal gruppo.");
}
public function aggiungiTerritori(): void
{
$ids = array_map('intval', $this->nuoviTerritori);
if ($ids === []) {
$this->avvisaErrore('Spunta almeno un territorio.');
return;
}
$this->esegui(fn () => $this->servizio()->aggiungiAlPacchetto($this->gruppo, $ids), count($ids) . ' territori aggiunti al pacchetto.');
$this->nuoviTerritori = [];
}
public function rimanda(int $territorioId): void
{
$territorio = Territorio::findOrFail($territorioId);
$this->esegui(fn () => $this->servizio()->rimandaInReparto($this->gruppo, $territorio), "Territorio {$territorio->numero} rimandato in reparto.");
}
/** Il sorvegliante ha perso la password: una nuova temporanea, da cambiare al primo accesso. */
public function nuovaPassword(): void
{
$this->servizio();
$utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first();
if (! $utente || $this->gruppo->sciolto_at) {
$this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
return;
}
$password = Str::password(12, symbols: false);
$utente->forceFill(['password' => $password, 'password_change_required' => true])->save();
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
$this->passwordTemporanea = $password;
}
/** Toglie il ruolo di sorvegliante: il gruppo si scioglie. */
public function sciogli(): void
{
$this->esegui(fn () => $this->servizio()->sciogli($this->gruppo), "Gruppo {$this->gruppo->nome} sciolto.");
$this->passwordTemporanea = null;
}
public function render()
{
$this->gruppo->load(['sorvegliante.utente']);
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values();
$territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
$attivo = ! $this->gruppo->sciolto_at;
return view('livewire.gruppi.gruppo-show', [
'attivo' => $attivo,
'membri' => $membri,
'territori' => $territori,
// Si possono aggiungere i proclamatori attivi senza gruppo...
'candidatiMembri' => $attivo
? Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
: collect(),
// ...e i territori in reparto (attivi, non assegnati, fuori da ogni pacchetto).
'candidatiTerritori' => $attivo
? Territorio::inReparto()->with('zona')->get()->sortBy('numero', SORT_NATURAL)->values()
: collect(),
'assegnazioniDelGruppo' => $this->gruppo->assegnazioni()->count(),
]);
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Livewire\Gruppi;
use App\Livewire\Concerns\Avvisa;
use App\Livewire\Concerns\InviaLink;
use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Services\GruppiServizio;
use Carbon\Carbon;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* "Il mio gruppo": la sola pagina del sorvegliante di gruppo. Vede il
* pacchetto del suo gruppo e i membri; dà i territori ai membri (o a sé),
* manda il link, registra i rientri (pagina Rientra), apre la piantina e
* rimanda in reparto quelli non ancora dati. Le regole stanno in
* App\Services\GruppiServizio; qui ogni azione ricontrolla che il territorio
* o l'assegnazione siano del suo gruppo.
*/
class MioGruppo extends Component
{
use Avvisa;
use InviaLink;
/** Il territorio del pacchetto che si sta assegnando. */
#[Locked]
public ?int $territorioDaAssegnare = null;
public ?int $membroId = null;
public string $dataAssegnazione = '';
public function mount(): void
{
$this->dataAssegnazione = now()->format('Y-m-d');
}
private function gruppo(): ?GruppoServizio
{
return auth()->user()->gruppoSorvegliato();
}
private function gruppoOFallisci(): GruppoServizio
{
$gruppo = $this->gruppo();
abort_unless($gruppo, 403, 'Non sei il sorvegliante di un gruppo di servizio attivo.');
return $gruppo;
}
private function territorioDelPacchetto(int $id): Territorio
{
$gruppo = $this->gruppoOFallisci();
$territorio = Territorio::find($id);
abort_unless($territorio && $territorio->gruppo_servizio_id === $gruppo->id, 403);
return $territorio;
}
// ─── Assegnare un territorio del pacchetto ──────────────────
public function apriAssegnazione(int $territorioId): void
{
$this->territorioDelPacchetto($territorioId);
$this->territorioDaAssegnare = $territorioId;
$this->membroId = null;
$this->dataAssegnazione = now()->format('Y-m-d');
$this->resetValidation();
}
public function annullaAssegnazione(): void
{
$this->reset(['territorioDaAssegnare', 'membroId']);
$this->resetValidation();
}
public function assegna(): void
{
$this->validate([
'membroId' => 'required|integer',
'dataAssegnazione' => 'required|date|before_or_equal:today',
], [
'membroId.required' => 'Scegli a chi dare il territorio.',
'dataAssegnazione.before_or_equal' => 'La data non può essere nel futuro.',
]);
$gruppo = $this->gruppoOFallisci();
$territorio = $this->territorioDelPacchetto((int) $this->territorioDaAssegnare);
$membro = Proclamatore::find($this->membroId);
if (! $membro) {
$this->addError('membroId', 'Scegli un membro del gruppo.');
return;
}
try {
$assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione));
} catch (InvalidArgumentException $e) {
$this->addError('membroId', $e->getMessage());
return;
}
$this->reset(['territorioDaAssegnare', 'membroId']);
// Come dalla pagina Assegna: si apre l'invio del link.
$this->apriInvio($assegnazione->id);
}
// ─── Rimandare in reparto ───────────────────────────────────
public function rimanda(int $territorioId): void
{
$territorio = $this->territorioDelPacchetto($territorioId);
try {
app(GruppiServizio::class)->rimandaInReparto($this->gruppoOFallisci(), $territorio);
} catch (InvalidArgumentException $e) {
$this->avvisaErrore($e->getMessage());
return;
}
$this->avvisa("Territorio {$territorio->numero} rimandato in reparto.");
}
// ─── Link già inviato / condiviso dall'elenco ───────────────
private function assegnazioneDelGruppo(int $id): Assegnazione
{
$assegnazione = Assegnazione::aperte()->with('territorio')->find($id);
abort_unless($assegnazione && $this->puoInviare($assegnazione), 403);
return $assegnazione;
}
public function toggleLinkSent(int $assegnazioneId): void
{
$assegnazione = $this->assegnazioneDelGruppo($assegnazioneId);
$assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale);
}
/** "Condividi" dall'elenco andato a buon fine (elencoCercabile.condividi). */
public function segnaCondiviso(int $assegnazioneId): void
{
$this->assegnazioneDelGruppo($assegnazioneId)->registraInvioLink(true, 'condividi');
}
// ─── Invio del link (trait InviaLink) ───────────────────────
protected function puoInviare(Assegnazione $assegnazione): bool
{
$gruppo = $this->gruppo();
return $gruppo !== null
&& ($assegnazione->gruppo_servizio_id === $gruppo->id
|| $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id);
}
protected function paginaDopoInvio(): string
{
return route('mio-gruppo');
}
public function render()
{
$gruppo = $this->gruppo();
$territori = $gruppo
? $gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values()
: collect();
$membri = $gruppo
? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
: collect();
return view('livewire.gruppi.mio-gruppo', [
'gruppo' => $gruppo,
'territori' => $territori,
'membri' => $membri,
'opzioniMembri' => Proclamatore::opzioniScelta($membri),
'invio' => $this->invioCorrente(),
]);
}
}

View File

@@ -66,10 +66,20 @@ class PiantineEditor extends Component
public string $nuovaTipologia = ''; public string $nuovaTipologia = '';
/**
* Arrivati da "Nuovo territorio" (?nuovo=1): la mappa parte già in
* disegno, e la prima piantina disegnata si apre con "nuovo territorio"
* scelto, pronta da compilare e creare.
*/
#[Locked]
public bool $creaTerritorio = false;
public function mount(?int $mappa = null): void public function mount(?int $mappa = null): void
{ {
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) { if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
$this->carica(TerritorioMappa::with('territorio')->find($mappa)); $this->carica(TerritorioMappa::with('territorio')->find($mappa));
} elseif (request()->boolean('nuovo')) {
$this->creaTerritorio = true;
} }
} }
@@ -154,6 +164,11 @@ class PiantineEditor extends Component
$mappa = TerritorioMappa::create(['geojson' => $geojson]); $mappa = TerritorioMappa::create(['geojson' => $geojson]);
$this->carica($mappa); $this->carica($mappa);
activity()->causedBy(auth()->user())->log('piantina_creata'); activity()->causedBy(auth()->user())->log('piantina_creata');
if ($this->creaTerritorio) {
$this->creaTerritorio = false;
$this->territorioScelto = 'nuovo';
}
} }
$this->salvato($mappa, seleziona: true); $this->salvato($mappa, seleziona: true);
@@ -562,18 +577,33 @@ class PiantineEditor extends Component
->pluck('territorio_id'); ->pluck('territorio_id');
$territori = Territorio::attivi() $territori = Territorio::attivi()
->with(['zona:id,nome', 'tipologia:id,nome'])
->whereNotIn('id', $occupati) ->whereNotIn('id', $occupati)
->orderByRaw('LENGTH(numero), numero') ->orderByRaw('LENGTH(numero), numero')
->get(['id', 'numero', 'attivo', 'deleted_at']); ->get(['id', 'numero', 'zona_id', 'tipologia_id', 'attivo', 'deleted_at']);
if ($mappa?->territorio && ! $territori->contains('id', $mappa->territorio_id)) { if ($mappa?->territorio && ! $territori->contains('id', $mappa->territorio_id)) {
$territori->prepend($mappa->territorio); $territori->prepend($mappa->territorio);
} }
// Il campo di scelta con ricerca (<x-scelta>): due voci fisse in cima,
// poi i territori, che qui restano sceglibili anche se cestinati o
// disattivati (è il collegamento che già c'era).
$opzioniTerritori = [
['valore' => '', 'etichetta' => '— nessun territorio —', 'speciale' => true],
['valore' => 'nuovo', 'etichetta' => '+ Crea un territorio nuovo', 'speciale' => true],
...$territori->map(function (Territorio $t) {
$opzione = $t->opzioneScelta(nota: $t->trashed() ? 'nel cestino' : (! $t->attivo ? 'disattivato' : null));
$opzione['etichetta'] = str_replace('N° ', 'Territorio ', $opzione['etichetta']);
return $opzione;
})->all(),
];
return view('livewire.piantine.piantine-editor', [ return view('livewire.piantine.piantine-editor', [
'mappa' => $mappa, 'mappa' => $mappa,
'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null, 'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
'territori' => $territori, 'opzioniTerritori' => $opzioniTerritori,
'zone' => Zona::orderBy('nome')->get(['id', 'nome']), 'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']), 'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine', 'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine',

View File

@@ -2,18 +2,85 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component; use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User;
use App\Services\ConfigurazionePosta;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role;
/**
* Informativa privacy (solo amministratore).
*
* Tutto ciò che può cambiare si legge qui, al momento: impostazioni
* (congregazione, conservazione del log, scadenza dei link, download del
* PDF, server di posta), configurazione (indirizzo, cifratura, durata della
* sessione), ruoli con i loro permessi reali e quanti dati sono conservati.
* Il testo resta nella vista; i numeri non vanno mai scritti a mano.
*/
class Privacy extends Component class Privacy extends Component
{ {
/** Data dell'ultima revisione del testo dell'informativa. */
public const REVISIONE = '19 ottobre 2026';
/** I permessi spiegati a chi legge l'informativa. */
public const PERMESSI = [
'settings.manage' => 'impostazioni, utenti, backup e import',
'proclamatori.manage' => 'anagrafica dei proclamatori',
'gruppo.gestisci' => 'solo il proprio gruppo di servizio: i territori del pacchetto e i membri del gruppo',
'territori.manage' => 'gestione dei territori e delle piantine',
'campagne.manage' => 'campagne',
'registro.view' => 'consultazione del registro delle assegnazioni',
'registro.export' => 'esportazione del registro',
'territori.assign' => 'assegnazione dei territori',
'territori.return' => 'rientro dei territori',
'audit.view' => 'consultazione del registro delle attività',
'audit.export' => 'esportazione del registro delle attività',
];
public function render() public function render()
{ {
$settings = Setting::instance(); $impostazioni = Setting::instance();
$posta = app(ConfigurazionePosta::class);
$hostPosta = $posta->configurataNellApp()
? (string) $impostazioni->mail_host
: (string) config('mail.mailers.smtp.host');
$indirizzo = (string) config('app.url');
return view('livewire.privacy', [ return view('livewire.privacy', [
'congregazione' => $settings->congregazione_nome ?? 'Congregazione', 'revisione' => self::REVISIONE,
'auditRetention' => $settings->audit_retention_days ?? 365, 'congregazione' => $impostazioni->congregazione_nome ?: 'Congregazione',
'indirizzo' => $indirizzo,
'https' => str_starts_with($indirizzo, 'https://'),
'cifratura' => (string) config('app.cipher'),
'durataSessione' => (int) config('session.lifetime'),
'auditRetention' => (int) ($impostazioni->audit_retention_days ?: 365),
'scadenzaLink' => max(1, (int) ($impostazioni->giorni_per_smarrito ?: 120)),
'downloadPdf' => (bool) $impostazioni->pdf_viewer_show_download,
'postaAttiva' => $posta->funzionante(),
'hostPosta' => $hostPosta !== '' ? $hostPosta : null,
'ruoli' => Role::with('permissions')->orderBy('name')->get()->map(fn (Role $r) => [
'nome' => $r->name,
'permessi' => $r->permissions->pluck('name')
->map(fn ($p) => self::PERMESSI[$p] ?? $p)
->all(),
]),
// Quanti dati sono conservati adesso: solo conteggi, contati in
// SQL, senza decifrare né mostrare nomi.
'conteggi' => [
'proclamatori' => Proclamatore::count(),
'proclamatoriAttivi' => Proclamatore::where('attivo', true)->count(),
'anonimizzati' => Proclamatore::onlyTrashed()->count(),
'utenti' => User::count(),
'assegnazioni' => Assegnazione::count(),
'assegnazioniAperte' => Assegnazione::aperte()->count(),
'attivita' => Activity::count(),
'attivitaDal' => Activity::min('created_at'),
],
]); ]);
} }
} }

View File

@@ -2,16 +2,17 @@
namespace App\Livewire\Proclamatori; namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Proclamatore; use App\Models\Proclamatore;
class ProclamatoreCreate extends Component class ProclamatoreCreate extends Component
{ {
use RicordaRitorno;
public string $nome = ''; public string $nome = '';
public string $cognome = ''; public string $cognome = '';
public bool $attivo = true; public bool $attivo = true;
public string $email = '';
public string $telefono = '';
protected function rules(): array protected function rules(): array
{ {
@@ -19,17 +20,6 @@ class ProclamatoreCreate extends Component
'nome' => 'required|string|max:100', 'nome' => 'required|string|max:100',
'cognome' => 'required|string|max:100', 'cognome' => 'required|string|max:100',
'attivo' => 'boolean', 'attivo' => 'boolean',
// Facoltativi: si controlla il formato solo se compilati.
'email' => 'nullable|email|max:255',
'telefono' => ['nullable', 'string', 'max:30', 'regex:/^\+?[0-9 ().\/-]{6,25}$/'],
];
}
protected function messages(): array
{
return [
'email.email' => "L'indirizzo email non è valido (puoi anche lasciarlo vuoto).",
'telefono.regex' => 'Il numero può contenere solo cifre, spazi e un "+" iniziale (puoi anche lasciarlo vuoto).',
]; ];
} }
@@ -43,8 +33,6 @@ class ProclamatoreCreate extends Component
'nome' => $this->nome, 'nome' => $this->nome,
'cognome' => $this->cognome, 'cognome' => $this->cognome,
'attivo' => $this->attivo, 'attivo' => $this->attivo,
'email' => trim($this->email) ?: null,
'telefono' => trim($this->telefono) ?: null,
]); ]);
activity()->causedBy(auth()->user()) activity()->causedBy(auth()->user())
@@ -52,7 +40,7 @@ class ProclamatoreCreate extends Component
->log('created'); ->log('created');
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} creato."); session()->flash('success', "Proclamatore {$proclamatore->nome_completo} creato.");
return $this->redirect(route('proclamatori.index'), navigate: true); return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
} }
public function render() public function render()

View File

@@ -2,17 +2,18 @@
namespace App\Livewire\Proclamatori; namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Proclamatore; use App\Models\Proclamatore;
class ProclamatoreEdit extends Component class ProclamatoreEdit extends Component
{ {
use RicordaRitorno;
public Proclamatore $proclamatore; public Proclamatore $proclamatore;
public string $nome = ''; public string $nome = '';
public string $cognome = ''; public string $cognome = '';
public bool $attivo = true; public bool $attivo = true;
public string $email = '';
public string $telefono = '';
public function mount(Proclamatore $proclamatore) public function mount(Proclamatore $proclamatore)
{ {
@@ -20,8 +21,6 @@ class ProclamatoreEdit extends Component
$this->nome = $proclamatore->nome; $this->nome = $proclamatore->nome;
$this->cognome = $proclamatore->cognome; $this->cognome = $proclamatore->cognome;
$this->attivo = $proclamatore->attivo; $this->attivo = $proclamatore->attivo;
$this->email = (string) $proclamatore->email;
$this->telefono = (string) $proclamatore->telefono;
} }
protected function rules(): array protected function rules(): array
@@ -30,17 +29,6 @@ class ProclamatoreEdit extends Component
'nome' => 'required|string|max:100', 'nome' => 'required|string|max:100',
'cognome' => 'required|string|max:100', 'cognome' => 'required|string|max:100',
'attivo' => 'boolean', 'attivo' => 'boolean',
// Facoltativi: si controlla il formato solo se compilati.
'email' => 'nullable|email|max:255',
'telefono' => ['nullable', 'string', 'max:30', 'regex:/^\+?[0-9 ().\/-]{6,25}$/'],
];
}
protected function messages(): array
{
return [
'email.email' => "L'indirizzo email non è valido (puoi anche lasciarlo vuoto).",
'telefono.regex' => 'Il numero può contenere solo cifre, spazi e un "+" iniziale (puoi anche lasciarlo vuoto).',
]; ];
} }
@@ -54,12 +42,10 @@ class ProclamatoreEdit extends Component
'nome' => $this->nome, 'nome' => $this->nome,
'cognome' => $this->cognome, 'cognome' => $this->cognome,
'attivo' => $this->attivo, 'attivo' => $this->attivo,
'email' => trim($this->email) ?: null,
'telefono' => trim($this->telefono) ?: null,
]); ]);
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato."); session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");
return $this->redirect(route('proclamatori.index'), navigate: true); return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
} }
public function render() public function render()

View File

@@ -3,8 +3,10 @@
namespace App\Livewire\Proclamatori; namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\Avvisa; use App\Livewire\Concerns\Avvisa;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\GruppoServizio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
class ProclamatoreIndex extends Component class ProclamatoreIndex extends Component
@@ -12,9 +14,14 @@ class ProclamatoreIndex extends Component
use Avvisa; use Avvisa;
use WithPagination; use WithPagination;
// Ricerca, filtro e ordinamento nell'indirizzo: si ritrovano tornando.
#[Url(except: '')]
public string $search = ''; public string $search = '';
#[Url(except: '')]
public string $filtroStato = ''; public string $filtroStato = '';
#[Url(except: 'cognome')]
public string $sortField = 'cognome'; public string $sortField = 'cognome';
#[Url(except: 'asc')]
public string $sortDirection = 'asc'; public string $sortDirection = 'asc';
protected $queryString = [ protected $queryString = [
@@ -64,7 +71,12 @@ class ProclamatoreIndex extends Component
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori."); $this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
return; return;
} }
if ($this->sorveglianteDiUnGruppo($proclamatore, 'spostarlo nel cestino')) {
return;
}
// Nel cestino non resta membro di un gruppo.
$proclamatore->update(['gruppo_servizio_id' => null]);
$proclamatore->delete(); $proclamatore->delete();
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino."); $this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
} }
@@ -74,10 +86,27 @@ class ProclamatoreIndex extends Component
$this->authorize('settings.manage'); $this->authorize('settings.manage');
$proclamatore = Proclamatore::findOrFail($id); $proclamatore = Proclamatore::findOrFail($id);
if ($this->sorveglianteDiUnGruppo($proclamatore, 'anonimizzarlo')) {
return;
}
$proclamatore->anonimizza(); $proclamatore->anonimizza();
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)."); $this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
} }
/** Il sorvegliante di un gruppo attivo si toglie sciogliendo il gruppo, non da qui. */
private function sorveglianteDiUnGruppo(Proclamatore $proclamatore, string $azione): bool
{
$gruppo = GruppoServizio::attivi()->where('sorvegliante_id', $proclamatore->id)->first();
if (! $gruppo) {
return false;
}
$this->avvisaErrore("{$proclamatore->nome_completo} è il sorvegliante del gruppo {$gruppo->nome}: per {$azione} va prima sciolto il gruppo (Gruppi di servizio).");
return true;
}
public function render() public function render()
{ {
// withCount rather than letting the view call // withCount rather than letting the view call

View File

@@ -2,12 +2,15 @@
namespace App\Livewire\Proclamatori; namespace App\Livewire\Proclamatori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
class ProclamatoreShow extends Component class ProclamatoreShow extends Component
{ {
use RicordaRitorno;
public Proclamatore $proclamatore; public Proclamatore $proclamatore;
public function mount(Proclamatore $proclamatore) public function mount(Proclamatore $proclamatore)

View File

@@ -41,7 +41,8 @@ class Profile extends Component
// L'indirizzo nuovo va confermato (User::booted lo segna da // L'indirizzo nuovo va confermato (User::booted lo segna da
// confermare): finché non apre il link, l'account resta bloccato. // confermare): finché non apre il link, l'account resta bloccato.
if ($emailCambiata) { // Il sorvegliante di gruppo non conferma l'email (User::hasVerifiedEmail).
if ($emailCambiata && ! $user->isSorvegliante()) {
$user->inviaConfermaEmail() $user->inviaConfermaEmail()
? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.") ? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.")
: session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.'); : session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.');

View File

@@ -2,6 +2,7 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Assegnazione; use App\Models\Assegnazione;
@@ -19,12 +20,20 @@ class Registro extends Component
{ {
use WithPagination; use WithPagination;
// Ricerca, filtri e ordinamento nell'indirizzo: si ritrovano tornando.
#[Url(except: '')]
public string $search = ''; public string $search = '';
#[Url(except: '')]
public string $filtroAnno = ''; public string $filtroAnno = '';
#[Url(except: '')]
public string $filtroZona = ''; public string $filtroZona = '';
#[Url(except: '')]
public string $filtroTipologia = ''; public string $filtroTipologia = '';
#[Url(except: '')]
public string $filtroStato = ''; // aperte, chiuse public string $filtroStato = ''; // aperte, chiuse
#[Url(except: 'territorio_numero')]
public string $sortField = 'territorio_numero'; public string $sortField = 'territorio_numero';
#[Url(except: 'asc')]
public string $sortDirection = 'asc'; public string $sortDirection = 'asc';
// ─── Modal create/edit ────────────────────────────────────── // ─── Modal create/edit ──────────────────────────────────────
@@ -154,7 +163,10 @@ class Registro extends Component
'note' => $this->form_note ?: null, 'note' => $this->form_note ?: null,
]; ];
$conflitto = DB::transaction(function () use ($data, $uscita, $rientro) { $assegnazione = null;
$prima = [false, null];
$conflitto = DB::transaction(function () use ($data, $uscita, $rientro, &$assegnazione, &$prima) {
Territorio::whereKey($data['territorio_id'])->lockForUpdate()->first(); Territorio::whereKey($data['territorio_id'])->lockForUpdate()->first();
$conflitto = Assegnazione::sovrappostaA((int) $data['territorio_id'], $uscita, $rientro, $this->editingId); $conflitto = Assegnazione::sovrappostaA((int) $data['territorio_id'], $uscita, $rientro, $this->editingId);
@@ -163,9 +175,11 @@ class Registro extends Component
} }
if ($this->editingId) { if ($this->editingId) {
Assegnazione::findOrFail($this->editingId)->update($data); $assegnazione = Assegnazione::findOrFail($this->editingId);
$prima = [(bool) $assegnazione->counted_in_campaign, $assegnazione->campaign_id ? (int) $assegnazione->campaign_id : null];
$assegnazione->update($data);
} else { } else {
Assegnazione::create($data + ['created_by' => auth()->id()]); $assegnazione = Assegnazione::create($data + ['created_by' => auth()->id()]);
} }
return null; return null;
@@ -177,6 +191,16 @@ class Registro extends Component
return; return;
} }
// Log the campaign choice when the admin sets or changes it here.
$dopo = [(bool) $assegnazione->counted_in_campaign, $assegnazione->campaign_id ? (int) $assegnazione->campaign_id : null];
if ($dopo !== $prima && ($dopo[0] || $prima[0])) {
$assegnazione->logCampagnaPercorso(
$assegnazione->campagna ?? ($prima[1] ? Campagna::find($prima[1]) : null),
$dopo[0],
'Registro'
);
}
$this->showModal = false; $this->showModal = false;
} }
@@ -261,7 +285,7 @@ class Registro extends Component
public function render() public function render()
{ {
$query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']); $query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna', 'gruppo']);
if ($this->filtroAnno) { if ($this->filtroAnno) {
$query->where('anno_teocratico_id', $this->filtroAnno); $query->where('anno_teocratico_id', $this->filtroAnno);
@@ -324,8 +348,14 @@ class Registro extends Component
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(), 'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
'zone' => Zona::attive()->orderBy('nome')->get(), 'zone' => Zona::attive()->orderBy('nome')->get(),
'tipologie' => Tipologia::orderBy('nome')->get(), 'tipologie' => Tipologia::orderBy('nome')->get(),
'territori' => Territorio::attivi()->orderBy('numero')->get(), // Campi di scelta con ricerca della finestra di modifica. Ci sono
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(), // anche i disattivati, non sceglibili: modificando una vecchia
// assegnazione il campo mostra comunque il nome giusto.
'opzioniTerritori' => Territorio::with(['zona', 'tipologia'])->get()
->sortBy('numero', SORT_NATURAL)
->map(fn (Territorio $t) => $t->opzioneScelta($t->attivo ? null : 'territorio disattivato'))
->values()->all(),
'opzioniProclamatori' => Proclamatore::opzioniScelta(Proclamatore::all()),
'campagne' => Campagna::orderByDesc('created_at')->get(), 'campagne' => Campagna::orderByDesc('created_at')->get(),
]); ]);
} }

View File

@@ -34,7 +34,10 @@ class UsersIndex extends Component
public function mount(): void public function mount(): void
{ {
// "sorvegliante" si dà solo creando un gruppo di servizio, con
// l'utenza collegata al proclamatore: da qui non si assegna.
$this->availableRoles = Role::query() $this->availableRoles = Role::query()
->where('name', '!=', 'sorvegliante')
->orderBy('name') ->orderBy('name')
->pluck('name') ->pluck('name')
->all(); ->all();
@@ -111,9 +114,28 @@ class UsersIndex extends Component
} }
} }
/**
* Le utenze dei sorveglianti di gruppo (attive o disattivate) si gestiscono
* da Gruppi di servizio: cambiarle o cancellarle da qui lascerebbe un
* gruppo senza sorvegliante, o un sorvegliante con un ruolo diverso.
*/
private function gestitaDaiGruppi(User $user): bool
{
if ($user->proclamatore_id === null) {
return false;
}
$this->avvisaErrore("L'utenza di {$user->name} è di un sorvegliante di gruppo: si gestisce da Gruppi di servizio.");
return true;
}
public function startEdit(int $userId): void public function startEdit(int $userId): void
{ {
$user = User::query()->with('roles')->findOrFail($userId); $user = User::query()->with('roles')->findOrFail($userId);
if ($this->gestitaDaiGruppi($user)) {
return;
}
$this->editingUserId = $user->id; $this->editingUserId = $user->id;
$this->editName = $user->name; $this->editName = $user->name;
@@ -143,6 +165,9 @@ class UsersIndex extends Component
$validated = $this->validate($this->editRules()); $validated = $this->validate($this->editRules());
$user = User::query()->findOrFail($this->editingUserId); $user = User::query()->findOrFail($this->editingUserId);
if ($this->gestitaDaiGruppi($user)) {
return;
}
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo // Togliere il ruolo di amministratore a sé stessi, o all'ultimo
// rimasto, lascia l'installazione senza nessuno che possa gestire // rimasto, lascia l'installazione senza nessuno che possa gestire
@@ -193,6 +218,9 @@ class UsersIndex extends Component
{ {
$currentUser = auth()->user(); $currentUser = auth()->user();
$user = User::query()->with('roles')->findOrFail($userId); $user = User::query()->with('roles')->findOrFail($userId);
if ($this->gestitaDaiGruppi($user)) {
return;
}
if (! $currentUser || $currentUser->id === $user->id) { if (! $currentUser || $currentUser->id === $user->id) {
$this->avvisaErrore('Non puoi cancellare il tuo utente.'); $this->avvisaErrore('Non puoi cancellare il tuo utente.');

View File

@@ -7,6 +7,7 @@ use App\Jobs\ImportTerritoryPdfFolder;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\GruppoServizio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
@@ -469,6 +470,7 @@ class XmlExchange extends Component
try { try {
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) { DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
Assegnazione::query()->delete(); Assegnazione::query()->delete();
GruppoServizio::query()->delete();
Campagna::query()->delete(); Campagna::query()->delete();
Territorio::query()->withTrashed()->forceDelete(); Territorio::query()->withTrashed()->forceDelete();
Proclamatore::query()->withTrashed()->forceDelete(); Proclamatore::query()->withTrashed()->forceDelete();
@@ -520,6 +522,7 @@ class XmlExchange extends Component
// accounts in the file are (re)created. $userMap remaps the // accounts in the file are (re)created. $userMap remaps the
// created_by/returned_by FKs on the assignments below. // created_by/returned_by FKs on the assignments below.
$userMap = []; $userMap = [];
$utentiDaCollegare = [];
foreach (($xml->users->user ?? []) as $userNode) { foreach (($xml->users->user ?? []) as $userNode) {
$email = trim((string) ($userNode->email ?? '')); $email = trim((string) ($userNode->email ?? ''));
$legacyId = (string) ($userNode['legacy_id'] ?? ''); $legacyId = (string) ($userNode['legacy_id'] ?? '');
@@ -589,6 +592,13 @@ class XmlExchange extends Component
DB::table('users')->where('id', $user->id)->update($meta); DB::table('users')->where('id', $user->id)->update($meta);
// Utenza di un sorvegliante: si collega al proclamatore dopo che
// i proclamatori sono stati importati (vedi i gruppi di servizio).
$utentiDaCollegare[$user->id] = [
'proclamatore' => (string) ($userNode->legacy_proclamatore_id ?? ''),
'disattivato_at' => $this->normalizeDateTimeForDb((string) ($userNode->disattivato_at ?? '')),
];
$stats['utenti_importati']++; $stats['utenti_importati']++;
if ($legacyId !== '') { if ($legacyId !== '') {
$userMap[$legacyId] = $user->id; $userMap[$legacyId] = $user->id;
@@ -624,25 +634,53 @@ class XmlExchange extends Component
} }
$proclamatoreMap = []; $proclamatoreMap = [];
$gruppiDeiProclamatori = [];
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) { foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
$p = Proclamatore::create([ $p = Proclamatore::create([
'nome' => (string) $proclamatoreNode->nome, 'nome' => (string) $proclamatoreNode->nome,
'cognome' => (string) $proclamatoreNode->cognome, 'cognome' => (string) $proclamatoreNode->cognome,
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1, 'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
// Facoltativi, e assenti nei file di prima dell'ottobre 2026. // <email> e <telefono>, presenti nei file del 13-19 ottobre
'email' => trim((string) ($proclamatoreNode->email ?? '')) ?: null, // 2026, si ignorano: i proclamatori non li hanno più.
'telefono' => trim((string) ($proclamatoreNode->telefono ?? '')) ?: null,
]); ]);
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true); $this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
$stats['proclamatori_importati']++; $stats['proclamatori_importati']++;
$gruppiDeiProclamatori[$p->id] = (string) ($proclamatoreNode->legacy_gruppo_id ?? '');
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? ''); $legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
if ($legacyId !== '') { if ($legacyId !== '') {
$proclamatoreMap[$legacyId] = $p->id; $proclamatoreMap[$legacyId] = $p->id;
} }
} }
$annoMap = []; // Gruppi di servizio, dopo i proclamatori: il sorvegliante è uno di
foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) { // loro. Poi i collegamenti che li aspettavano (membri, utenze).
$gruppoMap = [];
foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) {
$legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? '');
$g = GruppoServizio::create([
'nome' => (string) $gruppoNode->nome,
'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null,
'sciolto_at' => $this->normalizeDateTimeForDb((string) ($gruppoNode->sciolto_at ?? '')),
]);
$this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode);
$legacyId = (string) ($gruppoNode['legacy_id'] ?? '');
if ($legacyId !== '') {
$gruppoMap[$legacyId] = $g->id;
}
}
foreach ($gruppiDeiProclamatori as $idProclamatore => $legacyGruppo) {
if (isset($gruppoMap[$legacyGruppo])) {
DB::table('proclamatori')->where('id', $idProclamatore)->update(['gruppo_servizio_id' => $gruppoMap[$legacyGruppo]]);
}
}
foreach ($utentiDaCollegare as $idUtente => $collegamento) {
DB::table('users')->where('id', $idUtente)->update([
'proclamatore_id' => $proclamatoreMap[$collegamento['proclamatore']] ?? null,
'disattivato_at' => $collegamento['disattivato_at'],
]);
}
$annoMap = []; foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
$label = (string) $annoNode->label; $label = (string) $annoNode->label;
$anno = AnnoTeocratico::create([ $anno = AnnoTeocratico::create([
'label' => $label, 'label' => $label,
@@ -707,6 +745,7 @@ class XmlExchange extends Component
'note' => (string) ($territorioNode->note ?? ''), 'note' => (string) ($territorioNode->note ?? ''),
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1, 'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1, 'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
'gruppo_servizio_id' => $gruppoMap[(string) ($territorioNode->legacy_gruppo_id ?? '')] ?? null,
'pdf_path' => $pdfPath !== '' ? $pdfPath : null, 'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null, 'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
]); ]);
@@ -768,6 +807,9 @@ class XmlExchange extends Component
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1, 'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null, 'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1, 'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null,
'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null,
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null, 'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
'note' => (string) ($assegnazioneNode->note ?? ''), 'note' => (string) ($assegnazioneNode->note ?? ''),
'created_by' => $createdBy, 'created_by' => $createdBy,
@@ -823,16 +865,20 @@ class XmlExchange extends Component
// remember_token no: all'import si azzera comunque, e nel file // remember_token no: all'import si azzera comunque, e nel file
// sarebbe solo una credenziale in più per chi lo tiene. // sarebbe solo una credenziale in più per chi lo tiene.
'roles' => $u->roles->pluck('name')->all(), 'roles' => $u->roles->pluck('name')->all(),
// Sorvegliante di gruppo: il proclamatore collegato e se l'utenza è disattivata.
'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '',
'disattivato_at' => optional($u->disattivato_at)->toIso8601String() ?? '',
'created_at' => optional($u->created_at)->toIso8601String() ?? '', 'created_at' => optional($u->created_at)->toIso8601String() ?? '',
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '', 'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
])->all(), ])->all(),
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']), 'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']), 'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'email', 'telefono', 'attivo', 'deleted_at', 'created_at', 'updated_at']), 'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']),
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']), 'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'sciolto_at', 'created_at', 'updated_at']),
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'gruppo_servizio_id', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']), 'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']), 'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at']), 'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'gruppo_servizio_id', 'pdf_access_code', 'created_at', 'updated_at']),
]; ];
} }
@@ -1231,6 +1277,8 @@ class XmlExchange extends Component
$this->addXmlText($node, 'email', (string) ($user['email'] ?? '')); $this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
$this->addXmlText($node, 'password', (string) ($user['password'] ?? '')); $this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? '')); $node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
$node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
$node->addChild('disattivato_at', (string) ($user['disattivato_at'] ?? ''));
$node->addChild('created_at', (string) ($user['created_at'] ?? '')); $node->addChild('created_at', (string) ($user['created_at'] ?? ''));
$node->addChild('updated_at', (string) ($user['updated_at'] ?? '')); $node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
$rolesNode = $node->addChild('roles'); $rolesNode = $node->addChild('roles');
@@ -1263,9 +1311,8 @@ class XmlExchange extends Component
$this->addLegacyIdAttribute($node, $proclamatore); $this->addLegacyIdAttribute($node, $proclamatore);
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? '')); $this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? '')); $this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
$this->addXmlText($node, 'email', (string) ($proclamatore['email'] ?? ''));
$this->addXmlText($node, 'telefono', (string) ($proclamatore['telefono'] ?? ''));
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
$node->addChild('legacy_gruppo_id', (string) ($proclamatore['gruppo_servizio_id'] ?? ''));
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? '')); $node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
$this->addTimestampNodes($node, $proclamatore); $this->addTimestampNodes($node, $proclamatore);
} }
@@ -1281,6 +1328,7 @@ class XmlExchange extends Component
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? '')); $this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0))); $node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
$node->addChild('legacy_gruppo_id', (string) ($territorio['gruppo_servizio_id'] ?? ''));
$this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? '')); $this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? '')); $this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? '')); $node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
@@ -1297,6 +1345,17 @@ class XmlExchange extends Component
$this->addTimestampNodes($node, $anno); $this->addTimestampNodes($node, $anno);
} }
// Gruppi di servizio: il sorvegliante è un proclamatore (legacy id).
$gruppiNode = $xml->addChild('gruppi_servizio');
foreach (($dataset['gruppi_servizio'] ?? []) as $gruppo) {
$node = $gruppiNode->addChild('gruppo');
$this->addLegacyIdAttribute($node, $gruppo);
$this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? ''));
$node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? ''));
$node->addChild('sciolto_at', (string) ($gruppo['sciolto_at'] ?? ''));
$this->addTimestampNodes($node, $gruppo);
}
$campagneNode = $xml->addChild('campagne'); $campagneNode = $xml->addChild('campagne');
foreach ($dataset['campagne'] as $campagna) { foreach ($dataset['campagne'] as $campagna) {
$node = $campagneNode->addChild('campagna'); $node = $campagneNode->addChild('campagna');
@@ -1321,6 +1380,9 @@ class XmlExchange extends Component
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? '')); $node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0))); $node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0))); $node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? ''));
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? '')); $this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? '')); $this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
$this->addTimestampNodes($node, $assegnazione); $this->addTimestampNodes($node, $assegnazione);

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori; namespace App\Livewire\Territori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use App\Models\Territorio; use App\Models\Territorio;
@@ -11,6 +12,8 @@ use App\Services\TerritorioThumbnailService;
class TerritorioCreate extends Component class TerritorioCreate extends Component
{ {
use RicordaRitorno;
use WithFileUploads; use WithFileUploads;
public string $numero = ''; public string $numero = '';
@@ -64,7 +67,7 @@ class TerritorioCreate extends Component
->log('created'); ->log('created');
session()->flash('success', "Territorio {$territorio->numero} creato."); session()->flash('success', "Territorio {$territorio->numero} creato.");
return redirect()->route('territori.index'); return redirect()->to($this->destinazione(route('territori.index')));
} }
public function render() public function render()

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori; namespace App\Livewire\Territori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use App\Models\Territorio; use App\Models\Territorio;
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Storage;
class TerritorioEdit extends Component class TerritorioEdit extends Component
{ {
use RicordaRitorno;
use WithFileUploads; use WithFileUploads;
public Territorio $territorio; public Territorio $territorio;
@@ -82,7 +85,7 @@ class TerritorioEdit extends Component
$this->territorio->update($data); $this->territorio->update($data);
session()->flash('success', "Territorio {$this->territorio->numero} aggiornato."); session()->flash('success', "Territorio {$this->territorio->numero} aggiornato.");
return redirect()->route('territori.show', $this->territorio); return redirect()->to($this->destinazione(route('territori.show', $this->territorio)));
} }
public function removePdf() public function removePdf()

View File

@@ -4,6 +4,7 @@ namespace App\Livewire\Territori;
use App\Livewire\Concerns\Avvisa; use App\Livewire\Concerns\Avvisa;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Territorio; use App\Models\Territorio;
@@ -15,14 +16,25 @@ class TerritorioIndex extends Component
use Avvisa; use Avvisa;
use WithPagination; use WithPagination;
// Ricerca, filtri e ordinamento stanno nell'indirizzo (#[Url]): tornando
// da una scheda o da un'operazione l'elenco si ritrova com'era.
#[Url(except: '')]
public string $search = ''; public string $search = '';
#[Url(except: '')]
public string $filterZona = ''; public string $filterZona = '';
#[Url(except: '')]
public string $filterTipologia = ''; public string $filterTipologia = '';
#[Url(except: '')]
public string $filterStato = ''; public string $filterStato = '';
#[Url(except: '')]
public string $filterPriorita = ''; public string $filterPriorita = '';
#[Url(except: '')]
public string $filterPdf = ''; public string $filterPdf = '';
#[Url(except: '')]
public string $filterContenuti = ''; public string $filterContenuti = '';
#[Url(except: 'numero')]
public string $sortField = 'numero'; public string $sortField = 'numero';
#[Url(except: 'asc')]
public string $sortDirection = 'asc'; public string $sortDirection = 'asc';
protected $queryString = [ protected $queryString = [
@@ -143,6 +155,7 @@ class TerritorioIndex extends Component
'tipologia', 'tipologia',
'assegnazioneCorrente.proclamatore', 'assegnazioneCorrente.proclamatore',
'ultimaAssegnazione', 'ultimaAssegnazione',
'gruppo',
]); ]);
if ($this->search) { if ($this->search) {
@@ -167,6 +180,7 @@ class TerritorioIndex extends Component
if ($this->filterStato) { if ($this->filterStato) {
match ($this->filterStato) { match ($this->filterStato) {
'in_reparto' => $query->inReparto(), 'in_reparto' => $query->inReparto(),
'in_gruppo' => $query->inGruppo(),
'assegnato' => $query->assegnato(), 'assegnato' => $query->assegnato(),
'da_rientrare' => $query->daRientrare(), 'da_rientrare' => $query->daRientrare(),
'inattivo' => $query->where('attivo', false), 'inattivo' => $query->where('attivo', false),

View File

@@ -2,12 +2,15 @@
namespace App\Livewire\Territori; namespace App\Livewire\Territori;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Assegnazione; use App\Models\Assegnazione;
class TerritorioShow extends Component class TerritorioShow extends Component
{ {
use RicordaRitorno;
public Territorio $territorio; public Territorio $territorio;
public function mount(Territorio $territorio) public function mount(Territorio $territorio)
@@ -18,7 +21,7 @@ class TerritorioShow extends Component
public function render() public function render()
{ {
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id) $assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa']) ->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa', 'gruppo'])
->orderByDesc('assigned_at') ->orderByDesc('assigned_at')
->get() ->get()
->groupBy(fn($a) => $a->annoTeocratico->label); ->groupBy(fn($a) => $a->annoTeocratico->label);

View File

@@ -20,11 +20,20 @@ class Assegnazione extends Model
'campaign_id', 'campaign_id',
'pdf_access_code', 'pdf_access_code',
'link_sent', 'link_sent',
'link_da_inviare',
'link_canale',
'note', 'note',
'created_by', 'created_by',
'returned_by', 'returned_by',
'gruppo_servizio_id',
]; ];
/** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */
public function gruppo()
{
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
}
protected function casts(): array protected function casts(): array
{ {
return [ return [
@@ -32,6 +41,8 @@ class Assegnazione extends Model
'returned_at' => 'date', 'returned_at' => 'date',
'counted_in_campaign' => 'boolean', 'counted_in_campaign' => 'boolean',
'link_sent' => 'boolean', 'link_sent' => 'boolean',
'link_da_inviare' => 'boolean',
'gruppo_servizio_id' => 'integer',
]; ];
} }
@@ -53,6 +64,20 @@ class Assegnazione extends Model
$territorio->update(['prioritario' => false]); $territorio->update(['prioritario' => false]);
} }
}); });
// Al rientro un territorio del pacchetto di un gruppo torna in reparto
// ed esce dal pacchetto. Qui e non nella pagina Rientra: vale per ogni
// via (rientro del sorvegliante, dell'amministratore, Registro).
static::updated(function (self $assegnazione): void {
if (! $assegnazione->wasChanged('returned_at') || $assegnazione->returned_at === null) {
return;
}
Territorio::withTrashed()
->whereKey($assegnazione->territorio_id)
->whereNotNull('gruppo_servizio_id')
->update(['gruppo_servizio_id' => null]);
});
} }
/** /**
@@ -133,7 +158,8 @@ class Assegnazione extends Model
public function getGiorniAttribute(): int public function getGiorniAttribute(): int
{ {
$end = $this->returned_at ?? now(); $end = $this->returned_at ?? now();
return Carbon::parse($this->assigned_at)->diffInDays($end); // Carbon 3 restituisce un float: i giorni interi trascorsi.
return (int) Carbon::parse($this->assigned_at)->diffInDays($end);
} }
public function getIsApertaAttribute(): bool public function getIsApertaAttribute(): bool
@@ -211,9 +237,56 @@ class Assegnazione extends Model
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]); return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]);
} }
public function markLinkSent(): void public function markLinkSent(?string $canale = null): void
{ {
$this->forceFill(['link_sent' => true])->saveQuietly(); $this->registraInvioLink(true, $canale);
}
/**
* Canali con cui si manda il link al proclamatore. Dal 19 ottobre 2026 se
* ne usano due: copia link e Condividi (il menu di condivisione del
* telefono); il destinatario lo sceglie chi usa l'app. "whatsapp" ed
* "email" restano solo perché le assegnazioni già inviate così lo
* mostrino e l'XML lo accetti.
*/
public const CANALI_LINK = ['copia' => 'Copia link', 'condividi' => 'Condividi', 'whatsapp' => 'WhatsApp', 'email' => 'Email'];
/** I canali che si possono scegliere oggi nella finestra di invio. */
public const CANALI_ATTIVI = ['copia', 'condividi'];
/**
* Esito dell'invio del link: inviato (con il canale usato) oppure no. Un
* link non inviato resta "da inviare" e lo si segnala sull'assegnazione,
* a meno che non ci sia proprio un link da mandare (territorio senza PDF).
*/
public function registraInvioLink(bool $inviato, ?string $canale = null): void
{
$this->forceFill([
'link_sent' => $inviato,
'link_da_inviare' => ! $inviato && $this->shortPdfUrl() !== null,
'link_canale' => $inviato ? $canale : $this->link_canale,
])->saveQuietly();
}
/** Il link va ancora mandato: l'avviso da mostrare sull'assegnazione. */
public function getLinkInSospesoAttribute(): bool
{
return $this->link_da_inviare && ! $this->link_sent && $this->is_aperta;
}
/**
* Il messaggio con il link, per "Condividi": il menu di condivisione del
* telefono, da cui chi usa l'app sceglie app e contatto. L'app non vede
* quei contatti.
*/
public function messaggioLink(?string $link = null): ?string
{
$link ??= $this->shortPdfUrl();
if ($link === null) {
return null;
}
return "Ciao {$this->proclamatore?->nome}, ti è stato assegnato il territorio N° {$this->territorio?->numero}. Ecco la piantina: {$link}";
} }
// ─── Scopes ───────────────────────────────────────────────── // ─── Scopes ─────────────────────────────────────────────────
@@ -235,16 +308,69 @@ class Assegnazione extends Model
// ─── Business Logic ───────────────────────────────────────── // ─── Business Logic ─────────────────────────────────────────
/**
* Registra nell'audit log la scelta dell'operatore sul conteggio in
* campagna: evento dedicato "campagna_percorso", leggibile senza dover
* interpretare le proprietà dell'evento "returned".
*/
public function logCampagnaPercorso(?Campagna $campagna, bool $percorso, string $origine): void
{
$this->loadMissing('territorio');
$giorni = $this->giorniPercorrenza();
$soglia = static::sogliaPercorrenzaElevata();
activity()->causedBy(auth()->user())
->performedOn($this)
->withProperties([
'territorio' => $this->territorio?->numero,
'proclamatore_id' => $this->proclamatore_id, // vedi Assegna: niente nomi in chiaro
'campagna' => $campagna?->descrizione,
'percorso_in_campagna' => $percorso ? 'Sì' : 'No',
'giorni_percorrenza' => $giorni,
'avviso_tempo_elevato' => $giorni > $soglia ? "Sì (soglia {$soglia} giorni)" : 'No',
'origine' => $origine,
])
->log('campagna_percorso');
}
/**
* Giorni tra assegnazione e rientro (o la data indicata, o oggi).
*/
public function giorniPercorrenza(?Carbon $returnDate = null): int
{
$end = $returnDate ?? $this->returned_at ?? now();
return (int) Carbon::parse($this->assigned_at)->startOfDay()->diffInDays(Carbon::parse($end)->startOfDay());
}
/**
* Oltre questa soglia il tempo di percorrenza è considerato molto alto:
* è la stessa soglia "giorni_per_smarrito" dello stato "da_rientrare".
*/
public static function sogliaPercorrenzaElevata(): int
{
return (int) Setting::getValue('giorni_per_smarrito', 120);
}
/** /**
* Check if a campaign prompt should be shown when returning this assignment. * Check if a campaign prompt should be shown when returning this assignment.
* Returns the matching campaign or null. * Returns the matching campaign or null.
*/ */
public function campagnaApplicabile(?\Carbon\Carbon $returnDate = null): ?Campagna public function campagnaApplicabile(?\Carbon\Carbon $returnDate = null): ?Campagna
{ {
$returnDate = $returnDate ?? now(); $returnDate = ($returnDate ?? now())->toDateString();
$assignedAt = $this->assigned_at->toDateString();
// Il territorio può essere conteggiato per una campagna se è rimasto
// in mano al proclamatore almeno in parte del periodo: assegnato
// durante la campagna, rientrato durante la campagna, oppure fuori per
// tutta la sua durata (assegnato prima e rientrato dopo). Se sia stato
// davvero percorso lo decide l'operatore con la spunta.
// Se più campagne corrispondono si propone la più recente.
return Campagna::where('start_date', '<=', $returnDate) return Campagna::where('start_date', '<=', $returnDate)
->where('end_date', '>=', $this->assigned_at) ->where('end_date', '>=', $assignedAt)
->orderByDesc('start_date')
->first(); ->first();
} }
} }

View File

@@ -65,6 +65,16 @@ class Campagna extends Model
->where('counted_in_campaign', true); ->where('counted_in_campaign', true);
} }
/**
* Assignments made during this campaign and not yet returned.
*/
public function assegnazioniInCorso()
{
return Assegnazione::whereNull('returned_at')
->where('assigned_at', '>=', $this->start_date)
->where('assigned_at', '<=', $this->end_date);
}
/** /**
* All assignments with assigned_at in this campaign's range. * All assignments with assigned_at in this campaign's range.
*/ */

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Gruppo di servizio: un sorvegliante (proclamatore con un'utenza limitata
* al gruppo), i membri (proclamatori, un solo gruppo a testa) e un pacchetto
* di territori che il sorvegliante distribuisce ai membri.
*
* Il pacchetto non è un'assegnazione: un territorio del pacchetto non ancora
* dato a nessuno è "in carico al gruppo" e non entra nelle statistiche. Quando
* il sorvegliante lo dà a un membro nasce un'assegnazione normale, con
* gruppo_servizio_id per dire da dove viene. Al rientro il territorio torna in
* reparto ed esce dal pacchetto.
*
* Un gruppo sciolto (sciolto_at) non ha più membri né pacchetto, ma resta: le
* assegnazioni nate da lui continuano a citarlo. Logica in
* App\Services\GruppiServizio.
*/
class GruppoServizio extends Model
{
use LogsActivity;
protected $table = 'gruppi_servizio';
protected $fillable = ['nome', 'sorvegliante_id', 'sciolto_at'];
protected function casts(): array
{
return ['sciolto_at' => 'datetime', 'sorvegliante_id' => 'integer'];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['nome', 'sorvegliante_id', 'sciolto_at'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function sorvegliante()
{
return $this->belongsTo(Proclamatore::class, 'sorvegliante_id')->withTrashed();
}
public function membri()
{
return $this->hasMany(Proclamatore::class, 'gruppo_servizio_id');
}
/** Il pacchetto: i territori in carico al gruppo, dati o non ancora dati. */
public function territori()
{
return $this->hasMany(Territorio::class, 'gruppo_servizio_id');
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'gruppo_servizio_id');
}
public function getScioltoAttribute(): bool
{
return $this->sciolto_at !== null;
}
public function scopeAttivi($query)
{
return $query->whereNull('sciolto_at');
}
}

View File

@@ -13,24 +13,37 @@ class Proclamatore extends Model
protected $table = 'proclamatori'; protected $table = 'proclamatori';
protected $fillable = ['nome', 'cognome', 'email', 'telefono', 'attivo']; // Niente email né telefono (tolti il 19 ottobre 2026): il link della
// piantina lo manda chi assegna, copiandolo o con "Condividi", e il
// destinatario lo sceglie dai propri contatti.
protected $fillable = ['nome', 'cognome', 'attivo', 'gruppo_servizio_id'];
/** Il gruppo di servizio di cui è membro (al massimo uno). */
public function gruppo()
{
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
}
/** L'utenza, se è sorvegliante di un gruppo. */
public function utente()
{
return $this->hasOne(User::class, 'proclamatore_id');
}
protected function casts(): array protected function casts(): array
{ {
return [ return [
'nome' => 'encrypted', 'nome' => 'encrypted',
'cognome' => 'encrypted', 'cognome' => 'encrypted',
// Facoltativi. Cifrati come nome e cognome: dati personali.
'email' => 'encrypted',
'telefono' => 'encrypted',
'attivo' => 'boolean', 'attivo' => 'boolean',
'gruppo_servizio_id' => 'integer',
]; ];
} }
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
{ {
return LogOptions::defaults() return LogOptions::defaults()
->logOnly(['attivo']) // Do NOT log nome/cognome/email/telefono in audit (encrypted, GDPR) ->logOnly(['attivo']) // Do NOT log nome/cognome in audit (encrypted, GDPR)
->logOnlyDirty() ->logOnlyDirty()
->dontSubmitEmptyLogs(); ->dontSubmitEmptyLogs();
} }
@@ -44,43 +57,31 @@ class Proclamatore extends Model
} }
/** /**
* Il numero come lo vuole WhatsApp: solo cifre, con il prefisso * L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per
* internazionale. Un numero senza prefisso si considera italiano: un * cognome e nome. Nome e cognome sono cifrati nel database: la ricerca
* cellulare scritto "333 1234567" diventa 393331234567. Null se non c'è un * avviene sui valori già decifrati, nella pagina, mai in SQL.
* numero utilizzabile.
*/ */
public function telefonoInternazionale(): ?string public function opzioneScelta(?string $motivo = null): array
{ {
$grezzo = trim((string) $this->telefono); return array_filter([
if ($grezzo === '') { 'valore' => (string) $this->id,
return null; 'etichetta' => $this->nome_completo,
} 'chiavi' => [(string) $this->cognome, (string) $this->nome],
'motivo' => $motivo,
$cifre = preg_replace('/\D+/', '', $grezzo); ], fn ($v) => $v !== null);
if (str_starts_with($cifre, '00') && ! str_starts_with($grezzo, '+')) {
$cifre = substr($cifre, 2);
} elseif (! str_starts_with($grezzo, '+') && (! str_starts_with($cifre, '39') || strlen($cifre) <= 10)) {
// Senza prefisso: cellulari (3…) e fissi (0…) italiani. Un
// "39…" di 11 cifre o più ha già il prefisso, scritto senza "+".
$cifre = '39' . $cifre;
}
return strlen($cifre) >= 8 && strlen($cifre) <= 15 ? $cifre : null;
} }
/** /**
* Il link che apre una chat WhatsApp con il proclamatore, con un messaggio * Le opzioni di un elenco di proclamatori, in ordine di cognome e nome.
* già scritto se c'è. Null se non ha un numero utilizzabile. * L'ordine si fa qui: in SQL ordinerebbe i testi cifrati.
*/ */
public function whatsappUrl(?string $messaggio = null): ?string public static function opzioniScelta(iterable $proclamatori): array
{ {
$numero = $this->telefonoInternazionale(); return collect($proclamatori)
if ($numero === null) { ->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome))
return null; ->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo'))
} ->values()
->all();
return 'https://wa.me/' . $numero . ($messaggio !== null && $messaggio !== '' ? '?text=' . rawurlencode($messaggio) : '');
} }
public function assegnazioni() public function assegnazioni()
@@ -106,10 +107,23 @@ class Proclamatore extends Model
{ {
$this->nome = 'Anonimo'; $this->nome = 'Anonimo';
$this->cognome = 'Proclamatore #' . $this->id; $this->cognome = 'Proclamatore #' . $this->id;
$this->email = null;
$this->telefono = null;
$this->attivo = false; $this->attivo = false;
$this->gruppo_servizio_id = null;
$this->save(); $this->save();
$this->delete(); // soft delete $this->delete(); // soft delete
// L'utenza di un ex sorvegliante (già disattivata: il sorvegliante di
// un gruppo attivo non si anonimizza) porta nome ed email in chiaro.
$utente = $this->utente()->first();
if ($utente) {
$utente->syncRoles([]);
$utente->forceFill([
'name' => 'Anonimo',
'email' => "utente-{$utente->id}@anonimo.invalid",
'password' => \Illuminate\Support\Str::random(40),
'remember_token' => null,
'disattivato_at' => $utente->disattivato_at ?? now(),
])->save();
}
} }
} }

View File

@@ -24,12 +24,20 @@ class Territorio extends Model
'thumbnail_path', 'thumbnail_path',
'attivo', 'attivo',
'prioritario', 'prioritario',
'gruppo_servizio_id',
]; ];
/** Il gruppo di servizio nel cui pacchetto si trova, se c'è. */
public function gruppo()
{
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
}
protected function casts(): array protected function casts(): array
{ {
return [ return [
'attivo' => 'boolean', 'attivo' => 'boolean',
'gruppo_servizio_id' => 'integer',
'prioritario' => 'boolean', 'prioritario' => 'boolean',
]; ];
} }
@@ -95,9 +103,25 @@ class Territorio extends Model
return 'assegnato'; return 'assegnato';
} }
// Nel pacchetto di un gruppo e non ancora dato a nessuno: non è in
// reparto (non si assegna dalla pagina normale), è del gruppo.
if ($this->gruppo_servizio_id) {
return 'in_gruppo';
}
return 'in_reparto'; return 'in_reparto';
} }
/** Lo stato da mostrare: "In gruppo Zisa" invece di "In gruppo". */
public function getStatoEtichettaAttribute(): string
{
$stato = $this->stato;
return $stato === 'in_gruppo'
? 'In gruppo ' . ($this->gruppo?->nome ?? '')
: str_replace('_', ' ', ucfirst($stato));
}
public function getAssegnatarioAttribute(): ?Proclamatore public function getAssegnatarioAttribute(): ?Proclamatore
{ {
return $this->assegnazioneCorrente?->proclamatore; return $this->assegnazioneCorrente?->proclamatore;
@@ -145,6 +169,24 @@ class Territorio extends Model
return false; return false;
} }
/**
* 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ò
* scegliere ("già assegnato a …").
*/
public function opzioneScelta(?string $motivo = null, ?string $nota = null): array
{
$dettaglio = trim(($this->zona?->nome ?? '') . ($this->tipologia?->nome ? " ({$this->tipologia->nome})" : ''));
return array_filter([
'valore' => (string) $this->id,
'etichetta' => "N° {$this->numero}" . ($nota ? " ({$nota})" : ''),
'dettaglio' => $dettaglio !== '' ? $dettaglio : null,
'chiavi' => [(string) $this->numero, (string) $this->zona?->nome, (string) $this->tipologia?->nome],
'motivo' => $motivo,
], fn ($v) => $v !== null);
}
// ─── Scopes ───────────────────────────────────────────────── // ─── Scopes ─────────────────────────────────────────────────
public function scopeAttivi($query) public function scopeAttivi($query)
@@ -154,12 +196,23 @@ class Territorio extends Model
public function scopeInReparto($query) public function scopeInReparto($query)
{ {
// Quelli nel pacchetto di un gruppo non sono in reparto: stanno al
// gruppo finché il sorvegliante non li dà o non li rimanda indietro.
return $query->attivi() return $query->attivi()
->whereNull('gruppo_servizio_id')
->whereDoesntHave('assegnazioni', function ($q) { ->whereDoesntHave('assegnazioni', function ($q) {
$q->whereNull('returned_at'); $q->whereNull('returned_at');
}); });
} }
/** Nel pacchetto di un gruppo e non ancora dati a nessuno. */
public function scopeInGruppo($query)
{
return $query->attivi()
->whereNotNull('gruppo_servizio_id')
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
}
public function scopeAssegnato($query) public function scopeAssegnato($query)
{ {
return $query->attivi() return $query->attivi()

View File

@@ -24,6 +24,8 @@ class User extends Authenticatable implements MustVerifyEmail
'email', 'email',
'password', 'password',
'password_change_required', 'password_change_required',
'proclamatore_id',
'disattivato_at',
]; ];
protected $hidden = [ protected $hidden = [
@@ -37,9 +39,50 @@ class User extends Authenticatable implements MustVerifyEmail
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'password_change_required' => 'boolean', 'password_change_required' => 'boolean',
'disattivato_at' => 'datetime',
'proclamatore_id' => 'integer',
]; ];
} }
/** Il proclamatore di cui è l'utenza (sorvegliante di gruppo). */
public function proclamatore()
{
return $this->belongsTo(Proclamatore::class, 'proclamatore_id')->withTrashed();
}
/**
* Sorvegliante di gruppo: vede solo il proprio gruppo e il proprio
* profilo (middleware LimitaSorvegliante).
*/
public function isSorvegliante(): bool
{
return $this->hasRole('sorvegliante');
}
/** Il gruppo (attivo) di cui è sorvegliante, se c'è. */
public function gruppoSorvegliato(): ?GruppoServizio
{
return $this->proclamatore_id
? GruppoServizio::attivi()->where('sorvegliante_id', $this->proclamatore_id)->first()
: null;
}
/** Utenza disattivata (gruppo sciolto): non può più entrare. */
public function isDisattivato(): bool
{
return $this->disattivato_at !== null;
}
/**
* Il sorvegliante non conferma l'email (scelta dell'utente, 19 ottobre
* 2026): l'utenza la crea l'amministratore con una password temporanea.
* Per tutti gli altri resta obbligatoria.
*/
public function hasVerifiedEmail(): bool
{
return ! is_null($this->email_verified_at) || $this->isSorvegliante();
}
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
{ {
return LogOptions::defaults() return LogOptions::defaults()
@@ -48,6 +91,32 @@ class User extends Authenticatable implements MustVerifyEmail
->dontSubmitEmptyLogs(); ->dontSubmitEmptyLogs();
} }
/**
* L'utenza di un sorvegliante porta nome (ed email, spesso) di un
* proclamatore: nel registro delle attività i proclamatori compaiono solo
* con l'identificativo, quindi anche qui.
*/
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
{
$proclamatoreId = $this->proclamatore_id ?? $this->getOriginal('proclamatore_id');
if (! $proclamatoreId) {
return;
}
$activity->properties = collect($activity->properties)->map(function ($valori) use ($proclamatoreId) {
if (! is_array($valori)) {
return $valori;
}
foreach (['name', 'email'] as $campo) {
if (array_key_exists($campo, $valori)) {
$valori[$campo] = "proclamatore #{$proclamatoreId}";
}
}
return $valori;
});
}
protected static function booted(): void protected static function booted(): void
{ {
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi. // Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
@@ -65,6 +134,10 @@ class User extends Authenticatable implements MustVerifyEmail
*/ */
public function inviaConfermaEmail(): bool public function inviaConfermaEmail(): bool
{ {
if ($this->isSorvegliante()) {
return true;
}
try { try {
$this->sendEmailVerificationNotification(); $this->sendEmailVerificationNotification();
} catch (\Throwable $e) { } catch (\Throwable $e) {

View File

@@ -0,0 +1,272 @@
<?php
namespace App\Services;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use InvalidArgumentException;
/**
* Le regole dei gruppi di servizio, in un posto solo: le usano le pagine
* dell'amministratore (Gruppi di servizio) e quella del sorvegliante (Il mio
* gruppo). Ogni metodo lancia InvalidArgumentException con un messaggio da
* mostrare così com'è quando una regola non è rispettata.
*/
class GruppiServizio
{
/**
* Crea il gruppo e l'utenza del sorvegliante: ruolo "sorvegliante",
* password temporanea da cambiare al primo accesso, nessuna conferma
* dell'email. Restituisce il gruppo e la password temporanea, da mostrare
* una volta sola all'amministratore.
*
* @param int[] $membri id dei proclamatori, oltre al sorvegliante
* @return array{0: GruppoServizio, 1: string}
*/
public function crea(string $nome, Proclamatore $sorvegliante, string $email, array $membri = []): array
{
$nome = trim($nome);
if ($nome === '') {
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
}
if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
}
if ($sorvegliante->gruppo_servizio_id) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
}
// Chi è stato sorvegliante di un gruppo sciolto ha già un'utenza,
// disattivata: si riattiva quella (un proclamatore, un'utenza).
$esistente = $sorvegliante->utente()->first();
if ($esistente && ! $esistente->isDisattivato()) {
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
}
if (User::where('email', $email)->when($esistente, fn ($q) => $q->whereKeyNot($esistente->id))->exists()) {
throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente.");
}
$password = Str::password(12, symbols: false);
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password, $esistente) {
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
$this->aggiungiMembri($gruppo, $membri);
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
$utente->forceFill([
'name' => $sorvegliante->nome_completo,
'email' => $email,
'password' => $password,
'password_change_required' => true,
'disattivato_at' => null,
])->save();
$utente->syncRoles(['sorvegliante']);
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
->log('gruppo_creato');
return $gruppo;
});
return [$gruppo, $password];
}
/** @param int[] $ids */
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
{
$this->attivo($gruppo);
foreach (Proclamatore::whereIn('id', $ids)->get() as $p) {
if ($p->gruppo_servizio_id && $p->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("{$p->nome_completo} fa già parte di un altro gruppo.");
}
if (! $p->attivo) {
throw new InvalidArgumentException("{$p->nome_completo} non è attivo.");
}
$p->update(['gruppo_servizio_id' => $gruppo->id]);
}
}
public function togliMembro(GruppoServizio $gruppo, Proclamatore $membro): void
{
$this->attivo($gruppo);
if ($membro->id === $gruppo->sorvegliante_id) {
throw new InvalidArgumentException('Il sorvegliante non si toglie: per cambiarlo si scioglie il gruppo.');
}
if ($membro->gruppo_servizio_id === $gruppo->id) {
$membro->update(['gruppo_servizio_id' => null]);
}
}
/**
* Mette nel pacchetto del gruppo dei territori in reparto (attivi, non
* assegnati, non già in un altro pacchetto). Restituisce quanti.
*
* @param int[] $ids
*/
public function aggiungiAlPacchetto(GruppoServizio $gruppo, array $ids): int
{
$this->attivo($gruppo);
$territori = Territorio::whereIn('id', $ids)->get();
foreach ($territori as $t) {
if (! $t->attivo) {
throw new InvalidArgumentException("Il territorio {$t->numero} non è attivo.");
}
if ($t->gruppo_servizio_id && $t->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("Il territorio {$t->numero} è già nel pacchetto di un altro gruppo.");
}
if ($t->assegnazioneCorrente) {
throw new InvalidArgumentException("Il territorio {$t->numero} è assegnato: va prima fatto rientrare.");
}
}
Territorio::whereKey($territori->pluck('id'))->update(['gruppo_servizio_id' => $gruppo->id]);
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['territori' => $territori->pluck('numero')->all()])
->log('pacchetto_aggiunti');
return $territori->count();
}
/**
* Rimanda in reparto un territorio del pacchetto non ancora dato a
* nessuno (lo fa l'amministratore o il sorvegliante).
*/
public function rimandaInReparto(GruppoServizio $gruppo, Territorio $territorio): void
{
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
}
if ($territorio->assegnazioneCorrente) {
throw new InvalidArgumentException("Il territorio {$territorio->numero} è assegnato: prima va registrato il rientro.");
}
$territorio->update(['gruppo_servizio_id' => null]);
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['territorio' => $territorio->numero])
->log('pacchetto_rimandato_in_reparto');
}
/**
* Il sorvegliante dà un territorio del pacchetto a un membro (o a sé):
* nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il
* territorio resta nel pacchetto finché non rientra.
*/
public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data): Assegnazione
{
$this->attivo($gruppo);
// Si decide sui dati di adesso, non su quelli con cui è partita la
// pagina: nel frattempo l'amministratore può aver cambiato pacchetto
// o membri.
$territorio->refresh();
$membro->refresh();
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
}
if ($membro->gruppo_servizio_id !== $gruppo->id) {
throw new InvalidArgumentException("{$membro->nome_completo} non fa parte del gruppo.");
}
if (! $membro->attivo) {
throw new InvalidArgumentException("{$membro->nome_completo} non è attivo.");
}
if ($data->isFuture()) {
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
}
$esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data) {
$bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first();
if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) {
return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo.";
}
$conflitto = Assegnazione::sovrappostaA($territorio->id, $data);
if ($conflitto) {
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}.";
}
return Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $membro->id,
'anno_teocratico_id' => AnnoTeocratico::perData($data)->id,
'assigned_at' => $data,
'created_by' => auth()->id(),
'gruppo_servizio_id' => $gruppo->id,
'link_da_inviare' => (bool) $territorio->pdf_path,
]);
});
if (is_string($esito)) {
throw new InvalidArgumentException($esito);
}
activity()->causedBy(auth()->user())->performedOn($esito)
->withProperties([
'territorio' => $territorio->numero,
'proclamatore_id' => $membro->id,
'gruppo_servizio_id' => $gruppo->id,
])
->log('assigned');
return $esito;
}
/**
* Scioglie il gruppo (si toglie il ruolo di sorvegliante): i territori del
* pacchetto non ancora dati tornano in reparto, i membri restano senza
* gruppo, l'utenza del sorvegliante si disattiva. Le assegnazioni già
* fatte restano com'erano, con il loro riferimento al gruppo, che resta
* nello storico come sciolto.
*/
public function sciogli(GruppoServizio $gruppo): void
{
if ($gruppo->sciolto_at) {
return;
}
DB::transaction(function () use ($gruppo) {
// Esce dal pacchetto solo quello non ancora dato: un territorio
// assegnato resta del suo gruppo finché non rientra (Assegnazione
// lo libera al rientro), così non può essere ridato due volte.
$nonDati = Territorio::withTrashed()
->where('gruppo_servizio_id', $gruppo->id)
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
$liberati = (clone $nonDati)->pluck('numero');
$nonDati->update(['gruppo_servizio_id' => null]);
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
if ($utente) {
$utente->removeRole('sorvegliante');
$utente->forceFill(['disattivato_at' => now(), 'remember_token' => null])->save();
}
$gruppo->update(['sciolto_at' => now()]);
activity()->causedBy(auth()->user())->performedOn($gruppo)
->withProperties(['territori_liberati' => $liberati->all(), 'utente_disattivato' => $utente?->id])
->log('gruppo_sciolto');
});
}
private function attivo(GruppoServizio $gruppo): void
{
if ($gruppo->sciolto_at) {
throw new InvalidArgumentException('Il gruppo è stato sciolto: creane uno nuovo.');
}
}
}

49
app/Support/Ritorno.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
namespace App\Support;
/**
* La pagina da cui è partito un percorso, per tornarci dopo un'operazione.
*
* I link che aprono una scheda o un'operazione portano `?da=<percorso>`
* (lo aggiunge resources/js/app.js ai link con data-ritorno): il percorso
* della pagina di partenza con i suoi filtri, o il `da` che quella pagina
* aveva già ricevuto, così si risale sempre all'origine. Elenco → scheda →
* Rientra → conferma riporta all'elenco filtrato.
*
* Si accettano solo percorsi interni ("/territori?zona=3"): un indirizzo
* completo o "//altro.sito" farebbe del parametro un rimando verso l'esterno.
*/
final class Ritorno
{
public const PARAM = 'da';
/** L'URL a cui tornare, o null se il valore non è un percorso interno valido. */
public static function valida(mixed $percorso): ?string
{
if (! is_string($percorso) || $percorso === '' || strlen($percorso) > 2000) {
return null;
}
if (! str_starts_with($percorso, '/')
|| str_starts_with($percorso, '//')
|| str_contains($percorso, '\\')
|| preg_match('/[\x00-\x1F\x7F]/', $percorso)) {
return null;
}
// Le richieste interne di Livewire non sono pagine.
$path = (string) parse_url($percorso, PHP_URL_PATH);
if ($path === '' || str_starts_with($path, '/livewire')) {
return null;
}
return url($percorso);
}
/** Il ritorno indicato dalla richiesta corrente (?da=…), se valido. */
public static function daRichiesta(): ?string
{
return self::valida(request()->query(self::PARAM));
}
}

View File

@@ -57,6 +57,8 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [ $middleware->web(append: [
\Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Session\Middleware\AuthenticateSession::class,
\App\Http\Middleware\RequirePasswordChange::class, \App\Http\Middleware\RequirePasswordChange::class,
// Utenze disattivate fuori; il sorvegliante di gruppo solo nel suo gruppo.
\App\Http\Middleware\LimitaSorvegliante::class,
]); ]);
$middleware->alias([ $middleware->alias([

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Invio del link dopo l'assegnazione.
*
* - link_da_inviare: l'assegnazione aspetta che il link venga mandato al
* proclamatore. Nasce vero con l'assegnazione fatta dalla pagina Assegna e
* torna falso quando il link risulta inviato. Le assegnazioni già esistenti
* restano false: non si segnalano a posteriori centinaia di link di cui non
* si sa niente.
* - link_canale: come è stato mandato l'ultima volta (copia, whatsapp, email).
* Non è un dato personale: email e telefono restano solo, cifrati, nel
* proclamatore.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->boolean('link_da_inviare')->default(false)->after('link_sent');
$table->string('link_canale', 16)->nullable()->after('link_da_inviare');
});
}
public function down(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropColumn(['link_da_inviare', 'link_canale']);
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* I proclamatori non hanno più email né telefono.
*
* Servivano solo a mandare il link della piantina; ora lo manda chi assegna
* il territorio, scegliendo il destinatario dai propri contatti (WhatsApp
* senza numero, oppure il menu "Condividi" del telefono). Meno dati personali
* da conservare. In produzione, al momento della decisione (19 ottobre
* 2026), nessun proclamatore li aveva compilati.
*/
return new class extends Migration
{
public function up(): void
{
$colonne = array_values(array_filter(['email', 'telefono'], fn ($c) => Schema::hasColumn('proclamatori', $c)));
if ($colonne !== []) {
Schema::table('proclamatori', fn (Blueprint $table) => $table->dropColumn($colonne));
}
}
public function down(): void
{
// Le colonne tornano vuote: i valori non si recuperano.
Schema::table('proclamatori', function (Blueprint $table) {
$table->text('email')->nullable()->after('cognome');
$table->text('telefono')->nullable()->after('email');
});
}
};

View File

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

View File

@@ -25,6 +25,7 @@ class RolesAndPermissionsSeeder extends Seeder
'territori.return', 'territori.return',
'audit.view', 'audit.view',
'audit.export', 'audit.export',
'gruppo.gestisci',
]; ];
foreach ($permissions as $permission) { foreach ($permissions as $permission) {
@@ -43,7 +44,9 @@ class RolesAndPermissionsSeeder extends Seeder
// Amministratore: all permissions // Amministratore: all permissions
$admin = Role::firstOrCreate(['name' => 'amministratore']); $admin = Role::firstOrCreate(['name' => 'amministratore']);
$admin->givePermissionTo($permissions); // Tutti tranne gruppo.gestisci, che è del solo sorvegliante: l'admin
// gestisce i gruppi da settings.manage, non ne ha uno suo.
$admin->givePermissionTo(array_values(array_diff($permissions, ['gruppo.gestisci'])));
// Assistente: proclamatori, campagne, assign, return // Assistente: proclamatori, campagne, assign, return
$assistente = Role::firstOrCreate(['name' => 'assistente']); $assistente = Role::firstOrCreate(['name' => 'assistente']);
@@ -61,5 +64,10 @@ class RolesAndPermissionsSeeder extends Seeder
'territori.assign', 'territori.assign',
'territori.return', 'territori.return',
]); ]);
// Sorvegliante di gruppo: solo il proprio gruppo di servizio. Il ruolo
// si dà creando un gruppo (Gruppi di servizio), mai dalla pagina Utenti.
$sorvegliante = Role::firstOrCreate(['name' => 'sorvegliante']);
$sorvegliante->givePermissionTo(['gruppo.gestisci']);
} }
} }

View File

@@ -3,7 +3,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build" "build": "vite build",
"test:js": "node --test tests/js/"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5", "@tailwindcss/forms": "^0.5",

View File

@@ -557,33 +557,6 @@ html { scroll-behavior: smooth; }
background: var(--tm-superficie-alt); background: var(--tm-superficie-alt);
} }
/* ─── Numero di telefono che apre WhatsApp ───────────────────────────────── */
.tm-whatsapp {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid #b7e1c6;
border-radius: 999px;
background: #eaf7ef;
font-size: 13px;
font-weight: 600;
color: #17703f;
text-decoration: none;
white-space: nowrap;
}
.tm-whatsapp:hover { background: #d8f0e1; }
.tm-whatsapp:focus-visible { outline: 2px solid #17703f; outline-offset: 2px; }
.tm-whatsapp svg { width: 16px; height: 16px; flex: none; }
.tm-whatsapp-compatto { padding: 5px; }
.tm-whatsapp-grande {
padding: 10px 18px;
font-size: 15px;
}
.tm-whatsapp-grande svg { width: 20px; height: 20px; }
.tm-whatsapp-numero { font-weight: 500; opacity: .75; }
/* ─── Assegnazione appena fatta: il link da mandare ──────────────────────── */ /* ─── Assegnazione appena fatta: il link da mandare ──────────────────────── */
.tm-da-inviare { .tm-da-inviare {
@@ -598,6 +571,14 @@ html { scroll-behavior: smooth; }
background: var(--tm-accento-tenue); background: var(--tm-accento-tenue);
box-shadow: var(--tm-ombra-2); box-shadow: var(--tm-ombra-2);
} }
/* Lo stesso riquadro come finestra sopra la pagina (invio del link dopo
l'assegnazione): sfondo pieno, perché sotto c'è il velo scuro. */
.tm-da-inviare-finestra {
width: 100%;
max-width: 34rem;
margin-bottom: 0;
background: var(--tm-superficie, #fff);
}
.tm-da-inviare-titolo { .tm-da-inviare-titolo {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
@@ -1678,3 +1659,165 @@ html { scroll-behavior: smooth; }
text-align: center; text-align: center;
} }
} }
/* ─── Campo di scelta con ricerca (<x-scelta>) ──────────────────────────────
Al posto delle tendine di territori e proclamatori. Pensato anche per il
telefono: testo a 16px (sotto, iOS ingrandisce la pagina al tocco), voci
alte almeno 44px, elenco che scorre dentro mezzo schermo. I selettori
battono `.tm-campo input[type="text"]` dell'editor delle piantine. */
.tm-scelta {
position: relative;
width: 100%;
}
.tm-scelta-campo {
position: relative;
display: flex;
align-items: center;
}
.tm-scelta input.tm-scelta-input {
width: 100%;
min-height: 44px;
padding: 8px 76px 8px 12px;
border: 1px solid var(--tm-bordo-forte);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
color: var(--tm-testo);
font-family: inherit;
font-size: 16px;
line-height: 1.3;
transition: border-color .15s ease, box-shadow .15s ease;
}
@media (min-width: 640px) {
.tm-scelta input.tm-scelta-input { min-height: 40px; font-size: 14px; }
}
.tm-scelta input.tm-scelta-input:focus {
outline: none;
border-color: var(--tm-accento);
box-shadow: 0 0 0 3px var(--tm-accento-tenue);
}
.tm-scelta input.tm-scelta-input:disabled {
padding-right: 12px;
background: var(--tm-superficie-alt);
color: var(--tm-testo-medio);
}
.tm-scelta input.tm-scelta-input::placeholder { color: var(--tm-testo-tenue); }
.tm-scelta-pulisci,
.tm-scelta-apri {
position: absolute;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-top: -18px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--tm-testo-tenue);
cursor: pointer;
}
.tm-scelta-pulisci { right: 38px; }
.tm-scelta-apri { right: 2px; }
.tm-scelta-pulisci:hover,
.tm-scelta-apri:hover { color: var(--tm-testo); background: var(--tm-superficie-alt); }
.tm-scelta-pulisci svg,
.tm-scelta-apri svg { width: 18px; height: 18px; transition: transform .15s ease; }
.tm-scelta-apri svg.tm-scelta-giu { transform: rotate(180deg); }
.tm-scelta-elenco {
position: absolute;
z-index: 40;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: min(50vh, 20rem);
margin: 0;
padding: 4px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
list-style: none;
border: 1px solid var(--tm-bordo);
border-radius: var(--tm-raggio-interno);
background: var(--tm-superficie);
box-shadow: var(--tm-ombra-3);
}
.tm-scelta-voce {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 8px;
row-gap: 2px;
min-height: 44px;
padding: 10px 12px;
border-radius: 8px;
color: var(--tm-testo);
font-size: 15px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
@media (min-width: 640px) {
.tm-scelta-voce { min-height: 36px; padding: 7px 10px; font-size: 14px; }
}
.tm-scelta-voce-attiva { background: var(--tm-accento-tenue); }
.tm-scelta-voce-scelta .tm-scelta-etichetta { font-weight: 700; color: var(--tm-accento-scuro); }
.tm-scelta-voce-speciale { color: var(--tm-testo-medio); }
.tm-scelta-voce-spenta { cursor: not-allowed; color: var(--tm-testo-tenue); }
.tm-scelta-voce-spenta.tm-scelta-voce-attiva { background: var(--tm-superficie-alt); }
.tm-scelta-dettaglio { font-size: 13px; color: var(--tm-testo-tenue); }
.tm-scelta-motivo { flex-basis: 100%; font-size: 12px; color: var(--tm-attesa); }
.tm-scelta-forse {
padding: 1px 6px;
border-radius: 999px;
background: var(--tm-attesa-tenue);
color: var(--tm-attesa);
font-size: 11px;
font-weight: 600;
}
.tm-scelta-vuoto {
padding: 12px;
color: var(--tm-testo-tenue);
font-size: 14px;
}
/* ─── Elenco dei territori assegnati (pagina Assegna) ───────────────────────
Classi brevi al posto delle catene di utility: le righe sono ~80 e ogni
byte si moltiplica (ElencoAssegnati, AssegnaPrestazioniTest). */
.tm-assegnato { padding: 16px 20px; }
.tm-assegnato svg { width: 16px; height: 16px; fill: none; stroke: currentColor; flex: none; }
.tm-assegnato-testa {
display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between;
gap: 8px 24px;
}
.tm-assegnato-numero { color: #4f46e5; font-size: 14px; font-weight: 700; }
.tm-assegnato-numero:hover { text-decoration: underline; }
.tm-assegnato-zona {
margin-left: 6px; padding: 2px 6px; border-radius: 4px;
background: #f3f4f6; color: #9ca3af; font-size: 12px;
}
.tm-assegnato-info { color: #6b7280; font-size: 12px; }
.tm-assegnato-info b { color: #1f2937; font-size: 14px; font-weight: 500; }
.tm-assegnato-giorni {
padding: 2px 8px; border-radius: 999px;
background: #f3f4f6; color: #6b7280; font-weight: 600;
}
.tm-assegnato-giorni.tm-g-presto { background: #fffbeb; color: #d97706; }
.tm-assegnato-giorni.tm-g-tardi { background: #fef2f2; color: #dc2626; }
.tm-assegnato-sospeso {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
margin-top: 12px; padding: 6px 12px; border: 1px solid #fde68a; border-radius: 6px;
background: #fffbeb; color: #92400e; font-size: 12px; font-weight: 600;
}
.tm-assegnato-link { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
.tm-assegnato-link > * { flex: none; }
.tm-assegnato-link code {
flex: 1 1 0; min-width: 0; padding: 8px 12px;
border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb;
color: #4b5563; font-size: 12px; word-break: break-all; user-select: all; cursor: text;
}
.tm-assegnato-senza { margin-top: 8px; color: #d1d5db; font-size: 12px; }
/* Pulsante copia: la spunta al posto dell'icona per due secondi. */
.tm-copia svg + svg, .tm-copia.tm-copiato svg:first-child { display: none; }
.tm-copia.tm-copiato svg + svg { display: block; color: #16a34a; }

View File

@@ -1 +1,181 @@
import './bootstrap'; import './bootstrap';
import { proponi, normalizza } from './scelta';
// ─── Campo di scelta con ricerca (<x-scelta>) ───────────────────────────────
//
// Al posto della tendina: si scrive e sotto compaiono le proposte più vicine
// (logica in ./scelta.js). Campo vuoto o appena aperto: la tendina completa.
// Il valore scelto arriva a Livewire con x-modelable + wire:model.
document.addEventListener('alpine:init', () => {
// Elenco filtrato nel browser: ogni riga ha data-cerca (testo già senza
// accenti, minuscolo); valgono tutte le parole scritte, in qualsiasi
// ordine. Nessuna richiesta al server mentre si scrive.
window.Alpine.data('elencoCercabile', () => ({
q: '',
condivisibile: !! navigator.share,
// Condividi il link della riga (data-messaggio): il menu del telefono
// fa scegliere app e contatto. A condivisione fatta il link risulta
// inviato; se la si annulla non cambia niente.
condividi(pulsante) {
const riga = pulsante.closest('[data-link]');
if (!riga || !navigator.share) return;
navigator.share({ title: riga.dataset.titolo, text: riga.dataset.messaggio })
.then(() => this.$wire.segnaCondiviso(Number(riga.dataset.id)))
.catch(() => {});
},
corrisponde(testo) {
const parole = normalizza(this.q).split(/\s+/).filter(Boolean);
return parole.every((p) => (testo ?? '').includes(p));
},
get nessuno() {
if (normalizza(this.q) === '') return false;
return ![...this.$root.querySelectorAll('[data-cerca]')].some((riga) => this.corrisponde(riga.dataset.cerca));
},
// Copia il link della riga (il <code> accanto al pulsante); il pulsante
// mostra la spunta per due secondi (classe tm-copiato). Un metodo solo
// invece dello stesso codice ripetuto in ogni riga.
copia(pulsante) {
const testo = pulsante.closest('[data-link]')?.querySelector('code')?.textContent.trim() ?? '';
navigator.clipboard?.writeText(testo).then(() => {
pulsante.classList.add('tm-copiato');
setTimeout(() => pulsante.classList.remove('tm-copiato'), 2000);
}).catch(() => {});
},
}));
// vuoto: il valore di "nessuna scelta" per la proprietà Livewire collegata
// ('' per le stringhe, null per i ?int: '' su un ?int sarebbe un errore).
window.Alpine.data('sceltaRicerca', (opzioni, vuoto = '') => ({
opzioni,
vuoto,
valore: null,
testo: '',
aperto: false,
evidenziato: 0,
init() {
this.testo = this.etichettaDi(this.valore);
// Valore cambiato da Livewire (reset del form, scelta da codice).
this.$watch('valore', (v) => { if (!this.aperto) this.testo = this.etichettaDi(v); });
},
scelta() {
return this.opzioni.find((o) => String(o.valore) === String(this.valore ?? '')) ?? null;
},
etichettaDi(v) {
if (v === null || v === undefined || v === '') {
const vuota = this.opzioni.find((o) => o.speciale && o.valore === '');
return vuota ? vuota.etichetta : '';
}
const o = this.opzioni.find((x) => String(x.valore) === String(v));
return o ? o.etichetta : '';
},
// Finché nel campo c'è l'etichetta della scelta attuale, si vede la
// tendina completa: si cerca solo da quando si comincia a scrivere.
get ricerca() {
return this.testo === this.etichettaDi(this.valore) ? '' : this.testo;
},
get risultati() {
return proponi(this.opzioni, this.ricerca);
},
apri() {
if (this.aperto) return;
this.aperto = true;
const i = this.risultati.findIndex((o) => String(o.valore) === String(this.valore ?? ''));
this.evidenziato = Math.max(0, i);
this.$nextTick(() => this.mostraEvidenziato());
},
chiudi() {
this.aperto = false;
this.testo = this.etichettaDi(this.valore);
},
alterna() {
if (this.aperto) { this.chiudi(); return; }
this.$refs.campo.focus();
this.apri();
},
scrivendo() {
this.aperto = true;
this.evidenziato = 0;
},
sposta(passo) {
if (!this.aperto) { this.apri(); return; }
const n = this.risultati.length;
if (n === 0) return;
this.evidenziato = (this.evidenziato + passo + n) % n;
this.$nextTick(() => this.mostraEvidenziato());
},
mostraEvidenziato() {
this.$refs.elenco?.querySelector('[data-evidenziato="1"]')?.scrollIntoView({ block: 'nearest' });
},
// daTocco: scelta col dito o col mouse. Si toglie il focus, così sul
// telefono si chiude la tastiera; con frecce e Invio il focus resta.
scegli(opzione, daTocco = false) {
if (!opzione || opzione.motivo) return;
this.valore = opzione.valore;
this.aperto = false;
this.testo = opzione.etichetta;
// Per chi vuole reagire alla scelta senza passare dal server
// (l'anteprima della miniatura in Assegna).
this.$dispatch('scelta', { valore: opzione.valore, opzione });
if (daTocco) this.$refs.campo.blur();
},
scegliEvidenziato() {
if (this.aperto) this.scegli(this.risultati[this.evidenziato]);
},
svuota() {
this.valore = this.vuoto;
this.testo = '';
this.$dispatch('scelta', { valore: this.vuoto, opzione: null });
this.$refs.campo.focus();
this.aperto = true;
this.evidenziato = 0;
},
}));
});
// ─── Ritorno alla pagina di partenza ────────────────────────────────────────
//
// I link con data-ritorno (schede e operazioni) portano con sé ?da=<percorso>:
// la pagina di partenza con i suoi filtri, oppure il `da` che la pagina
// corrente aveva già ricevuto, così si risale sempre all'origine del percorso
// (elenco → scheda → Rientra → torna all'elenco). Il server lo valida e lo usa
// dopo conferma o annulla: App\Support\Ritorno, trait RicordaRitorno.
//
// Si calcola al momento dell'uso e non al caricamento: filtri e pagina degli
// elenchi cambiano l'indirizzo (#[Url] di Livewire) senza ricaricare.
function ritornoCorrente() {
const qui = new URL(window.location.href);
return qui.searchParams.get('da') || (qui.pathname + qui.search);
}
function aggiornaLink(link) {
const destinazione = new URL(link.getAttribute('href'), window.location.origin);
if (destinazione.origin !== window.location.origin) return;
destinazione.searchParams.set('da', ritornoCorrente());
link.setAttribute('href', destinazione.pathname + destinazione.search + destinazione.hash);
}
// Fase di cattura: si arriva prima di wire:navigate, che legge l'href al
// pointerdown (precaricamento) e al click. contextmenu e focusin coprono
// "apri in una nuova scheda" e la tastiera.
['pointerdown', 'click', 'contextmenu', 'focusin'].forEach((evento) => {
document.addEventListener(evento, (e) => {
const link = e.target instanceof Element ? e.target.closest('a[data-ritorno][href]') : null;
if (link) aggiornaLink(link);
}, true);
});

View File

@@ -312,6 +312,9 @@ function initPiantine() {
} else { } else {
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
} }
// Arrivati da "Nuovo territorio": si comincia subito a disegnare.
if (componente().creaTerritorio) avviaDisegno(false);
} }
mappa.setView([41.9, 12.5], 6); mappa.setView([41.9, 12.5], 6);

101
resources/js/scelta.js Normal file
View File

@@ -0,0 +1,101 @@
// ─── Scelta con ricerca: la logica, senza DOM ───────────────────────────────
//
// Le proposte per quello che si scrive in un campo di scelta (territorio,
// proclamatore). Funzioni pure, provate da tests/js/scelta.test.mjs.
//
// Un'opzione è { valore, etichetta, dettaglio?, chiavi: [stringhe], motivo?,
// speciale? }:
// chiavi i campi in cui cercare, in ordine di importanza (cognome prima
// del nome, numero prima della zona)
// motivo l'opzione c'è ma non si può scegliere ("già assegnato a …"): si
// mostra solo cercando, mai nella tendina completa
// speciale sempre in cima e mai filtrata ("— nessun territorio —")
//
// Punteggio di ogni parola scritta, più basso = più vicino:
// 0 uguale · 1 inizia con · 2 contiene · 3 a una lettera di distanza
// ("forse cercavi"), più 0.1 per ogni posizione della chiave.
// Devono trovare posto tutte le parole scritte, in qualsiasi ordine
// ("mario ros" trova Rossi Mario).
export function normalizza(testo) {
return (testo ?? '')
.toString()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim();
}
/** Distanza di modifica tra due parole, fermandosi appena supera `limite`. */
export function distanza(a, b, limite = 2) {
if (Math.abs(a.length - b.length) > limite) return limite + 1;
let precedente = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
const riga = [i];
let minimo = i;
for (let j = 1; j <= b.length; j++) {
const costo = a[i - 1] === b[j - 1] ? 0 : 1;
riga[j] = Math.min(precedente[j] + 1, riga[j - 1] + 1, precedente[j - 1] + costo);
minimo = Math.min(minimo, riga[j]);
}
if (minimo > limite) return limite + 1;
precedente = riga;
}
return precedente[b.length];
}
const PERFETTO = 0, INIZIA = 1, CONTIENE = 2, VICINO = 3;
function punteggioParola(parola, chiavi) {
let migliore = Infinity;
chiavi.forEach((chiave, posizione) => {
if (chiave === '') return;
let p = Infinity;
if (chiave === parola) p = PERFETTO;
else if (chiave.startsWith(parola) || chiave.split(/\s+/).some((w) => w.startsWith(parola))) p = INIZIA;
else if (chiave.includes(parola)) p = CONTIENE;
else if (parola.length >= 4) {
// Errori di battitura: una lettera (due dalle 7 lettere in su), su
// parole intere. Mai sui numeri brevi: "35" non deve trovare "36".
const tolleranza = parola.length >= 7 ? 2 : 1;
if (chiave.split(/\s+/).some((w) => distanza(w, parola, tolleranza) <= tolleranza)) p = VICINO;
}
if (p !== Infinity) migliore = Math.min(migliore, p + posizione * 0.1);
});
return migliore;
}
/**
* Le opzioni da proporre per `testo`.
* Campo vuoto: la tendina completa (speciali e sceglibili, nell'ordine dato).
* Altrimenti: le opzioni in cui trovano posto tutte le parole, dalla più
* vicina; `forse` segna quelle trovate solo per un errore di battitura.
*/
export function proponi(opzioni, testo, massimo = 50) {
const speciali = opzioni.filter((o) => o.speciale);
const normali = opzioni.filter((o) => !o.speciale);
const parole = normalizza(testo).split(/\s+/).filter(Boolean);
if (parole.length === 0) {
return [...speciali, ...normali.filter((o) => !o.motivo)].map((o) => ({ ...o, forse: false }));
}
const trovate = [];
normali.forEach((opzione, ordine) => {
const chiavi = (opzione.chiavi ?? [opzione.etichetta]).map(normalizza);
let totale = 0;
let forse = false;
for (const parola of parole) {
const p = punteggioParola(parola, chiavi);
if (p === Infinity) return;
if (p >= VICINO) forse = true;
totale += p;
}
trovate.push({ opzione, totale, ordine, forse });
});
trovate.sort((a, b) => a.totale - b.totale || a.ordine - b.ordine);
return [...speciali.map((o) => ({ ...o, forse: false })),
...trovate.slice(0, massimo).map((t) => ({ ...t.opzione, forse: t.forse }))];
}

View File

@@ -79,11 +79,24 @@
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" <aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 lg:z-auto lg:shadow-none lg:border-r lg:border-gray-200 pt-16 lg:pt-0"> class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 lg:z-auto lg:shadow-none lg:border-r lg:border-gray-200 pt-16 lg:pt-0">
<nav class="mt-4 px-3 space-y-0.5"> <nav class="mt-4 px-3 space-y-0.5">
{{-- Il sorvegliante di gruppo vede solo il suo gruppo (LimitaSorvegliante).
Il ruolo, non @can: l'amministratore passa ogni @can
(Gate::before) ma un gruppo suo non ce l'ha. --}}
@if(auth()->user()->isSorvegliante())
<a href="{{ route('mio-gruppo') }}"
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('mio-gruppo*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Il mio gruppo
</a>
@endif
@unless(auth()->user()->isSorvegliante() && auth()->user()->roles->count() === 1)
<a href="{{ route('dashboard') }}" <a href="{{ route('dashboard') }}"
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('dashboard') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}"> class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('dashboard') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/></svg> <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/></svg>
Home Home
</a> </a>
@endunless
@can('territori.assign') @can('territori.assign')
<a href="{{ route('territori.index') }}" <a href="{{ route('territori.index') }}"
@@ -146,6 +159,11 @@
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg> <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Utenti Utenti
</a> </a>
<a href="{{ route('gruppi.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('gruppi.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
Gruppi di servizio
</a>
<a href="{{ route('zone.index') }}" <a href="{{ route('zone.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('zone.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}"> class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('zone.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg> <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>

View File

@@ -0,0 +1,96 @@
{{--
Campo di scelta con ricerca, al posto di <select> per territori e
proclamatori. Si scrive e sotto compaiono le proposte più vicine (uguale,
inizia con, contiene, errore di battitura); con il campo vuoto o appena
aperto c'è la tendina completa. Logica in resources/js/scelta.js, provata
da tests/js/scelta.test.mjs; comportamento in resources/js/app.js.
id: id del campo (per la <label for>)
opzioni: array di opzioni, vedi Territorio::opzioneScelta() e
Proclamatore::opzioneScelta(); per le voci fisse in cima
['valore' => '', 'etichetta' => '…', 'speciale' => true]
segnaposto: testo nel campo vuoto
disabilitato: true per bloccare il campo (territorio già preselezionato)
vuoto: il valore di "nessuna scelta": '' (default) per le proprietà
stringa, null per quelle ?int
Il valore va a Livewire come con un <select>: wire:model sul componente.
Si ridisegna da capo quando cambiano le opzioni (wire:key con l'impronta),
perché x-data le legge una volta sola.
--}}
@props(['id', 'opzioni' => [], 'segnaposto' => 'Scrivi per cercare…', 'disabilitato' => false, 'vuoto' => ''])
@php($impronta = substr(md5(json_encode($opzioni)), 0, 12))
<div x-data="sceltaRicerca(@js(array_values($opzioni)), @js($vuoto))"
x-modelable="valore"
wire:key="scelta-{{ $id }}-{{ $impronta }}"
{{ $attributes->whereStartsWith('wire:model') }}
{{ $attributes->whereDoesntStartWith('wire:model')->class('tm-scelta') }}
x-on:click.outside="aperto && chiudi()"
x-on:keydown.escape="if (aperto) { $event.stopPropagation(); chiudi() }">
<div class="tm-scelta-campo">
<input x-ref="campo"
id="{{ $id }}"
type="text"
role="combobox"
aria-autocomplete="list"
aria-controls="{{ $id }}-elenco"
x-bind:aria-expanded="aperto ? 'true' : 'false'"
x-bind:aria-activedescendant="aperto && risultati.length ? '{{ $id }}-voce-' + evidenziato : null"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
inputmode="search" enterkeyhint="done"
placeholder="{{ $segnaposto }}"
@disabled($disabilitato)
x-model="testo"
x-on:focus="$el.select(); apri()"
x-on:click="apri()"
x-on:input="scrivendo()"
x-on:keydown.arrow-down.prevent="sposta(1)"
x-on:keydown.arrow-up.prevent="sposta(-1)"
x-on:keydown.enter.prevent="scegliEvidenziato()"
x-on:keydown.tab="aperto && chiudi()"
class="tm-scelta-input">
@unless($disabilitato)
<button type="button" class="tm-scelta-pulisci" x-show="testo !== ''" x-cloak
x-on:pointerdown.prevent x-on:click="svuota()"
aria-label="Svuota il campo" tabindex="-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
<button type="button" class="tm-scelta-apri"
x-on:pointerdown.prevent x-on:click="alterna()"
x-bind:aria-label="aperto ? 'Chiudi l\'elenco' : 'Apri l\'elenco'" tabindex="-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true" x-bind:class="aperto && 'tm-scelta-giu'"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
@endunless
</div>
<ul x-ref="elenco" id="{{ $id }}-elenco" role="listbox" class="tm-scelta-elenco" x-show="aperto" x-cloak>
<template x-for="(opzione, i) in risultati" :key="String(opzione.valore) + '|' + i">
<li x-bind:id="'{{ $id }}-voce-' + i"
role="option"
x-bind:aria-selected="String(opzione.valore) === String(valore ?? '') ? 'true' : 'false'"
x-bind:aria-disabled="opzione.motivo ? 'true' : null"
x-bind:data-evidenziato="i === evidenziato ? '1' : null"
class="tm-scelta-voce"
x-bind:class="{
'tm-scelta-voce-attiva': i === evidenziato,
'tm-scelta-voce-scelta': String(opzione.valore) === String(valore ?? ''),
'tm-scelta-voce-spenta': !! opzione.motivo,
'tm-scelta-voce-speciale': !! opzione.speciale,
}"
x-on:pointerdown.prevent
x-on:click="scegli(opzione, true)"
x-on:pointermove="evidenziato = i">
<span class="tm-scelta-etichetta" x-text="opzione.etichetta"></span>
<span class="tm-scelta-dettaglio" x-show="opzione.dettaglio" x-text="opzione.dettaglio"></span>
<span class="tm-scelta-motivo" x-show="opzione.motivo" x-text="opzione.motivo"></span>
<span class="tm-scelta-forse" x-show="opzione.forse">forse cercavi</span>
</li>
</template>
<li class="tm-scelta-vuoto" x-show="risultati.length === 0" role="presentation">
Nessun risultato per «<span x-text="testo"></span>»
</li>
</ul>
</div>

View File

@@ -1,26 +0,0 @@
{{--
Il numero di un proclamatore che, cliccato, apre WhatsApp (wa.me: app sul
telefono, WhatsApp Web sul computer). Niente se il numero manca o non è
utilizzabile.
proclamatore: il Proclamatore.
messaggio: testo già scritto nella chat (facoltativo).
compatto: solo l'icona, per le righe di un elenco.
--}}
@props(['proclamatore', 'messaggio' => null, 'compatto' => false])
@php($url = $proclamatore?->whatsappUrl($messaggio))
@if($url)
<a href="{{ $url }}" target="_blank" rel="noopener"
{{ $attributes->merge(['class' => 'tm-whatsapp' . ($compatto ? ' tm-whatsapp-compatto' : '')]) }}
title="Scrivi su WhatsApp a {{ $proclamatore->nome_completo }}"
@if($compatto) aria-label="Scrivi su WhatsApp a {{ $proclamatore->nome_completo }}" @endif>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
@unless($compatto)
<span>{{ $proclamatore->telefono }}</span>
@endunless
</a>
@elseif(! $compatto && trim((string) $proclamatore?->telefono) !== '')
<span {{ $attributes }}>{{ $proclamatore->telefono }}</span>
@endif

View File

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

View File

@@ -1,75 +1,15 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1"> <a href="{{ $ritorno ?? route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna ai territori {{ $ritorno ? 'Torna indietro' : 'Torna ai territori' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">Assegnazioni</h1> <h1 class="text-2xl font-bold text-gray-900">Assegnazioni</h1>
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p> <p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p>
</div> </div>
{{-- Appena assegnato: il link da mandare al proclamatore, in evidenza, e @include('livewire.assegnazioni._invio-link')
la scelta "inviato / non ancora". Resta finché non si sceglie
(Assegna::DA_INVIARE in sessione). --}}
@foreach($daInviare as $nuova)
@php
$link = $nuova->shortPdfUrl();
$messaggio = $link
? "Ciao {$nuova->proclamatore->nome}, ti è stato assegnato il territorio N° {$nuova->territorio->numero}. Ecco la piantina: {$link}"
: null;
$whatsapp = $messaggio ? $nuova->proclamatore->whatsappUrl($messaggio) : null;
@endphp
<section class="tm-da-inviare" wire:key="da-inviare-{{ $nuova->id }}" aria-labelledby="da-inviare-{{ $nuova->id }}">
<h2 id="da-inviare-{{ $nuova->id }}" class="tm-da-inviare-titolo">
Territorio N° {{ $nuova->territorio->numero }} assegnato a {{ $nuova->proclamatore->nome_completo }}
</h2>
@if($link)
<div x-data="{ copiato: false }" class="tm-da-inviare-link">
<code x-ref="link" x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
<x-btn variante="secondary" tono="tenue" dimensione="sm"
x-on:click="navigator.clipboard.writeText($refs.link.textContent).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) })">
<span x-show="! copiato">Copia link</span>
<span x-show="copiato" x-cloak>Copiato</span>
</x-btn>
</div>
<div class="tm-da-inviare-azioni">
@if($whatsapp)
<a href="{{ $whatsapp }}" target="_blank" rel="noopener" class="tm-whatsapp tm-whatsapp-grande">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
<span>Invia su WhatsApp</span>
<span class="tm-whatsapp-numero">{{ $nuova->proclamatore->telefono }}</span>
</a>
@else
<p class="text-sm text-gray-600">
{{ $nuova->proclamatore->nome_completo }} non ha un numero di telefono: copia il link e mandalo come preferisci.
@can('settings.manage')
<a href="{{ route('proclamatori.edit', $nuova->proclamatore) }}" class="text-indigo-700 underline">Aggiungi il numero</a>
@endcan
</p>
@endif
</div>
<div class="tm-da-inviare-scelta">
<p>Il link è stato inviato?</p>
<div>
<x-btn variante="primary" wire:click="esitoInvio({{ $nuova->id }}, true)">Sì, inviato</x-btn>
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Non ancora</x-btn>
</div>
</div>
@else
<p class="text-sm text-gray-600">
Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio.
</p>
<div class="tm-da-inviare-scelta">
<div>
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Ho capito</x-btn>
</div>
</div>
@endif
</section>
@endforeach
<div x-data="{ formOpen: true }"> <div x-data="{ formOpen: true }">
@@ -87,54 +27,35 @@
{{-- Form --}} {{-- Form --}}
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl"> <div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
<form wire:submit="save" class="space-y-4"> <form wire:submit="save" class="space-y-4">
<div> {{-- L'anteprima segue la scelta nel browser (evento "scelta" di
@if(!$preselectedTerritorioId) <x-scelta>, miniatura nelle opzioni): scegliere un
<label for="territorio_search" class="block text-sm font-medium text-gray-700">Filtra territori</label> territorio non fa più una richiesta al server. --}}
<input wire:model.live.debounce.300ms="territorioSearch" <div x-data="{ scelto: @js((bool) $territorio_id), anteprima: @js($anteprimaIniziale) }"
type="text" x-on:scelta="scelto = !! $event.detail.valore; anteprima = $event.detail.opzione?.miniatura ?? null">
id="territorio_search" <label for="territorio_id" class="block text-sm font-medium text-gray-700 mb-1">Territorio *</label>
placeholder="Cerca per numero, zona o tipologia" <x-scelta id="territorio_id" wire:model="territorio_id"
class="mt-1 mb-2 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm"> :opzioni="$opzioniTerritori"
@endif segnaposto="Numero, zona o tipologia" :vuoto="null"
:disabilitato="(bool) $preselectedTerritorioId" />
<label for="territorio_id" class="block text-sm font-medium text-gray-700">Territorio *</label>
<select wire:model.live="territorio_id" id="territorio_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" @if($preselectedTerritorioId) disabled @endif>
<option value="">Seleziona un territorio</option>
@foreach($territoriDisponibili as $t)
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }} ({{ $t->tipologia?->nome }})</option>
@endforeach
</select>
@if($preselectedTerritorioId)
<input type="hidden" wire:model="territorio_id" value="{{ $preselectedTerritorioId }}">
@endif
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@if($territorio_id) <div class="mt-3" x-show="scelto" x-cloak>
<div class="mt-3">
<p class="text-xs text-gray-500 mb-2">Anteprima territorio</p> <p class="text-xs text-gray-500 mb-2">Anteprima territorio</p>
@if($this->selectedThumbnailUrl) <div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm" x-show="anteprima">
<div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm"> <img x-bind:src="anteprima" alt="Miniatura del territorio scelto" loading="lazy"
<img src="{{ $this->selectedThumbnailUrl }}"
alt="Thumbnail territorio selezionato"
class="block w-full h-auto max-h-[70vh] object-contain bg-white"> class="block w-full h-auto max-h-[70vh] object-contain bg-white">
</div> </div>
@else <div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500" x-show="! anteprima">
<div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500"> Nessuna miniatura disponibile per questo territorio.
Nessuna thumbnail disponibile per questo territorio.
</div> </div>
@endif
</div> </div>
@endif
</div> </div>
<div> <div>
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700">Proclamatore *</label> <label for="proclamatore_id" class="block text-sm font-medium text-gray-700 mb-1">Proclamatore *</label>
<select wire:model="proclamatore_id" id="proclamatore_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm"> <x-scelta id="proclamatore_id" wire:model="proclamatore_id"
<option value="">Seleziona un proclamatore</option> :opzioni="$opzioniProclamatori"
@foreach($proclamatoriAttivi as $p) segnaposto="Cognome o nome" :vuoto="null" />
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
@endforeach
</select>
@error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
@@ -146,113 +67,19 @@
<div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center"> <div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
<x-btn variante="primary" type="submit">Assegna</x-btn> <x-btn variante="primary" type="submit">Assegna</x-btn>
@if($ritorno)
<x-btn variante="secondary" :href="$ritorno">Annulla</x-btn>
@else
<x-btn variante="secondary" x-on:click="formOpen = false">Annulla</x-btn> <x-btn variante="secondary" x-on:click="formOpen = false">Annulla</x-btn>
@endif
</div> </div>
</form> </form>
</div> </div>
</div> </div>
{{-- Elenco territori attualmente assegnati --}} {{-- Elenco dei territori assegnati: componente a sé, caricato subito
@if($assegnazioniAperte->count()) dopo la pagina (ElencoAssegnati, #[Lazy]). Le azioni del form non
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-2"> lo ridisegnano. --}}
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe"> <livewire:assegnazioni.elenco-assegnati />
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#6366f1">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</div>
<div>
<h3 class="text-sm font-semibold" style="color:#3730a3">Territori Assegnati ({{ $assegnazioniAperte->count() }})</h3>
<p class="text-xs" style="color:#4f46e5">Link PDF · stato invio · il link scade alla soglia rientro ({{ $sogliaRientro }} giorni)</p>
</div>
</div>
{{-- Ricerca rapida: territorio, numero, zona, proclamatore in un solo campo --}}
<div class="px-5 py-3 border-b border-gray-100" style="background:#fafafa">
<div class="relative">
<svg class="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input wire:model.live.debounce.300ms="linkSearch" type="text"
placeholder="Cerca per territorio, numero, zona o proclamatore..."
class="block w-full rounded-lg border-gray-300 text-sm pl-9 focus:ring-indigo-500 focus:border-indigo-500">
</div>
</div>
<div class="divide-y divide-gray-100">
@forelse($assegnazioniAperteVisibili as $a)
@php
$pdfUrl = $a->shortPdfUrl();
$linkScaduto = ! auth()->check() && $a->data_consegna_presunta && $a->data_consegna_presunta->isPast();
@endphp
<div class="px-5 py-4 hover:bg-indigo-50/30 transition-colors">
{{-- Testata riga --}}
<div class="flex flex-wrap items-start justify-between gap-x-6 gap-y-2">
<div class="flex items-center gap-2">
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-bold text-sm">N° {{ $a->territorio?->numero }}</a>
@if($a->territorio?->zona?->nome)
<span class="text-xs text-gray-400 bg-gray-100 rounded px-1.5 py-0.5">{{ $a->territorio?->zona?->nome }}</span>
@endif
</div>
<div class="flex items-center gap-3 text-sm flex-wrap">
<span class="font-medium text-gray-800">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</span>
<span class="text-gray-300">·</span>
<span class="text-gray-500 text-xs">{{ $a->assigned_at->format('d/m/Y') }}</span>
<span class="text-gray-300">·</span>
<span class="text-xs font-semibold px-2 py-0.5 rounded-full
{{ $a->giorni > 120 ? 'bg-red-50 text-red-600' : ($a->giorni > 90 ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-500') }}">
{{ $a->giorni }}g
</span>
</div>
</div>
{{-- Avviso link scaduto (solo per non loggati) --}}
@if($linkScaduto)
<div class="mt-3 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold bg-red-50 border border-red-200 text-red-700">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Link scaduto — rigenerare dal dettaglio territorio
</div>
{{-- Link attivo --}}
@elseif($pdfUrl)
<div x-data="{ copied: false }" class="mt-3 flex flex-wrap items-center gap-2">
<code class="flex-1 min-w-0 text-xs bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-gray-600 break-all select-all cursor-text" x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="flex-none"
x-on:click="navigator.clipboard.writeText($el.closest('div').querySelector('code').textContent).then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
title="Copia link" aria-label="Copia il link del PDF">
<svg x-show="!copied" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
<svg x-show="copied" x-cloak class="text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</x-btn>
@if($a->proclamatore)
<x-whatsapp :proclamatore="$a->proclamatore" compatto class="flex-none"
:messaggio="'Ciao ' . $a->proclamatore->nome . ', ti è stato assegnato il territorio N° ' . $a->territorio?->numero . '. Ecco la piantina: ' . $pdfUrl" />
@endif
{{-- Flag Link Inviato --}}
{{-- Interruttore di stato, non un comando: lo stato "inviato"
si legge dalla variante (primary quando è acceso) e
aria-pressed lo dice anche a chi non lo vede. --}}
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" class="flex-none"
wire:click="toggleLinkSent({{ $a->id }})"
aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}"
title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}">
@if($a->link_sent)
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Link inviato
@else
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
Non inviato
@endif
</x-btn>
<span x-show="copied" x-cloak class="flex-none text-xs text-green-600 font-medium">Copiato!</span>
</div>
@else
<p class="mt-2 text-xs text-gray-300">Nessun PDF disponibile</p>
@endif
</div>
@empty
<div class="px-5 py-8 text-center text-sm text-gray-400">Nessun risultato per "{{ $linkSearch }}".</div>
@endforelse
</div>
</div>
@endif
</div> </div>

View File

@@ -0,0 +1,3 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 mt-2 px-5 py-6 text-sm text-gray-500" role="status">
Caricamento dei territori assegnati…
</div>

View File

@@ -0,0 +1,70 @@
<div>
@if($assegnazioni->count())
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-2" x-data="elencoCercabile">
{{-- Le icone delle righe, una volta sola: ogni riga le richiama con
<use>. Ripetute per esteso pesavano ~60 KB su 80 righe. --}}
<svg aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
<symbol id="tm-i-copia" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></symbol>
<symbol id="tm-i-spunta" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></symbol>
<symbol id="tm-i-invio" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></symbol>
<symbol id="tm-i-avviso" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></symbol>
<symbol id="tm-i-condividi" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></symbol>
</svg>
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#6366f1">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</div>
<div>
<h3 class="text-sm font-semibold" style="color:#3730a3">Territori Assegnati ({{ $assegnazioni->count() }})</h3>
<p class="text-xs" style="color:#4f46e5">Link PDF · stato invio · il link scade alla soglia rientro ({{ $sogliaRientro }} giorni)</p>
</div>
</div>
{{-- Ricerca rapida: filtra nel browser, nessuna richiesta al server --}}
<div class="px-5 py-3 border-b border-gray-100" style="background:#fafafa">
<div class="relative">
<svg class="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input x-model="q" type="search" inputmode="search" autocomplete="off"
aria-label="Cerca tra i territori assegnati"
placeholder="Cerca per territorio, numero, zona o proclamatore..."
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm pl-9 focus:ring-indigo-500 focus:border-indigo-500">
</div>
</div>
<div class="divide-y divide-gray-100">
@foreach($assegnazioni as $a)
@php
$pdfUrl = $a->shortPdfUrl();
$nome = $a->proclamatore?->nome_completo ?? 'N/A';
@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). --}}
<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>
<span class="tm-assegnato-info"><b>{{ $nome }}</b> · {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
</div>
@if($a->link_in_sospeso)
<div class="tm-assegnato-sospeso" role="status"><svg aria-hidden="true"><use href="#tm-i-avviso"/></svg> Link ancora da inviare <x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="$dispatch('apri-invio', { assegnazioneId: {{ $a->id }} })">Invia link</x-btn></div>
@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 }}">
<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). --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona x-show="condivisibile" x-cloak x-on:click="condividi($el)" title="Condividi il link: scegli tu app e contatto" aria-label="Condividi il link con {{ $nome }}"><svg aria-hidden="true"><use href="#tm-i-condividi"/></svg></x-btn>
{{-- Interruttore di stato: la variante (primary se acceso) e aria-pressed dicono se il link è inviato. --}}
<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>
@endif
</div>
@endforeach
<div x-show="nessuno" x-cloak class="px-5 py-8 text-center text-sm text-gray-400">Nessun risultato per «<span x-text="q"></span>».</div>
</div>
</div>
@endif
</div>

View File

@@ -1,8 +1,8 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('territori.show', $assegnazione->territorio) }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1"> <a href="{{ $ritorno ?? $this->paginaPredefinita() }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna al territorio {{ $ritorno ? 'Torna indietro' : (auth()->user()->can('territori.assign') ? 'Torna al territorio' : 'Torna al mio gruppo') }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1> <h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1>
</div> </div>
@@ -40,13 +40,19 @@
@if($showCampaignPrompt && $campagna) @if($showCampaignPrompt && $campagna)
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4"> <div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p class="text-sm font-medium text-amber-800 mb-2"> <p class="text-sm font-medium text-amber-800 mb-2">
⚠️ Campagna Attiva: <strong>{{ $campagna->descrizione }}</strong> ⚠️ Campagna: <strong>{{ $campagna->descrizione }}</strong>
</p> </p>
<p class="text-xs text-amber-700 mb-3"> <p class="text-xs text-amber-700 mb-3">
Questo territorio è stato assegnato durante la campagna "{{ $campagna->descrizione }}" Questo territorio era assegnato durante la campagna "{{ $campagna->descrizione }}"
({{ $campagna->start_date->format('d/m/Y') }} — {{ $campagna->end_date->format('d/m/Y') }}). ({{ $campagna->start_date->format('d/m/Y') }} — {{ $campagna->end_date->format('d/m/Y') }}).
Vuoi conteggiarlo nella percentuale di percorrenza? È stato percorso per la campagna? Se sì, conteggialo nella percentuale di percorrenza.
</p> </p>
@if($giorniPercorrenza !== null && $giorniPercorrenza > $sogliaPercorrenza)
<div class="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
⚠️ <strong>Tempo di percorrenza molto alto: {{ $giorniPercorrenza }} giorni</strong> (soglia {{ $sogliaPercorrenza }}).
Verifica che il territorio sia stato effettivamente percorso durante la campagna.
</div>
@endif
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input wire:model="counted_in_campaign" type="checkbox" id="counted_in_campaign" class="rounded border-gray-300 text-amber-600 focus:ring-amber-500"> <input wire:model="counted_in_campaign" type="checkbox" id="counted_in_campaign" class="rounded border-gray-300 text-amber-600 focus:ring-amber-500">
<label for="counted_in_campaign" class="text-sm text-amber-800">Sì, conteggia in campagna</label> <label for="counted_in_campaign" class="text-sm text-amber-800">Sì, conteggia in campagna</label>
@@ -56,7 +62,7 @@
<div class="flex flex-wrap items-center gap-3 pt-4"> <div class="flex flex-wrap items-center gap-3 pt-4">
<x-btn variante="primary" type="submit">Registra rientro</x-btn> <x-btn variante="primary" type="submit">Registra rientro</x-btn>
<x-btn variante="secondary" :href="route('territori.show', $assegnazione->territorio)">Annulla</x-btn> <x-btn variante="secondary" :href="$ritorno ?? $this->paginaPredefinita()">Annulla</x-btn>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -49,6 +49,7 @@
'deleted' => 'bg-red-100 text-red-800', 'deleted' => 'bg-red-100 text-red-800',
'assigned' => 'bg-indigo-100 text-indigo-800', 'assigned' => 'bg-indigo-100 text-indigo-800',
'returned' => 'bg-amber-100 text-amber-800', 'returned' => 'bg-amber-100 text-amber-800',
'campagna_percorso' => 'bg-orange-100 text-orange-800',
'restored' => 'bg-purple-100 text-purple-800', 'restored' => 'bg-purple-100 text-purple-800',
'login' => 'bg-cyan-100 text-cyan-800', 'login' => 'bg-cyan-100 text-cyan-800',
default => 'bg-gray-100 text-gray-700', default => 'bg-gray-100 text-gray-700',

View File

@@ -1,8 +1,8 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('campagne.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1"> <a href="{{ $ritorno ?? route('campagne.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista {{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1>
</div> </div>
@@ -30,7 +30,7 @@
<div class="flex items-center gap-3 pt-4"> <div class="flex items-center gap-3 pt-4">
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn> <x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>
<x-btn variante="secondary" :href="route('campagne.index')">Annulla</x-btn> <x-btn variante="secondary" :href="$ritorno ?? route('campagne.index')">Annulla</x-btn>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900">Campagne</h1> <h1 class="text-2xl font-bold text-gray-900">Campagne</h1>
@can('settings.manage') @can('settings.manage')
<x-btn variante="primary" :href="route('campagne.create')"> <x-btn data-ritorno variante="primary" :href="route('campagne.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuova campagna Nuova campagna
</x-btn> </x-btn>
@@ -47,13 +47,13 @@
</td> </td>
<td class="px-4 py-3 text-sm text-right cell-actions"> <td class="px-4 py-3 text-sm text-right cell-actions">
<div class="flex flex-wrap items-center justify-end gap-1"> <div class="flex flex-wrap items-center justify-end gap-1">
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona <x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('campagne.show', $campagna)" :href="route('campagne.show', $campagna)"
title="Dettaglio" aria-label="Dettaglio della campagna {{ $campagna->descrizione }}"> title="Dettaglio" aria-label="Dettaglio della campagna {{ $campagna->descrizione }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</x-btn> </x-btn>
@can('settings.manage') @can('settings.manage')
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona <x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('campagne.edit', $campagna)" :href="route('campagne.edit', $campagna)"
title="Modifica" aria-label="Modifica la campagna {{ $campagna->descrizione }}"> title="Modifica" aria-label="Modifica la campagna {{ $campagna->descrizione }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>

View File

@@ -1,14 +1,14 @@
<div> <div>
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<a href="{{ route('campagne.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1"> <a href="{{ $ritorno ?? route('campagne.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista {{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">{{ $campagna->descrizione }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ $campagna->descrizione }}</h1>
</div> </div>
@can('settings.manage') @can('settings.manage')
<x-btn variante="secondary" :href="route('campagne.edit', $campagna)"> <x-btn data-ritorno variante="secondary" :href="route('campagne.edit', $campagna)">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica Modifica
</x-btn> </x-btn>
@@ -63,7 +63,7 @@
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@forelse($conteggiate as $a) @forelse($conteggiate as $a)
<tr> <tr>
<td data-label="Territorio" class="px-4 py-2"><a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a></td> <td data-label="Territorio" class="px-4 py-2"><a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a></td>
<td data-label="Proclamatore" class="px-4 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td> <td data-label="Proclamatore" class="px-4 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
<td data-label="Assegnato" class="px-4 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td> <td data-label="Assegnato" class="px-4 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-4 py-2">{{ $a->returned_at?->format('d/m/Y') }}</td> <td data-label="Rientrato" class="px-4 py-2">{{ $a->returned_at?->format('d/m/Y') }}</td>
@@ -78,4 +78,40 @@
</table> </table>
</div> </div>
</div> </div>
{{-- Assigned during the campaign, not yet returned (hidden once a later campaign has started) --}}
@if($inCorso !== null)
<div class="mt-6 bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 border-b">
<h3 class="text-sm font-semibold text-gray-700">In corso ({{ $inCorso->count() }})</h3>
<p class="text-xs text-gray-500 mt-0.5">Assegnati durante la campagna e non ancora rientrati: si potranno conteggiare al rientro.</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($inCorso as $a)
<tr>
<td data-label="Territorio" class="px-4 py-2"><a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a></td>
<td data-label="Proclamatore" class="px-4 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
<td data-label="Assegnato" class="px-4 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td data-label="Giorni" class="px-4 py-2">{{ $a->giorni }}</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-gray-500">Nessun territorio in corso.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
</div> </div>

View File

@@ -0,0 +1,73 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Gruppi di servizio</h1>
<p class="text-sm text-gray-500 mt-1">
Ogni gruppo ha un sorvegliante, che con la sua utenza vede solo il proprio gruppo e assegna ai membri i territori del pacchetto.
</p>
</div>
@if($creato && $passwordTemporanea)
<div class="mb-6 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
<p class="font-semibold">Gruppo «{{ $creato->nome }}» creato.</p>
<p class="mt-1">Consegna queste credenziali a {{ $creato->sorvegliante?->nome_completo }}: la password si vede <strong>solo ora</strong> e al primo accesso va cambiata.</p>
<dl class="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt class="text-green-700">Indirizzo</dt><dd><code>{{ route('login') }}</code></dd>
<dt class="text-green-700">Email</dt><dd><code>{{ $creato->sorvegliante?->utente?->email }}</code></dd>
<dt class="text-green-700">Password</dt><dd><code class="select-all">{{ $passwordTemporanea }}</code></dd>
</dl>
<div class="mt-3 flex flex-wrap gap-2">
<x-btn variante="primary" dimensione="sm" :href="route('gruppi.show', $creato)">Aggiungi membri e territori</x-btn>
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiPassword">Ho annotato la password</x-btn>
</div>
</div>
@endif
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl" aria-labelledby="titolo-nuovo">
<h2 id="titolo-nuovo" class="text-sm font-semibold text-gray-800 mb-3">Nuovo gruppo</h2>
<form wire:submit="crea" class="space-y-4">
<div>
<label for="nome-gruppo" class="block text-sm font-medium text-gray-700 mb-1">Nome del gruppo *</label>
<input wire:model="nome" id="nome-gruppo" type="text" placeholder="es. Zisa"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Sorvegliante *</label>
<x-scelta id="sorvegliante" wire:model="sorveglianteId" :opzioni="$opzioniSorveglianti" segnaposto="Cognome o nome" :vuoto="null" />
<p class="text-xs text-gray-500 mt-1">Un proclamatore attivo che non fa già parte di un gruppo. Diventa anche membro del gruppo.</p>
@error('sorveglianteId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="email-sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Email per l'accesso *</label>
<input wire:model="email" id="email-sorvegliante" type="email" autocomplete="off"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<p class="text-xs text-gray-500 mt-1">Resta solo nell'utenza del sorvegliante (non nel proclamatore) e non va confermata.</p>
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<x-btn variante="primary" type="submit">Crea il gruppo</x-btn>
</form>
</section>
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" aria-labelledby="titolo-elenco">
<h2 id="titolo-elenco" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">Gruppi</h2>
<ul class="divide-y divide-gray-100">
@forelse($gruppi as $g)
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div>
<a data-ritorno href="{{ route('gruppi.show', $g) }}" class="font-semibold text-indigo-600 hover:underline">{{ $g->nome }}</a>
@if($g->sciolto_at)
<span class="ml-1 inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">sciolto il {{ $g->sciolto_at->format('d/m/Y') }}</span>
@endif
<p class="text-xs text-gray-500">
Sorvegliante: {{ $g->sorvegliante?->nome_completo ?? '—' }}
@unless($g->sciolto_at) · {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto @endunless
</p>
</div>
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">{{ $g->sciolto_at ? 'Dettaglio' : 'Gestisci' }}</x-btn>
</li>
@empty
<li class="px-4 py-8 text-center text-sm text-gray-500">Nessun gruppo di servizio.</li>
@endforelse
</ul>
</section>
</div>

View File

@@ -0,0 +1,134 @@
<div>
<div class="mb-6">
<a href="{{ $ritorno ?? route('gruppi.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
{{ $ritorno ? 'Torna indietro' : 'Gruppi di servizio' }}
</a>
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
<p class="text-sm text-gray-500 mt-1">
Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong>
@if($attivo && $gruppo->sorvegliante?->utente)
· accede con <code>{{ $gruppo->sorvegliante->utente->email }}</code>
@endif
</p>
@unless($attivo)
<p class="mt-2 inline-flex rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700">
Sciolto il {{ $gruppo->sciolto_at->format('d/m/Y') }} · {{ $assegnazioniDelGruppo }} assegnazioni nate dal gruppo restano nello storico
</p>
@endunless
</div>
@if($passwordTemporanea)
<div class="mb-6 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
<p>Nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}, da cambiare al primo accesso. Si vede <strong>solo ora</strong>:</p>
<p class="mt-2"><code class="select-all text-base">{{ $passwordTemporanea }}</code></p>
</div>
@endif
@if($attivo)
{{-- ─── Pacchetto ─────────────────────────────────────────── --}}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-pacchetto">
<h2 id="titolo-pacchetto" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">
Pacchetto ({{ $territori->count() }} territori)
</h2>
<ul class="divide-y divide-gray-100">
@forelse($territori as $t)
@php($a = $t->assegnazioneCorrente)
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-2" wire:key="pacchetto-{{ $t->id }}">
<span class="text-sm">
<a data-ritorno href="{{ route('territori.show', $t) }}" class="font-semibold text-indigo-600 hover:underline">N° {{ $t->numero }}</a>
<span class="text-gray-500">{{ $t->zona?->nome }}</span>
—
@if($a)
<span class="text-gray-700">a {{ $a->proclamatore?->nome_completo }} dal {{ $a->assigned_at->format('d/m/Y') }}</span>
@else
<span class="text-gray-700">da dare</span>
@endif
</span>
@unless($a)
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="rimanda({{ $t->id }})"
wire:confirm="Rimandare in reparto il territorio {{ $t->numero }}?">Rimanda in reparto</x-btn>
@endunless
</li>
@empty
<li class="px-4 py-6 text-center text-sm text-gray-500">Il pacchetto è vuoto.</li>
@endforelse
</ul>
{{-- Assegnazione in blocco: si spuntano i territori in reparto --}}
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi territori al pacchetto</h3>
@if($candidatiTerritori->isEmpty())
<p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p>
@else
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i territori in reparto"
placeholder="Filtra per numero o zona…"
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
@foreach($candidatiTerritori as $c)
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40"
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->numero . ' ' . $c->zona?->nome)) }}"
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-t-{{ $c->id }}">
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviTerritori" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
<span><strong>N° {{ $c->numero }}</strong> <span class="text-gray-500">{{ $c->zona?->nome }}</span></span>
</label>
@endforeach
</div>
<div class="mt-3">
<x-btn variante="primary" wire:click="aggiungiTerritori">Aggiungi i territori selezionati</x-btn>
</div>
@endif
</div>
</section>
{{-- ─── Membri ───────────────────────────────────────────── --}}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-membri">
<h2 id="titolo-membri" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">Membri ({{ $membri->count() }})</h2>
<ul class="divide-y divide-gray-100">
@foreach($membri as $m)
<li class="px-4 py-2.5 flex items-center justify-between gap-2 text-sm" wire:key="membro-{{ $m->id }}">
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
@if($m->id !== $gruppo->sorvegliante_id)
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="togliMembro({{ $m->id }})">Togli</x-btn>
@endif
</li>
@endforeach
</ul>
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi membri</h3>
@if($candidatiMembri->isEmpty())
<p class="text-sm text-gray-500">Tutti i proclamatori attivi fanno già parte di un gruppo.</p>
@else
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i proclamatori"
placeholder="Filtra per nome…"
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
@foreach($candidatiMembri as $c)
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40"
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->nome_completo)) }}"
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-p-{{ $c->id }}">
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviMembri" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
<span>{{ $c->nome_completo }}</span>
</label>
@endforeach
</div>
<div class="mt-3">
<x-btn variante="primary" wire:click="aggiungiMembri">Aggiungi i membri selezionati</x-btn>
</div>
@endif
</div>
</section>
{{-- ─── Sorvegliante ─────────────────────────────────────── --}}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-4" aria-labelledby="titolo-sorvegliante">
<h2 id="titolo-sorvegliante" class="text-sm font-semibold text-gray-800 mb-2">Sorvegliante</h2>
<div class="flex flex-wrap gap-2">
<x-btn variante="secondary" wire:click="nuovaPassword"
wire:confirm="Generare una nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
<x-btn variante="danger" wire:click="sciogli"
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene disattivata. Le assegnazioni già fatte restano.">Togli il ruolo e sciogli il gruppo</x-btn>
</div>
</section>
@endif
</div>

View File

@@ -0,0 +1,123 @@
<div>
{{-- Il mio gruppo (MioGruppo): la sola pagina del sorvegliante di gruppo.
Pensata prima per il telefono: schede una sotto l'altra, pulsanti
grandi. Copia, Condividi e ricerca vengono da elencoCercabile
(resources/js/app.js), l'invio del link dal partial _invio-link. --}}
@if(! $gruppo)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-sm text-gray-600">
Non sei il sorvegliante di un gruppo di servizio attivo.
</div>
@else
<div class="mb-5">
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
{{-- In questa vista solo blocchi php…endphp: la forma in linea, se
viene prima di un blocco, confonde il compilatore di Blade. --}}
@php $daDare = $territori->filter(fn ($t) => ! $t->assegnazioneCorrente)->count(); @endphp
<p class="text-sm text-gray-500 mt-1">
{{ $territori->count() }} {{ $territori->count() === 1 ? 'territorio' : 'territori' }} nel pacchetto ·
{{ $daDare }} da dare · {{ $membri->count() }} {{ $membri->count() === 1 ? 'membro' : 'membri' }}
</p>
</div>
@include('livewire.assegnazioni._invio-link')
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" x-data="elencoCercabile" aria-labelledby="titolo-pacchetto">
<svg aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
<symbol id="tm-i-copia" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></symbol>
<symbol id="tm-i-spunta" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></symbol>
<symbol id="tm-i-condividi" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></symbol>
<symbol id="tm-i-avviso" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></symbol>
</svg>
<div class="px-4 py-3 border-b border-gray-100 flex flex-wrap items-center justify-between gap-2">
<h2 id="titolo-pacchetto" class="text-sm font-semibold text-gray-800">Territori del pacchetto</h2>
@if($territori->count() > 5)
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto"
placeholder="Cerca numero, zona o nome…"
class="w-full sm:w-64 rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
@endif
</div>
<div class="divide-y divide-gray-100">
@forelse($territori as $t)
@php
$a = $t->assegnazioneCorrente;
$pdfUrl = $a?->shortPdfUrl();
$cerca = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii(implode(' ', array_filter([$t->numero, $t->zona?->nome, $a?->proclamatore?->nome_completo]))));
@endphp
<div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
<div class="tm-assegnato-testa">
<span><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif</span>
@if($a)
<span class="tm-assegnato-info"><b>{{ $a->proclamatore?->nome_completo }}</b> · dal {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
@else
<span class="tm-assegnato-info"><b>Da dare</b></span>
@endif
</div>
@if($a?->link_in_sospeso)
<div class="tm-assegnato-sospeso" role="status"><svg aria-hidden="true"><use href="#tm-i-avviso"/></svg> Link ancora da inviare <x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="apriInvio({{ $a->id }})">Invia link</x-btn></div>
@endif
@if($a)
{{-- Assegnato: rientro, link (copia / condividi), stato dell'invio --}}
<div class="tm-assegnato-link" @if($pdfUrl) data-link data-id="{{ $a->id }}" data-messaggio="{{ $a->messaggioLink($pdfUrl) }}" data-titolo="Territorio N° {{ $t->numero }}" @endif>
<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a)">Rientro</x-btn>
@if($pdfUrl)
<code x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="tm-copia" x-on:click="copia($el)" title="Copia link" aria-label="Copia il link della piantina"><svg aria-hidden="true"><use href="#tm-i-copia"/></svg><svg aria-hidden="true"><use href="#tm-i-spunta"/></svg></x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona x-show="condivisibile" x-cloak x-on:click="condividi($el)" title="Condividi il link" aria-label="Condividi il link della piantina"><svg aria-hidden="true"><use href="#tm-i-condividi"/></svg></x-btn>
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" wire:click="toggleLinkSent({{ $a->id }})" aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}">{{ $a->link_sent ? 'Link inviato' : 'Non inviato' }}</x-btn>
@endif
</div>
@elseif($territorioDaAssegnare === $t->id)
{{-- Da dare: a chi e da quando --}}
<div class="mt-3 rounded-lg border border-indigo-100 bg-indigo-50/50 p-3 space-y-3">
<div>
<label for="membro-{{ $t->id }}" class="block text-sm font-medium text-gray-700 mb-1">A chi lo dai?</label>
<x-scelta id="membro-{{ $t->id }}" wire:model="membroId" :opzioni="$opzioniMembri" segnaposto="Cognome o nome" :vuoto="null" />
@error('membroId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="data-{{ $t->id }}" class="block text-sm font-medium text-gray-700 mb-1">Data</label>
<input wire:model="dataAssegnazione" type="date" id="data-{{ $t->id }}" max="{{ now()->format('Y-m-d') }}"
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
@error('dataAssegnazione') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex flex-wrap gap-2">
<x-btn variante="primary" wire:click="assegna">Assegna</x-btn>
<x-btn variante="ghost" wire:click="annullaAssegnazione">Annulla</x-btn>
</div>
</div>
@else
<div class="tm-assegnato-link">
<x-btn variante="primary" dimensione="sm" wire:click="apriAssegnazione({{ $t->id }})">Assegna</x-btn>
@if($t->pdf_path)
<x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('mio-gruppo.pdf', $t)" target="_blank" rel="noopener">Piantina</x-btn>
@endif
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="rimanda({{ $t->id }})"
wire:confirm="Rimandare in reparto il territorio {{ $t->numero }}? Uscirà dal pacchetto del gruppo.">Rimanda in reparto</x-btn>
</div>
@endif
</div>
@empty
<p class="px-4 py-8 text-center text-sm text-gray-500">Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo.</p>
@endforelse
<p x-show="nessuno" x-cloak class="px-4 py-8 text-center text-sm text-gray-400">Nessun risultato per «<span x-text="q"></span>».</p>
</div>
</section>
<section class="mt-5 bg-white rounded-xl shadow-sm border border-gray-200 p-4" aria-labelledby="titolo-membri">
<h2 id="titolo-membri" class="text-sm font-semibold text-gray-800 mb-2">Membri del gruppo</h2>
<ul class="text-sm text-gray-700 space-y-1">
@foreach($membri as $m)
@php $quanti = $territori->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp
<li class="flex justify-between gap-3">
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
<span class="text-gray-500">{{ $quanti }} {{ $quanti === 1 ? 'territorio' : 'territori' }}</span>
</li>
@endforeach
</ul>
</section>
@endif
</div>

View File

@@ -82,7 +82,7 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Da assegnare --}} {{-- Da assegnare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border-color:#bbf7d0"> <div class="px-5 py-4 border-b flex flex-wrap items-center gap-3" style="background:linear-gradient(135deg,#f0fdf4,#dcfce7);border-color:#bbf7d0">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#22c55e"> <div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#22c55e">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
</div> </div>
@@ -90,20 +90,26 @@
<h3 class="text-sm font-semibold" style="color:#166534">Da Assegnare</h3> <h3 class="text-sm font-semibold" style="color:#166534">Da Assegnare</h3>
<p class="text-xs" style="color:#15803d">Prima i prioritari, poi i territori con più tempo in reparto</p> <p class="text-xs" style="color:#15803d">Prima i prioritari, poi i territori con più tempo in reparto</p>
</div> </div>
{{-- "Vedi tutti" in testata, non in fondo all'elenco. --}}
<div class="ml-auto flex flex-none items-center gap-2">
@if($territoriDaAssegnare->count() >= $homeLimit)
<x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('territori.index', ['filterStato' => 'in_reparto'])">Vedi tutti</x-btn>
@endif
@hasanyrole('amministratore|assistente') @hasanyrole('amministratore|assistente')
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto" <x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="downloadPdfDaAssegnare" wire:click="downloadPdfDaAssegnare"
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da assegnare"> title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da assegnare">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</x-btn> </x-btn>
@endhasanyrole @endhasanyrole
</div> </div>
</div>
<ul class="divide-y divide-gray-100"> <ul class="divide-y divide-gray-100">
@forelse($territoriDaAssegnare as $t) @forelse($territoriDaAssegnare as $t)
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors"> <li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div> <div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a> <a data-ritorno href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
@if($t->home_is_prioritario) @if($t->home_is_prioritario)
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
@endif @endif
@@ -116,7 +122,7 @@
</p> </p>
</div> </div>
@can('territori.assign') @can('territori.assign')
<x-btn 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])">Assegna →</x-btn>
@endcan @endcan
</li> </li>
@empty @empty
@@ -126,16 +132,11 @@
</li> </li>
@endforelse @endforelse
</ul> </ul>
@if($territoriDaAssegnare->count() >= $homeLimit)
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
<a href="{{ route('territori.index') }}?filterStato=in_reparto" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti →</a>
</div>
@endif
</div> </div>
{{-- Da rientrare --}} {{-- Da rientrare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden card-hover">
<div class="px-5 py-4 border-b flex items-center gap-3" style="background:linear-gradient(135deg,#fef2f2,#fee2e2);border-color:#fecaca"> <div class="px-5 py-4 border-b flex flex-wrap items-center gap-3" style="background:linear-gradient(135deg,#fef2f2,#fee2e2);border-color:#fecaca">
<div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#ef4444"> <div class="h-8 w-8 rounded-lg flex items-center justify-center" style="background:#ef4444">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div> </div>
@@ -143,22 +144,27 @@
<h3 class="text-sm font-semibold" style="color:#991b1b">Da Rientrare</h3> <h3 class="text-sm font-semibold" style="color:#991b1b">Da Rientrare</h3>
<p class="text-xs" style="color:#b91c1c">Territori assegnati da più tempo</p> <p class="text-xs" style="color:#b91c1c">Territori assegnati da più tempo</p>
</div> </div>
<div class="ml-auto flex flex-none items-center gap-2">
@if($daRientrare->count() >= $homeLimit)
<x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('territori.index', ['filterStato' => 'da_rientrare'])">Vedi tutti</x-btn>
@endif
@hasanyrole('amministratore|assistente') @hasanyrole('amministratore|assistente')
{{-- Scaricare un elenco non è distruttivo: era rosso solo perché {{-- Scaricare un elenco non è distruttivo: era rosso solo perché
lo è la scheda che lo contiene. --}} lo è la scheda che lo contiene. --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto" <x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="downloadPdfDaRientrare" wire:click="downloadPdfDaRientrare"
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da rientrare"> title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da rientrare">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
</x-btn> </x-btn>
@endhasanyrole @endhasanyrole
</div> </div>
</div>
<ul class="divide-y divide-gray-100"> <ul class="divide-y divide-gray-100">
@forelse($daRientrare as $t) @forelse($daRientrare as $t)
@php($assegnazioneCorrente = $t->assegnazioneCorrente) @php($assegnazioneCorrente = $t->assegnazioneCorrente)
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors"> <li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div> <div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a> <a data-ritorno href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
{{ $assegnazioneCorrente?->proclamatore?->nome_completo }} {{ $assegnazioneCorrente?->proclamatore?->nome_completo }}
— <span class="font-medium" style="color:#dc2626">{{ $assegnazioneCorrente?->giorni }} gg</span> — <span class="font-medium" style="color:#dc2626">{{ $assegnazioneCorrente?->giorni }} gg</span>
@@ -166,7 +172,7 @@
</div> </div>
@can('territori.return') @can('territori.return')
@if($assegnazioneCorrente) @if($assegnazioneCorrente)
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id])">Rientra →</x-btn> <x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', ['assegnazione' => $assegnazioneCorrente->id])">Rientra →</x-btn>
@endif @endif
@endcan @endcan
</li> </li>
@@ -177,11 +183,6 @@
</li> </li>
@endforelse @endforelse
</ul> </ul>
@if($daRientrare->count() >= $homeLimit)
<div class="px-5 py-3 border-t text-center" style="background:#fafafa">
<a href="{{ route('territori.index') }}?filterStato=da_rientrare" class="text-xs font-medium" style="color:#4f46e5">Vedi tutti →</a>
</div>
@endif
</div> </div>
</div> </div>
</div> </div>

View File

@@ -148,15 +148,8 @@
<div class="tm-campo"> <div class="tm-campo">
<label for="territorioScelto">Collegata a</label> <label for="territorioScelto">Collegata a</label>
<select id="territorioScelto" wire:model.live="territorioScelto"> <x-scelta id="territorioScelto" wire:model.live="territorioScelto"
<option value="">— nessun territorio —</option> :opzioni="$opzioniTerritori" segnaposto="Numero, zona o tipologia" />
<option value="nuovo">+ Crea un territorio nuovo</option>
@foreach($territori as $territorio)
<option value="{{ $territorio->id }}">
Territorio {{ $territorio->numero }}@if($territorio->trashed()) (nel cestino)@elseif(! $territorio->attivo) (disattivato)@endif
</option>
@endforeach
</select>
@error('territorioScelto') <p class="tm-campo-errore">{{ $message }}</p> @enderror @error('territorioScelto') <p class="tm-campo-errore">{{ $message }}</p> @enderror
@if($mappa->territorioNelCestino()) @if($mappa->territorioNelCestino())
<p class="tm-campo-aiuto tm-campo-aiuto-attesa"> <p class="tm-campo-aiuto tm-campo-aiuto-attesa">

View File

@@ -1,6 +1,9 @@
<div> <div>
{{-- Ogni valore che può cambiare arriva da Privacy::render() (impostazioni,
configurazione, ruoli, conteggi): qui non si scrivono numeri a mano. --}}
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Informativa Privacy</h1> <h1 class="text-2xl font-bold text-gray-900 mb-1">Informativa Privacy</h1>
<p class="text-xs text-gray-500 mb-6">Testo rivisto il {{ $revisione }} · valori aggiornati in tempo reale dalle impostazioni</p>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 md:p-8 space-y-6 text-sm text-gray-700 leading-relaxed"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 md:p-8 space-y-6 text-sm text-gray-700 leading-relaxed">
@@ -13,10 +16,21 @@
<section> <section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Dati trattati</h2> <h2 class="text-lg font-semibold text-gray-900 mb-2">Dati trattati</h2>
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<li><strong>Proclamatori</strong>: nome e cognome — conservati in forma crittografata (AES-256-CBC) nel database.</li> <li><strong>Proclamatori</strong>: solo nome e cognome, cifrati nel database ({{ $cifratura }}), se sono attivi e l'eventuale gruppo di servizio di cui fanno parte. Nessun numero di telefono né indirizzo email.</li>
<li><strong>Territori</strong>: numero, località, zona, tipologia, mappe PDF — nessun dato personale.</li> <li><strong>Territori e piantine</strong>: numero, zona, tipologia, confini, mappe e PDF — nessun dato personale.</li>
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio con date di assegnazione e rientro.</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</strong>: nome, email e ruolo per l'accesso all'applicazione.</li> <li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata, tranne per i sorveglianti di gruppo), password conservata solo come impronta non reversibile, ruolo. L'utenza di un sorvegliante è collegata al suo proclamatore e viene disattivata quando il gruppo si scioglie.</li>
<li><strong>Registro delle attività</strong>: chi ha fatto cosa e quando. I proclamatori vi compaiono solo con un identificativo numerico, mai con il nome.</li>
</ul>
</section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Dati conservati oggi</h2>
<ul class="list-disc list-inside space-y-1">
<li>{{ $conteggi['proclamatori'] }} proclamatori, di cui {{ $conteggi['proclamatoriAttivi'] }} attivi; {{ $conteggi['anonimizzati'] }} nel cestino o anonimizzati.</li>
<li>{{ $conteggi['assegnazioni'] }} assegnazioni nello storico, di cui {{ $conteggi['assegnazioniAperte'] }} in corso.</li>
<li>{{ $conteggi['utenti'] }} utenti dell'applicazione.</li>
<li>{{ $conteggi['attivita'] }} voci nel registro delle attività@if($conteggi['attivitaDal']), dal {{ \Illuminate\Support\Carbon::parse($conteggi['attivitaDal'])->format('d/m/Y') }}@endif.</li>
</ul> </ul>
</section> </section>
@@ -25,6 +39,7 @@
<p>I dati personali sono trattati esclusivamente per:</p> <p>I dati personali sono trattati esclusivamente per:</p>
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<li>Organizzare l'assegnazione e il rientro dei territori di predicazione.</li> <li>Organizzare l'assegnazione e il rientro dei territori di predicazione.</li>
<li>Mandare al proclamatore il link della piantina del territorio assegnato.</li>
<li>Monitorare la copertura territoriale per anno teocratico.</li> <li>Monitorare la copertura territoriale per anno teocratico.</li>
<li>Gestire le campagne speciali di predicazione.</li> <li>Gestire le campagne speciali di predicazione.</li>
</ul> </ul>
@@ -35,24 +50,61 @@
<p>Il trattamento è basato sul legittimo interesse dell'organizzazione religiosa nella gestione delle proprie attività interne (Art. 6(1)(f) GDPR) e sull'appartenenza volontaria dei proclamatori alla congregazione (Art. 9(2)(d) GDPR).</p> <p>Il trattamento è basato sul legittimo interesse dell'organizzazione religiosa nella gestione delle proprie attività interne (Art. 6(1)(f) GDPR) e sull'appartenenza volontaria dei proclamatori alla congregazione (Art. 9(2)(d) GDPR).</p>
</section> </section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Chi accede ai dati</h2>
<p>L'accesso è riservato agli utenti dell'applicazione, ciascuno secondo il proprio ruolo:</p>
<ul class="list-disc list-inside space-y-1">
@foreach($ruoli as $ruolo)
<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>
</section>
<section> <section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Misure di sicurezza</h2> <h2 class="text-lg font-semibold text-gray-900 mb-2">Misure di sicurezza</h2>
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<li><strong>Crittografia</strong>: i dati personali (nome/cognome) sono crittografati con AES-256-CBC prima della memorizzazione.</li> <li><strong>Cifratura</strong>: nome e cognome dei proclamatori sono cifrati con {{ $cifratura }} prima di essere salvati; la chiave non sta nel database.</li>
<li><strong>Controllo accessi</strong>: sistema RBAC con tre ruoli (Admin, Servitore di Territorio, Proclamatore Semplificato).</li> <li><strong>Accesso</strong>: password personale, conferma dell'email, blocco temporaneo dopo troppi tentativi; la sessione scade dopo {{ $durataSessione }} minuti di inattività.</li>
<li><strong>Audit trail</strong>: tutte le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</li> <li><strong>Permessi</strong>: ogni ruolo vede e modifica solo ciò che gli serve (vedi sopra).</li>
<li><strong>Comunicazioni sicure</strong>: l'accesso avviene tramite HTTPS.</li> <li><strong>Registro delle attività</strong>: le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</li>
<li><strong>Hosting locale</strong>: i dati risiedono sull'infrastruttura della congregazione, non su servizi cloud di terze parti.</li> <li><strong>Comunicazioni</strong>: l'applicazione risponde all'indirizzo <code>{{ $indirizzo }}</code>@if($https), su connessione cifrata (HTTPS)@else. <span class="text-red-700 font-medium">Attenzione: l'indirizzo non usa HTTPS, le comunicazioni non sono cifrate.</span>@endif.</li>
<li><strong>Hosting</strong>: i dati risiedono sul server gestito dagli amministratori dell'applicazione, non su servizi cloud di terze parti.</li>
</ul>
</section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Link alla piantina</h2>
<ul class="list-disc list-inside space-y-1">
<li>Il link è personale e funziona solo finché il territorio è assegnato, al massimo per {{ $scadenzaLink }} giorni dall'assegnazione.</li>
<li>Lo manda chi assegna il territorio, copiandolo o con "Condividi" (il menu di condivisione del suo telefono): il destinatario lo sceglie lui tra i propri contatti. L'applicazione non vede quei contatti e non invia nulla ai proclamatori.</li>
<li>Mostra la piantina del territorio, senza dati personali; il PDF {{ $downloadPdf ? 'si può anche scaricare' : 'si può solo consultare, non scaricare' }}.</li>
</ul>
</section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Servizi esterni</h2>
<ul class="list-disc list-inside space-y-1">
<li><strong>Condivisione del link</strong>: il messaggio con il nome del proclamatore e il link passa per l'app scelta da chi assegna il territorio (WhatsApp, Telegram, SMS…), dal suo telefono, non dall'applicazione.</li>
<li><strong>Posta</strong>:
@if($postaAttiva)
le email agli utenti dell'applicazione (conferma dell'indirizzo) partono dal server di posta <code>{{ $hostPosta ?? 'configurato' }}</code>. Ai proclamatori non si mandano email.
@else
nessun server di posta configurato: l'applicazione non invia email. Ai proclamatori non se ne mandano comunque.
@endif
</li>
<li><strong>Mappe</strong>: le piantine usano le mappe di OpenStreetMap; per disegnarle si scaricano tessere dai server di OpenStreetMap, senza inviare dati personali.</li>
</ul> </ul>
</section> </section>
<section> <section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2> <h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<li>I dati dei proclamatori inattivi possono essere anonimizzati su richiesta.</li> <li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile, insieme a nome ed email dell'utenza se è stato sorvegliante di un gruppo.</li>
<li>I territori eliminati sono conservati nel cestino (soft-delete) prima dell'eliminazione definitiva.</li> <li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li>
<li>I log di audit sono conservati per <strong>{{ $auditRetention }} giorni</strong>, configurabili nelle impostazioni.</li> <li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li>
<li>Lo storico assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li> <li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
<li>I backup e gli export XML scaricati dagli amministratori contengono nome e cognome dei proclamatori <strong>in chiaro</strong>: vanno custoditi in un luogo protetto e cancellati quando non servono più.</li>
</ul> </ul>
</section> </section>
@@ -65,7 +117,7 @@
<li><strong>Cancellazione</strong>: richiedere l'anonimizzazione o la rimozione dei propri dati.</li> <li><strong>Cancellazione</strong>: richiedere l'anonimizzazione o la rimozione dei propri dati.</li>
<li><strong>Portabilità</strong>: richiedere l'esportazione dei propri dati.</li> <li><strong>Portabilità</strong>: richiedere l'esportazione dei propri dati.</li>
</ul> </ul>
<p class="mt-2">Le richieste possono essere inoltrate al Servitore di Territorio o all'Amministratore dell'applicazione.</p> <p class="mt-2">Le richieste vanno rivolte agli amministratori dell'applicazione della congregazione {{ $congregazione }}.</p>
</section> </section>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,8 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('proclamatori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1"> <a href="{{ $ritorno ?? route('proclamatori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista {{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1>
</div> </div>
@@ -21,22 +21,6 @@
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div>
<label for="telefono" class="block text-sm font-medium text-gray-700">Telefono</label>
<input wire:model="telefono" type="tel" id="telefono" autocomplete="off" placeholder="333 1234567"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<p class="text-xs text-gray-500 mt-1">Facoltativo. Serve a mandare il link del territorio su WhatsApp. Senza prefisso si considera un numero italiano.</p>
@error('telefono') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input wire:model="email" type="email" id="email" autocomplete="off"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<p class="text-xs text-gray-500 mt-1">Facoltativa.</p>
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"> <input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="attivo" class="text-sm text-gray-700">Attivo</label> <label for="attivo" class="text-sm text-gray-700">Attivo</label>
@@ -44,7 +28,7 @@
<div class="flex items-center gap-3 pt-4"> <div class="flex items-center gap-3 pt-4">
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn> <x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>
<x-btn variante="secondary" :href="route('proclamatori.index')">Annulla</x-btn> <x-btn variante="secondary" :href="$ritorno ?? route('proclamatori.index')">Annulla</x-btn>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -6,7 +6,7 @@
link rosso perso in mezzo ai filtri, in un posto diverso rispetto link rosso perso in mezzo ai filtri, in un posto diverso rispetto
all'altra lista che fa esattamente la stessa cosa. --}} all'altra lista che fa esattamente la stessa cosa. --}}
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<x-btn variante="primary" :href="route('proclamatori.create')"> <x-btn data-ritorno variante="primary" :href="route('proclamatori.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo proclamatore Nuovo proclamatore
</x-btn> </x-btn>
@@ -62,14 +62,13 @@
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td> <td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
<td class="px-4 py-3 text-sm text-right cell-actions"> <td class="px-4 py-3 text-sm text-right cell-actions">
<div class="flex flex-wrap items-center justify-end gap-1"> <div class="flex flex-wrap items-center justify-end gap-1">
<x-whatsapp :proclamatore="$proclamatore" compatto /> <x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" icona
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('proclamatori.show', $proclamatore)" :href="route('proclamatori.show', $proclamatore)"
title="Dettaglio" aria-label="Dettaglio del proclamatore"> title="Dettaglio" aria-label="Dettaglio del 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</x-btn> </x-btn>
@can('settings.manage') @can('settings.manage')
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona <x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('proclamatori.edit', $proclamatore)" :href="route('proclamatori.edit', $proclamatore)"
title="Modifica" aria-label="Modifica il proclamatore"> title="Modifica" aria-label="Modifica il proclamatore">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>

View File

@@ -1,22 +1,14 @@
<div> <div>
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<a href="{{ route('proclamatori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1"> <a href="{{ $ritorno ?? route('proclamatori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista {{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
@if($proclamatore->telefono || $proclamatore->email)
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm">
<x-whatsapp :proclamatore="$proclamatore" />
@if($proclamatore->email)
<a href="mailto:{{ $proclamatore->email }}" class="text-indigo-700 hover:underline break-all">{{ $proclamatore->email }}</a>
@endif
</div>
@endif
</div> </div>
@can('settings.manage') @can('settings.manage')
<x-btn variante="secondary" :href="route('proclamatori.edit', $proclamatore)"> <x-btn data-ritorno variante="secondary" :href="route('proclamatori.edit', $proclamatore)">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica Modifica
</x-btn> </x-btn>
@@ -66,7 +58,7 @@
@foreach($assegnazioni as $assegnazione) @foreach($assegnazioni as $assegnazione)
<tr> <tr>
<td data-label="Territorio" class="px-3 py-2"> <td data-label="Territorio" class="px-3 py-2">
<a href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline"> <a data-ritorno href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">
{{ $assegnazione->territorio?->numero ?? 'N/A' }} {{ $assegnazione->territorio?->numero ?? 'N/A' }}
</a> </a>
</td> </td>

View File

@@ -82,12 +82,12 @@
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none"> class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none">
@endif @endif
<div class="min-w-0"> <div class="min-w-0">
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-medium">N° {{ $a->territorio?->numero }}</a> <a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-medium">N° {{ $a->territorio?->numero }}</a>
<span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span> <span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span>
</div> </div>
</div> </div>
</td> </td>
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td> <td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}@if($a->gruppo)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700" title="Assegnato dal sorvegliante del gruppo di servizio">via gruppo {{ $a->gruppo->nome }}</span>@endif</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td> <td data-label="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2"> <td data-label="Rientrato" class="px-3 py-2">
@if($a->returned_at) @if($a->returned_at)
@@ -188,25 +188,17 @@
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;">
{{-- Territorio --}} {{-- Territorio --}}
<div style="grid-column:1/-1;"> <div style="grid-column:1/-1;">
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Territorio *</label> <label for="form_territorio_id" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Territorio *</label>
<select wire:model="form_territorio_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;"> <x-scelta id="form_territorio_id" wire:model="form_territorio_id"
<option value="">-- seleziona --</option> :opzioni="$opzioniTerritori" segnaposto="Numero, zona o tipologia" />
@foreach($territori as $t)
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }}</option>
@endforeach
</select>
@error('form_territorio_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror @error('form_territorio_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div> </div>
{{-- Proclamatore --}} {{-- Proclamatore --}}
<div style="grid-column:1/-1;"> <div style="grid-column:1/-1;">
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Proclamatore *</label> <label for="form_proclamatore_id" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Proclamatore *</label>
<select wire:model="form_proclamatore_id" style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;font-size:14px;"> <x-scelta id="form_proclamatore_id" wire:model="form_proclamatore_id"
<option value="">-- seleziona --</option> :opzioni="$opzioniProclamatori" segnaposto="Cognome o nome" />
@foreach($proclamatori as $p)
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
@endforeach
</select>
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror @error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
</div> </div>

View File

@@ -129,6 +129,12 @@
</div> </div>
</td> </td>
<td class="px-4 py-2 cell-actions"> <td class="px-4 py-2 cell-actions">
@if($user->proclamatore_id)
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo. --}}
<span class="text-xs text-gray-500">
{{ $user->disattivato_at ? 'Disattivata (gruppo sciolto)' : 'Sorvegliante: si gestisce da Gruppi di servizio' }}
</span>
@else
<div class="flex gap-2"> <div class="flex gap-2">
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn> <x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
@unless($user->hasVerifiedEmail()) @unless($user->hasVerifiedEmail())
@@ -145,6 +151,7 @@
Elimina Elimina
</x-btn> </x-btn>
</div> </div>
@endif
</td> </td>
@endif @endif
</tr> </tr>

View File

@@ -1,8 +1,8 @@
<div> <div>
<div class="mb-6"> <div class="mb-6">
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1"> <a href="{{ $ritorno ?? route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista {{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">{{ $title }}</h1> <h1 class="text-2xl font-bold text-gray-900">{{ $title }}</h1>
</div> </div>
@@ -65,7 +65,7 @@
dell'applicazione. Prima cambiava da una pagina all'altra. --}} dell'applicazione. Prima cambiava da una pagina all'altra. --}}
<div class="flex flex-wrap gap-3 pt-2"> <div class="flex flex-wrap gap-3 pt-2">
<x-btn variante="primary" type="submit">{{ $isEdit ? 'Aggiorna' : 'Crea' }}</x-btn> <x-btn variante="primary" type="submit">{{ $isEdit ? 'Aggiorna' : 'Crea' }}</x-btn>
<x-btn variante="secondary" :href="route('territori.index')">Annulla</x-btn> <x-btn variante="secondary" :href="$ritorno ?? route('territori.index')">Annulla</x-btn>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -6,7 +6,11 @@
</div> </div>
@can('territori.manage') @can('territori.manage')
<div class="mt-4 sm:mt-0 flex gap-2"> <div class="mt-4 sm:mt-0 flex gap-2">
<x-btn variante="primary" :href="route('territori.create')"> {{-- Un territorio nuovo nasce dal designer delle piantine: si
disegna il confine e lo si crea da lì (PiantineEditor, ?nuovo=1).
L'editor è dell'amministratore: chi ha solo territori.manage
resta sul form. --}}
<x-btn data-ritorno variante="primary" :href="auth()->user()->can('settings.manage') ? route('piantine.editor', ['nuovo' => 1]) : route('territori.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo territorio Nuovo territorio
</x-btn> </x-btn>
@@ -42,6 +46,7 @@
<select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500"> <select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Tutti gli stati</option> <option value="">Tutti gli stati</option>
<option value="in_reparto">In reparto</option> <option value="in_reparto">In reparto</option>
<option value="in_gruppo">In un gruppo di servizio</option>
<option value="prioritari">Prioritari</option> <option value="prioritari">Prioritari</option>
<option value="assegnato">Assegnato</option> <option value="assegnato">Assegnato</option>
<option value="da_rientrare">Da rientrare</option> <option value="da_rientrare">Da rientrare</option>
@@ -112,7 +117,7 @@
alt="Thumbnail territorio {{ $territorio->numero }}" alt="Thumbnail territorio {{ $territorio->numero }}"
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none shadow-sm"> class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none shadow-sm">
@endif @endif
<a href="{{ route('territori.show', $territorio) }}" class="hover:text-indigo-600 transition-colors">{{ $territorio->numero }}</a> <a data-ritorno href="{{ route('territori.show', $territorio) }}" class="hover:text-indigo-600 transition-colors">{{ $territorio->numero }}</a>
</div> </div>
</td> </td>
<td data-label="Zona" class="px-4 py-3 text-sm text-gray-600">{{ $territorio->zona?->nome ?? '-' }}</td> <td data-label="Zona" class="px-4 py-3 text-sm text-gray-600">{{ $territorio->zona?->nome ?? '-' }}</td>
@@ -122,12 +127,13 @@
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium <span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
{{ match($stato) { {{ match($stato) {
'in_reparto' => 'bg-green-100 text-green-800', 'in_reparto' => 'bg-green-100 text-green-800',
'in_gruppo' => 'bg-purple-100 text-purple-800',
'assegnato' => 'bg-blue-100 text-blue-800', 'assegnato' => 'bg-blue-100 text-blue-800',
'da_rientrare' => 'bg-red-100 text-red-800', 'da_rientrare' => 'bg-red-100 text-red-800',
'inattivo' => 'bg-gray-100 text-gray-600', 'inattivo' => 'bg-gray-100 text-gray-600',
default => 'bg-gray-100 text-gray-600' default => 'bg-gray-100 text-gray-600'
} }}"> } }}">
{{ str_replace('_', ' ', ucfirst($stato)) }} {{ $territorio->stato_etichetta }}
</span> </span>
@if($territorio->is_prioritario) @if($territorio->is_prioritario)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1"> <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">
@@ -143,7 +149,7 @@
{{-- Primary actions: Assegna / Rientra --}} {{-- Primary actions: Assegna / Rientra --}}
@can('territori.assign') @can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo) @if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $territorio->id])"> <x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $territorio->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegna Assegna
</x-btn> </x-btn>
@@ -154,7 +160,7 @@
{{-- Rientra non cancella niente: era rosso pieno {{-- Rientra non cancella niente: era rosso pieno
quanto un Elimina. È l'azione principale quanto un Elimina. È l'azione principale
della riga, alternativa ad Assegna. --}} della riga, alternativa ad Assegna. --}}
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id])"> <x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Rientra Rientra
</x-btn> </x-btn>
@@ -164,13 +170,13 @@
{{-- Solo icona: l'aria-label non è ridondante col {{-- Solo icona: l'aria-label non è ridondante col
title, è l'unico nome che un lettore di title, è l'unico nome che un lettore di
schermo annuncia in modo affidabile. --}} schermo annuncia in modo affidabile. --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona <x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('territori.show', $territorio)" :href="route('territori.show', $territorio)"
title="Dettaglio" aria-label="Dettaglio del territorio {{ $territorio->numero }}"> title="Dettaglio" aria-label="Dettaglio del territorio {{ $territorio->numero }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</x-btn> </x-btn>
@can('territori.manage') @can('territori.manage')
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona <x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" icona
:href="route('territori.edit', $territorio)" :href="route('territori.edit', $territorio)"
title="Modifica" aria-label="Modifica il territorio {{ $territorio->numero }}"> title="Modifica" aria-label="Modifica il territorio {{ $territorio->numero }}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>

View File

@@ -1,16 +1,16 @@
<div> <div>
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<a href="{{ route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1"> <a href="{{ $ritorno ?? route('territori.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla lista {{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
</a> </a>
<h1 class="text-2xl font-bold text-gray-900">Territorio {{ $territorio->numero }}</h1> <h1 class="text-2xl font-bold text-gray-900">Territorio {{ $territorio->numero }}</h1>
</div> </div>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
@can('territori.assign') @can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo) @if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<x-btn variante="primary" :href="route('assegnazioni.assegna', ['territorioId' => $territorio->id])"> <x-btn data-ritorno variante="primary" :href="route('assegnazioni.assegna', ['territorioId' => $territorio->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegna Assegna
</x-btn> </x-btn>
@@ -21,14 +21,14 @@
{{-- Rientra era rosso pieno quanto un Elimina. Non cancella {{-- Rientra era rosso pieno quanto un Elimina. Non cancella
niente: è l'azione principale di un territorio assegnato, niente: è l'azione principale di un territorio assegnato,
quindi primary come Assegna, che è la sua alternativa. --}} quindi primary come Assegna, che è la sua alternativa. --}}
<x-btn variante="primary" :href="route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id])"> <x-btn data-ritorno variante="primary" :href="route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id])">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Rientra Rientra
</x-btn> </x-btn>
@endif @endif
@endcan @endcan
@can('territori.manage') @can('territori.manage')
<x-btn variante="secondary" :href="route('territori.edit', $territorio)"> <x-btn data-ritorno variante="secondary" :href="route('territori.edit', $territorio)">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Modifica Modifica
</x-btn> </x-btn>
@@ -36,6 +36,16 @@
</div> </div>
</div> </div>
{{-- L'invio del link dopo l'assegnazione non si è concluso --}}
@if($territorio->assegnazioneCorrente?->link_in_sospeso)
<div class="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800" role="status">
<span class="font-semibold">Il link della piantina è ancora da inviare a {{ $territorio->assegnazioneCorrente->proclamatore?->nome_completo }}.</span>
@can('territori.assign')
<x-btn data-ritorno variante="secondary" dimensione="sm" :href="route('assegnazioni.assegna', ['invia' => $territorio->assegnazioneCorrente->id])">Invia link</x-btn>
@endcan
</div>
@endif
{{-- Info card --}} {{-- Info card --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@@ -45,12 +55,13 @@
<span class="inline-flex items-center mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium <span class="inline-flex items-center mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium
{{ match($stato) { {{ match($stato) {
'in_reparto' => 'bg-green-100 text-green-800', 'in_reparto' => 'bg-green-100 text-green-800',
'in_gruppo' => 'bg-purple-100 text-purple-800',
'assegnato' => 'bg-blue-100 text-blue-800', 'assegnato' => 'bg-blue-100 text-blue-800',
'da_rientrare' => 'bg-red-100 text-red-800', 'da_rientrare' => 'bg-red-100 text-red-800',
'inattivo' => 'bg-gray-100 text-gray-600', 'inattivo' => 'bg-gray-100 text-gray-600',
default => 'bg-gray-100 text-gray-600' default => 'bg-gray-100 text-gray-600'
} }}"> } }}">
{{ str_replace('_', ' ', ucfirst($stato)) }} {{ $territorio->stato_etichetta }}
</span> </span>
@if($territorio->is_prioritario) @if($territorio->is_prioritario)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">★ Prioritario</span> <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">★ Prioritario</span>
@@ -99,7 +110,7 @@
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@can('territori.return') @can('territori.return')
<x-btn variante="primary" :href="route('assegnazioni.rientra', ['assegnazione' => $activeAssignment->id])">Rientra</x-btn> <x-btn data-ritorno variante="primary" :href="route('assegnazioni.rientra', ['assegnazione' => $activeAssignment->id])">Rientra</x-btn>
@endcan @endcan
</div> </div>
</div> </div>
@@ -167,7 +178,7 @@
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $assegnazione) @foreach($assegnazioni as $assegnazione)
<tr> <tr>
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}</td> <td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}@if($assegnazione->gruppo)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo->nome }}</span>@endif</td>
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td> <td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td data-label="Rientrato" class="px-3 py-2"> <td data-label="Rientrato" class="px-3 py-2">
@if($assegnazione->returned_at) @if($assegnazione->returned_at)

View File

@@ -24,6 +24,9 @@ use App\Livewire\Proclamatori\ProclamatoreEdit;
use App\Livewire\Proclamatori\ProclamatoreCestino; use App\Livewire\Proclamatori\ProclamatoreCestino;
use App\Livewire\Assegnazioni\Assegna; use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\Rientra; use App\Livewire\Assegnazioni\Rientra;
use App\Livewire\Gruppi\GruppiIndex;
use App\Livewire\Gruppi\GruppoShow;
use App\Livewire\Gruppi\MioGruppo;
use App\Livewire\Campagne\CampagnaIndex; use App\Livewire\Campagne\CampagnaIndex;
use App\Livewire\Campagne\CampagnaCreate; use App\Livewire\Campagne\CampagnaCreate;
use App\Livewire\Campagne\CampagnaShow; use App\Livewire\Campagne\CampagnaShow;
@@ -163,10 +166,20 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware('permission:territori.assign')->group(function () { Route::middleware('permission:territori.assign')->group(function () {
Route::get('assegnazioni/assegna/{territorioId?}', Assegna::class)->name('assegnazioni.assegna'); Route::get('assegnazioni/assegna/{territorioId?}', Assegna::class)->name('assegnazioni.assegna');
}); });
Route::middleware('permission:territori.return')->group(function () { // Il rientro lo registra anche il sorvegliante di gruppo, ma solo per il
// suo gruppo: il controllo fine è in Rientra::autorizza().
Route::middleware('permission:territori.return|gruppo.gestisci')->group(function () {
Route::get('assegnazioni/{assegnazione}/rientra', Rientra::class)->name('assegnazioni.rientra'); Route::get('assegnazioni/{assegnazione}/rientra', Rientra::class)->name('assegnazioni.rientra');
}); });
// Il mio gruppo: la sola pagina del sorvegliante di gruppo (vedi anche
// il middleware LimitaSorvegliante).
Route::middleware('permission:gruppo.gestisci')->group(function () {
Route::get('mio-gruppo', MioGruppo::class)->name('mio-gruppo');
Route::get('mio-gruppo/territori/{territorio}/pdf', [TerritorioFileController::class, 'pdfGruppo'])
->name('mio-gruppo.pdf');
});
// Campagne — visualizzazione per chi ha campagne.manage; sola lettura per i // Campagne — visualizzazione per chi ha campagne.manage; sola lettura per i
// non-admin. // non-admin.
Route::middleware('permission:campagne.manage')->group(function () { Route::middleware('permission:campagne.manage')->group(function () {
@@ -200,6 +213,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware('permission:settings.manage')->group(function () { Route::middleware('permission:settings.manage')->group(function () {
Route::get('impostazioni', SettingsEdit::class)->name('settings.edit'); Route::get('impostazioni', SettingsEdit::class)->name('settings.edit');
Route::get('utenti', UsersIndex::class)->name('users.index'); Route::get('utenti', UsersIndex::class)->name('users.index');
Route::get('gruppi', GruppiIndex::class)->name('gruppi.index');
Route::get('gruppi/{gruppo}', GruppoShow::class)->name('gruppi.show');
Route::get('zone', ZoneIndex::class)->name('zone.index'); Route::get('zone', ZoneIndex::class)->name('zone.index');
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index'); Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange'); Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');

View File

@@ -0,0 +1,109 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\ElencoAssegnati;
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 App\Models\Zona;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
/**
* La pagina Assegna era lenta: ~400 KB rimandati per intero a ogni
* interazione. Questi test tengono fermi i rimedi: elenco in un componente
* lazy, ricerca e anteprima nel browser, righe leggere.
*/
class AssegnaPrestazioniTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Storage::fake('public');
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
}
/** $n assegnazioni aperte, ognuna con PDF. */
private function assegnazioni(int $n): void
{
$zona = Zona::create(['nome' => 'Montepellegrino', 'attivo' => true]);
for ($i = 1; $i <= $n; $i++) {
Storage::disk('public')->put("territori-pdf/t{$i}.pdf", '%PDF-1.4');
$t = Territorio::create(['numero' => (string) $i, 'attivo' => true, 'zona_id' => $zona->id, 'pdf_path' => "territori-pdf/t{$i}.pdf"]);
$p = Proclamatore::create(['nome' => "Nicolò{$i}", 'cognome' => 'Rossi', 'attivo' => true]);
Assegnazione::create([
'territorio_id' => $t->id, 'proclamatore_id' => $p->id, 'anno_teocratico_id' => AnnoTeocratico::sole()->id,
'assigned_at' => now()->subDays($i), 'created_by' => $this->admin->id,
]);
}
}
public function test_la_pagina_non_contiene_piu_l_elenco_che_arriva_dopo(): void
{
$this->assegnazioni(5);
$this->actingAs($this->admin)
->get(route('assegnazioni.assegna'))
->assertOk()
->assertSee('Caricamento dei territori assegnati')
->assertDontSee('Territori Assegnati (5)');
}
public function test_scegliere_un_territorio_non_chiede_niente_al_server(): void
{
$html = $this->actingAs($this->admin)->get(route('assegnazioni.assegna'))->getContent();
// wire:model senza .live: il valore parte solo con "Assegna".
$this->assertStringContainsString('wire:model="territorio_id"', $html);
$this->assertStringNotContainsString('wire:model.live="territorio_id"', $html);
}
public function test_le_opzioni_portano_la_miniatura_per_l_anteprima(): void
{
$t = Territorio::create(['numero' => '7', 'attivo' => true, 'thumbnail_path' => 'territori-thumbnails/t7.png']);
$opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniTerritori');
$this->assertSame($t->miniaturaUrl(), $opzioni[0]['miniatura']);
}
public function test_l_elenco_si_cerca_nel_browser_senza_accenti(): void
{
$this->assegnazioni(1);
Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class)
->assertSee('Territori Assegnati (1)')
->assertSeeHtml('data-cerca="1 montepellegrino rossi nicolo1"')
->assertDontSeeHtml('wire:model.live.debounce.300ms="linkSearch"');
}
public function test_le_righe_dell_elenco_restano_leggere(): void
{
$this->assegnazioni(30);
$html = Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class)->html();
// Prima ~3,8 KB a riga. Le icone sono simboli richiamati con <use>.
$this->assertLessThan(2600, strlen($html) / 30, 'Ogni riga dell\'elenco deve restare sotto i 2,6 KB');
$this->assertSame(1, substr_count($html, '<symbol id="tm-i-copia"'));
$this->assertGreaterThanOrEqual(30, substr_count($html, 'href="#tm-i-copia"'));
}
}

View File

@@ -1,210 +0,0 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Proclamatori\ProclamatoreCreate;
use App\Livewire\Settings\XmlExchange;
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 Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* Email e telefono dei proclamatori (facoltativi), WhatsApp, e il riquadro
* con il link da mandare subito dopo l'assegnazione.
*/
class ContattiProclamatoriTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Storage::fake('public');
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
}
// ─── Contatti ───────────────────────────────────────────────
public function test_email_e_telefono_sono_facoltativi_e_cifrati(): void
{
Livewire::actingAs($this->admin)->test(ProclamatoreCreate::class)
->set('nome', 'Mario')->set('cognome', 'Rossi')
->call('save')
->assertHasNoErrors();
Livewire::actingAs($this->admin)->test(ProclamatoreCreate::class)
->set('nome', 'Anna')->set('cognome', 'Verdi')
->set('email', 'anna@example.test')
->set('telefono', '333 123 4567')
->call('save')
->assertHasNoErrors();
$anna = Proclamatore::all()->first(fn ($p) => $p->nome === 'Anna');
$this->assertSame('anna@example.test', $anna->email);
$this->assertSame('333 123 4567', $anna->telefono);
$riga = DB::table('proclamatori')->where('id', $anna->id)->first();
$this->assertStringNotContainsString('anna@', $riga->email);
$this->assertStringNotContainsString('333', $riga->telefono);
}
public function test_un_email_sbagliata_si_segnala_ma_si_puo_lasciare_vuota(): void
{
Livewire::actingAs($this->admin)->test(ProclamatoreCreate::class)
->set('nome', 'Mario')->set('cognome', 'Rossi')
->set('email', 'non-una-email')
->call('save')
->assertHasErrors('email');
$this->assertSame(0, Proclamatore::count());
}
#[DataProvider('numeri')]
public function test_il_numero_diventa_internazionale_per_whatsapp(string $scritto, ?string $atteso): void
{
$p = new Proclamatore(['nome' => 'A', 'cognome' => 'B', 'telefono' => $scritto]);
$this->assertSame($atteso, $p->telefonoInternazionale());
$this->assertSame($atteso ? 'https://wa.me/' . $atteso : null, $p->whatsappUrl());
}
public static function numeri(): array
{
return [
'cellulare italiano' => ['333 123 4567', '393331234567'],
'con +39' => ['+39 333 1234567', '393331234567'],
'con 0039' => ['0039 3331234567', '393331234567'],
'39 senza +' => ['393331234567', '393331234567'],
'cellulare che inizia con 39' => ['392 1234567', '393921234567'],
'fisso italiano' => ['06 12345678', '390612345678'],
'estero' => ['+44 7911 123456', '447911123456'],
'troppo corto' => ['123', null],
'vuoto' => ['', null],
];
}
public function test_il_messaggio_whatsapp_contiene_il_link(): void
{
$p = new Proclamatore(['nome' => 'A', 'cognome' => 'B', 'telefono' => '3331234567']);
$this->assertSame(
'https://wa.me/393331234567?text=' . rawurlencode('Ecco: https://x.test/p/AB12'),
$p->whatsappUrl('Ecco: https://x.test/p/AB12'),
);
}
public function test_anonimizzare_cancella_anche_i_contatti(): void
{
$p = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'email' => 'm@example.test', 'telefono' => '3331234567']);
$p->anonimizza();
$p = Proclamatore::withTrashed()->find($p->id);
$this->assertNull($p->email);
$this->assertNull($p->telefono);
}
public function test_l_xml_porta_con_se_i_contatti(): void
{
$this->actingAs($this->admin);
Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'email' => 'm@example.test', 'telefono' => '3331234567']);
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$p = Proclamatore::sole();
$this->assertSame('m@example.test', $p->email);
$this->assertSame('3331234567', $p->telefono);
}
// ─── Dopo l'assegnazione ────────────────────────────────────
private function assegna(Proclamatore $proclamatore, Territorio $territorio)
{
return Livewire::actingAs($this->admin)->test(Assegna::class)
->set('territorio_id', $territorio->id)
->set('proclamatore_id', $proclamatore->id)
->set('assigned_at', now()->format('Y-m-d'))
->call('save');
}
private function territorioConPdf(string $numero = '7'): Territorio
{
Storage::disk('public')->put("territori-pdf/t{$numero}.pdf", '%PDF-1.4');
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => "territori-pdf/t{$numero}.pdf"]);
}
public function test_dopo_l_assegnazione_compare_il_link_da_mandare_su_whatsapp(): void
{
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'telefono' => '333 1234567']);
$territorio = $this->territorioConPdf();
$this->assegna($mario, $territorio)
->assertHasNoErrors()
->assertRedirect(route('assegnazioni.assegna'));
$assegnazione = Assegnazione::sole();
$this->assertSame([$assegnazione->id], session(Assegna::DA_INVIARE));
$link = $assegnazione->fresh()->shortPdfUrl();
Livewire::actingAs($this->admin)->test(Assegna::class)
->assertSee('Territorio N° 7 assegnato a Rossi Mario')
->assertSee($link)
->assertSee('https://wa.me/393331234567?text=', false)
->assertSee(rawurlencode($link), false)
->assertSee('Il link è stato inviato?');
}
public function test_la_scelta_registra_l_invio_e_toglie_il_riquadro(): void
{
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'telefono' => '3331234567']);
$anna = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => true]);
$this->assegna($mario, $this->territorioConPdf('7'));
$this->assegna($anna, $this->territorioConPdf('8'));
[$prima, $seconda] = Assegnazione::orderBy('id')->get()->all();
Livewire::actingAs($this->admin)->test(Assegna::class)
->assertSee('Territorio N° 7 assegnato')
->assertSee('Territorio N° 8 assegnato')
->call('esitoInvio', $prima->id, true)
->assertDontSee('Territorio N° 7 assegnato')
->assertSee('Territorio N° 8 assegnato')
->call('esitoInvio', $seconda->id, false)
->assertDontSee('Territorio N° 8 assegnato');
$this->assertTrue((bool) $prima->fresh()->link_sent);
$this->assertFalse((bool) $seconda->fresh()->link_sent);
$this->assertSame([], session(Assegna::DA_INVIARE));
}
public function test_senza_telefono_il_riquadro_lo_dice(): void
{
$anna = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => true]);
$this->assegna($anna, $this->territorioConPdf());
Livewire::actingAs($this->admin)->test(Assegna::class)
->assertSee('non ha un numero di telefono')
->assertDontSee('wa.me', false);
}
}

View File

@@ -0,0 +1,459 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\Rientra;
use App\Livewire\Gruppi\GruppiIndex;
use App\Livewire\Gruppi\GruppoShow;
use App\Livewire\Gruppi\MioGruppo;
use App\Livewire\Settings\UsersIndex;
use App\Livewire\Settings\XmlExchange;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\GruppoServizio;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use App\Services\GruppiServizio;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Gruppi di servizio (19 ottobre 2026): un sorvegliante con un'utenza
* limitata al proprio gruppo distribuisce ai membri i territori del pacchetto.
*/
class GruppiServizioTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Storage::fake('public');
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
$this->actingAs($this->admin);
}
private function proclamatore(string $cognome, string $nome = 'Test'): Proclamatore
{
return Proclamatore::create(['nome' => $nome, 'cognome' => $cognome, 'attivo' => true]);
}
private function territorio(string $numero, bool $pdf = true): Territorio
{
if ($pdf) {
Storage::disk('public')->put("territori-pdf/t{$numero}.pdf", '%PDF-1.4');
}
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => $pdf ? "territori-pdf/t{$numero}.pdf" : null]);
}
/** Gruppo "Zisa": sorvegliante Rossi, membri Verdi e Bianchi, pacchetto 12, 14, 35. */
private function gruppo(): array
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$verdi = $this->proclamatore('Verdi', 'Anna');
$bianchi = $this->proclamatore('Bianchi', 'Luca');
[$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, 'mario@example.test', [$verdi->id, $bianchi->id]);
$territori = collect(['12', '14', '35'])->map(fn ($n) => $this->territorio($n));
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all());
$territori = $territori->map->fresh();
$sorvegliante = User::where('email', 'mario@example.test')->sole();
// Il cambio password al primo accesso ha i suoi test: qui si lavora.
// E l'email non confermata, come nella realtà (TestCase le conferma).
$sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save();
return compact('gruppo', 'password', 'rossi', 'verdi', 'bianchi', 'territori', 'sorvegliante');
}
// ─── Creazione ──────────────────────────────────────────────
public function test_creare_un_gruppo_da_un_utenza_limitata_al_sorvegliante(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$pagina = Livewire::test(GruppiIndex::class)
->set('nome', 'Zisa')
->set('sorveglianteId', $rossi->id)
->set('email', 'mario@example.test')
->call('crea')
->assertHasNoErrors();
$gruppo = GruppoServizio::sole();
$utente = User::where('email', 'mario@example.test')->sole();
$this->assertSame($rossi->id, $gruppo->sorvegliante_id);
$this->assertSame($gruppo->id, $rossi->fresh()->gruppo_servizio_id, 'Il sorvegliante è anche membro');
$this->assertSame($rossi->id, $utente->proclamatore_id);
$this->assertTrue($utente->hasRole('sorvegliante'));
$this->assertTrue($utente->password_change_required);
// Nessuna conferma dell'email per il sorvegliante (TestCase conferma
// gli utenti creati: qui si toglie).
$utente->forceFill(['email_verified_at' => null])->save();
$this->assertTrue($utente->fresh()->hasVerifiedEmail());
// La password temporanea si vede una volta, ed è quella vera.
$password = $pagina->get('passwordTemporanea');
$this->assertNotEmpty($password);
$pagina->assertSee($password);
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
}
public function test_un_proclamatore_sta_in_un_solo_gruppo(): void
{
['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo();
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'), 'neri@example.test');
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiMembri($noce, [$verdi->id]);
}
public function test_il_ruolo_sorvegliante_non_si_da_dalla_pagina_utenti(): void
{
['sorvegliante' => $sorvegliante] = $this->gruppo();
$utenti = Livewire::test(UsersIndex::class);
$this->assertNotContains('sorvegliante', $utenti->get('availableRoles'));
$utenti->call('startEdit', $sorvegliante->id)->assertSet('editingUserId', null);
$utenti->call('deleteUser', $sorvegliante->id);
$this->assertNotNull($sorvegliante->fresh());
}
// ─── Pacchetto ──────────────────────────────────────────────
public function test_il_pacchetto_non_e_un_assegnazione_e_non_e_in_reparto(): void
{
['territori' => $territori] = $this->gruppo();
$libero = $this->territorio('99');
$this->assertSame(0, Assegnazione::count(), 'Il pacchetto non crea assegnazioni: medie e statistiche non cambiano');
$this->assertSame([$libero->id], Territorio::inReparto()->pluck('id')->all());
$this->assertSame('in_gruppo', $territori[0]->fresh()->stato);
$this->assertSame('In gruppo Zisa', $territori[0]->fresh()->stato_etichetta);
}
public function test_dalla_pagina_assegna_un_territorio_del_pacchetto_non_si_assegna(): void
{
['territori' => $territori] = $this->gruppo();
Livewire::test(Assegna::class)
->set('territorio_id', $territori[0]->id)
->set('proclamatore_id', $this->proclamatore('Neri')->id)
->call('save')
->assertHasErrors('territorio_id');
$this->assertSame(0, Assegnazione::count());
}
public function test_un_territorio_assegnato_non_entra_nel_pacchetto(): void
{
['gruppo' => $gruppo] = $this->gruppo();
$t = $this->territorio('50');
Assegnazione::create([
'territorio_id' => $t->id, 'proclamatore_id' => $this->proclamatore('Neri')->id,
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
]);
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$t->id]);
}
// ─── Accesso del sorvegliante ───────────────────────────────
public function test_il_sorvegliante_vede_solo_il_suo_gruppo_e_il_profilo(): void
{
['sorvegliante' => $sorvegliante] = $this->gruppo();
$this->flushSession();
$this->actingAs($sorvegliante);
$this->get(route('dashboard'))->assertRedirect(route('mio-gruppo'));
$this->get(route('mio-gruppo'))->assertOk()->assertSee('Gruppo Zisa')->assertSee('N° 12');
$this->get(route('profile'))->assertOk();
foreach (['territori.index', 'proclamatori.index', 'registro.index', 'campagne.index', 'assegnazioni.assegna', 'users.index', 'gruppi.index', 'privacy'] as $rotta) {
$this->get(route($rotta))->assertForbidden();
}
}
public function test_l_amministratore_non_ha_la_voce_il_mio_gruppo(): void
{
$this->gruppo();
// Passa ogni @can (Gate::before), ma un gruppo suo non ce l'ha.
$this->get(route('dashboard'))->assertOk()->assertDontSee('Il mio gruppo')->assertSee('Gruppi di servizio');
}
public function test_il_sorvegliante_entra_senza_conferma_dell_email_e_va_al_suo_gruppo(): void
{
$this->gruppo();
$this->flushSession();
auth()->logout();
$password = 'una-password-nuova-1';
User::where('email', 'mario@example.test')->sole()->forceFill(['password' => $password])->save();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password])
->assertRedirect(route('mio-gruppo'));
}
// ─── Il sorvegliante al lavoro ──────────────────────────────
public function test_il_sorvegliante_assegna_a_un_membro_e_nasce_un_assegnazione_normale(): void
{
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori, 'gruppo' => $gruppo] = $this->gruppo();
$this->flushSession();
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $territori[0]->id)
->set('membroId', $verdi->id)
->call('assegna')
->assertHasNoErrors()
// Come dalla pagina Assegna: si apre l'invio del link.
->assertSee('Hai inviato il link?');
$a = Assegnazione::sole();
$this->assertSame($verdi->id, $a->proclamatore_id);
$this->assertSame($gruppo->id, $a->gruppo_servizio_id, 'Tracciato che viene dal gruppo');
$this->assertTrue($a->link_da_inviare);
$this->assertSame($gruppo->id, $territori[0]->fresh()->gruppo_servizio_id, 'Resta nel pacchetto finché non rientra');
$this->assertSame('assegnato', $territori[0]->fresh()->stato);
}
public function test_il_sorvegliante_puo_tenere_un_territorio_per_se(): void
{
['sorvegliante' => $s, 'rossi' => $rossi, 'territori' => $territori] = $this->gruppo();
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $territori[1]->id)
->set('membroId', $rossi->id)
->call('assegna')
->assertHasNoErrors();
$this->assertSame($rossi->id, Assegnazione::sole()->proclamatore_id);
}
public function test_solo_ai_membri_e_solo_i_territori_del_pacchetto(): void
{
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
$estraneo = $this->proclamatore('Neri');
$fuori = $this->territorio('77');
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $territori[0]->id)
->set('membroId', $estraneo->id)
->call('assegna')
->assertHasErrors('membroId');
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $fuori->id)
->assertForbidden();
$this->assertSame(0, Assegnazione::count());
}
public function test_il_rientro_lo_registra_il_sorvegliante_e_il_territorio_torna_in_reparto(): void
{
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori, 'gruppo' => $gruppo] = $this->gruppo();
$a = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now()->subDays(3));
$this->flushSession();
$this->actingAs($s)->get(route('assegnazioni.rientra', $a))->assertOk();
Livewire::actingAs($s)->test(Rientra::class, ['assegnazione' => $a])
->call('save')
->assertRedirect(route('mio-gruppo'));
$this->assertNotNull($a->fresh()->returned_at);
$this->assertNull($territori[0]->fresh()->gruppo_servizio_id, 'Esce dal pacchetto');
$this->assertSame('in_reparto', $territori[0]->fresh()->stato);
}
public function test_il_sorvegliante_non_registra_rientri_di_altri(): void
{
['sorvegliante' => $s] = $this->gruppo();
$altro = Assegnazione::create([
'territorio_id' => $this->territorio('80')->id, 'proclamatore_id' => $this->proclamatore('Neri')->id,
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
]);
$this->flushSession();
$this->actingAs($s)->get(route('assegnazioni.rientra', $altro))->assertForbidden();
}
public function test_il_sorvegliante_rimanda_in_reparto_quelli_non_dati(): void
{
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
Livewire::actingAs($s)->test(MioGruppo::class)->call('rimanda', $territori[2]->id);
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id);
$this->assertSame('in_reparto', $territori[2]->fresh()->stato);
}
public function test_la_piantina_del_pacchetto_si_apre_solo_per_il_proprio_gruppo(): void
{
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
$fuori = $this->territorio('88');
$this->flushSession();
$this->actingAs($s)->get(route('mio-gruppo.pdf', $territori[0]))->assertOk();
$this->actingAs($s)->get(route('mio-gruppo.pdf', $fuori))->assertForbidden();
}
// ─── Scioglimento ───────────────────────────────────────────
public function test_togliere_il_ruolo_scioglie_il_gruppo(): void
{
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
$data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])->call('sciogli');
$gruppo->refresh();
$this->assertNotNull($gruppo->sciolto_at);
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione normale.
$this->assertNull($territori[1]->fresh()->gruppo_servizio_id);
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id);
$this->assertNull($data->fresh()->returned_at);
$this->assertSame($gruppo->id, $data->fresh()->gruppo_servizio_id, 'Lo storico continua a dirlo');
// Membri liberi, utenza disattivata e senza ruolo.
$this->assertSame(0, Proclamatore::where('gruppo_servizio_id', $gruppo->id)->count());
$this->assertTrue($s->fresh()->isDisattivato());
$this->assertFalse($s->fresh()->hasRole('sorvegliante'));
// Per ripartire serve un gruppo nuovo.
$this->expectException(InvalidArgumentException::class);
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$territori[1]->id]);
}
public function test_un_utenza_disattivata_non_entra_e_viene_buttata_fuori(): void
{
['gruppo' => $gruppo, 'sorvegliante' => $s] = $this->gruppo();
$password = 'una-password-nuova-1';
$s->forceFill(['password' => $password])->save();
app(GruppiServizio::class)->sciogli($gruppo);
$this->flushSession();
auth()->logout();
$this->post(route('login.store'), ['email' => 'mario@example.test', 'password' => $password])
->assertSessionHasErrors('email');
$this->assertGuest();
$this->actingAs($s->fresh())->get(route('profile'))->assertRedirect(route('login'));
}
public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo_con_la_stessa_utenza(): void
{
['gruppo' => $vecchio, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
app(GruppiServizio::class)->sciogli($vecchio);
$pagina = Livewire::test(GruppiIndex::class);
$this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati');
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa 2', $rossi->fresh(), 'mario.nuovo@example.test');
$utente = $s->fresh();
$this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza');
$this->assertFalse($utente->isDisattivato());
$this->assertTrue($utente->hasRole('sorvegliante'));
$this->assertSame('mario.nuovo@example.test', $utente->email);
$this->assertTrue($utente->password_change_required);
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
$this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id);
}
// ─── Proclamatori: cestino, anonimizzazione, registro ───────
public function test_il_sorvegliante_di_un_gruppo_attivo_non_si_cestina_ne_si_anonimizza(): void
{
['rossi' => $rossi, 'verdi' => $verdi] = $this->gruppo();
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
->call('deleteProclamatore', $rossi->id)
->call('anonimizza', $rossi->id);
$this->assertFalse($rossi->fresh()->trashed());
$this->assertSame('Rossi', $rossi->fresh()->cognome);
// Un membro qualsiasi sì, e lascia il gruppo.
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('deleteProclamatore', $verdi->id);
$this->assertTrue($verdi->fresh()->trashed());
$this->assertNull($verdi->fresh()->gruppo_servizio_id);
}
public function test_anonimizzare_un_ex_sorvegliante_anonimizza_anche_la_sua_utenza(): void
{
['gruppo' => $gruppo, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
app(GruppiServizio::class)->sciogli($gruppo);
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id);
$utente = $s->fresh();
$this->assertSame('Anonimo', $utente->name);
$this->assertStringEndsWith('@anonimo.invalid', $utente->email);
$this->assertTrue($utente->isDisattivato());
$this->assertCount(0, $utente->roles);
}
public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void
{
['sorvegliante' => $s] = $this->gruppo();
$s->update(['email' => 'mario.rossi@example.test']);
$voci = \Spatie\Activitylog\Models\Activity::where('subject_type', User::class)->where('subject_id', $s->id)->get();
$this->assertNotEmpty($voci);
foreach ($voci as $voce) {
$json = json_encode($voce->properties);
$this->assertStringNotContainsString('Rossi', $json);
$this->assertStringNotContainsString('mario', $json);
}
}
// ─── Backup e azzeramento ───────────────────────────────────
public function test_l_azzeramento_toglie_anche_gruppi_e_utenza_del_sorvegliante(): void
{
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori, 'sorvegliante' => $s] = $this->gruppo();
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
$stato = app(\App\Services\BackupOperationState::class);
$stato->initialize('azzeramento-gruppi', 'azzeramento', 'In attesa');
(new \App\Jobs\ResetApplication('azzeramento-gruppi', $this->admin->id))->handle($stato, app(\App\Services\BackupArchive::class));
$this->assertSame('completed', $stato->get('azzeramento-gruppi')['status'] ?? null, json_encode($stato->get('azzeramento-gruppi')));
$this->assertSame(0, GruppoServizio::count());
$this->assertNull(User::find($s->id));
}
public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void
{
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$g = GruppoServizio::sole();
$this->assertSame('Zisa', $g->nome);
$this->assertSame('Rossi Mario', $g->sorvegliante->nome_completo);
$this->assertEqualsCanonicalizing(['Rossi Mario', 'Verdi Anna', 'Bianchi Luca'], $g->membri->map->nome_completo->all());
$this->assertEqualsCanonicalizing(['12', '14', '35'], $g->territori->pluck('numero')->all());
$this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id);
$this->assertSame($g->sorvegliante_id, User::where('email', 'mario@example.test')->sole()->proclamatore_id);
}
}

View File

@@ -0,0 +1,263 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\ElencoAssegnati;
use App\Livewire\Proclamatori\ProclamatoreCreate;
use App\Livewire\Settings\XmlExchange;
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 Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
/**
* L'invio del link della piantina dopo l'assegnazione: copia link o
* "Condividi" (menu del telefono), con il destinatario scelto da chi usa
* l'app. I proclamatori non hanno più email né telefono (19 ottobre 2026).
*/
class InvioLinkTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Storage::fake('public');
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
}
private function assegna(Proclamatore $proclamatore, Territorio $territorio)
{
return Livewire::actingAs($this->admin)->test(Assegna::class)
->set('territorio_id', $territorio->id)
->set('proclamatore_id', $proclamatore->id)
->set('assigned_at', now()->format('Y-m-d'))
->call('save');
}
private function territorioConPdf(string $numero = '7'): Territorio
{
Storage::disk('public')->put("territori-pdf/t{$numero}.pdf", '%PDF-1.4');
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => "territori-pdf/t{$numero}.pdf"]);
}
private function mario(): Proclamatore
{
return Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
}
// ─── Niente contatti nei proclamatori ───────────────────────
public function test_i_proclamatori_non_hanno_ne_email_ne_telefono(): void
{
$this->assertFalse(Schema::hasColumn('proclamatori', 'email'));
$this->assertFalse(Schema::hasColumn('proclamatori', 'telefono'));
$this->actingAs($this->admin)->get(route('proclamatori.create'))
->assertOk()
->assertDontSee('id="telefono"', false)
->assertDontSee('id="email"', false);
Livewire::actingAs($this->admin)->test(ProclamatoreCreate::class)
->set('nome', 'Anna')->set('cognome', 'Verdi')
->call('save')
->assertHasNoErrors();
$this->assertSame(['Verdi Anna'], Proclamatore::all()->map->nome_completo->all());
}
public function test_un_xml_vecchio_con_email_e_telefono_si_importa_ignorandoli(): void
{
$this->actingAs($this->admin);
$this->mario();
$xml = (new XmlExchange())->currentXmlBackup();
$vecchio = str_replace('<cognome>', '<email>m@example.test</email><telefono>3331234567</telefono><cognome>', $xml);
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($vecchio));
$this->assertSame('Rossi Mario', Proclamatore::sole()->nome_completo);
$this->assertStringNotContainsString('<telefono>', (new XmlExchange())->currentXmlBackup());
}
// ─── Dopo l'assegnazione ────────────────────────────────────
public function test_dopo_l_assegnazione_si_sceglie_tra_copia_link_e_condividi(): void
{
$pagina = $this->assegna($this->mario(), $this->territorioConPdf())
->assertHasNoErrors()
->assertNoRedirect();
$assegnazione = Assegnazione::sole();
$this->assertTrue($assegnazione->link_da_inviare);
$this->assertFalse($assegnazione->link_sent);
$link = $assegnazione->shortPdfUrl();
$pagina->assertSet('invioId', $assegnazione->id)
->assertSee('Territorio N° 7 assegnato a Rossi Mario')
->assertSee('Come vuoi mandare il link della piantina?')
->assertSee($link)
->assertSee('Copia link')
->assertSee('Condividi…')
// Il messaggio per "Condividi", con il link.
->assertSeeHtml('data-messaggio="Ciao Mario, ti è stato assegnato il territorio N° 7. Ecco la piantina: ' . e($link) . '"')
->assertDontSee('wa.me', false)
->assertDontSee('WhatsApp')
->assertSee('Hai inviato il link?');
}
public function test_si_dice_inviato_solo_dopo_aver_scelto_un_canale(): void
{
$mario = $this->mario();
$this->assegna($mario, $this->territorioConPdf())
->call('concludiInvio', true)
->assertNoRedirect()
->assertSet('erroreInvio', 'Scegli prima come mandare il link.')
->call('canale', 'condividi')
->call('concludiInvio', true)
->assertRedirect(route('assegnazioni.assegna'));
$assegnazione = Assegnazione::sole();
$this->assertTrue($assegnazione->link_sent);
$this->assertFalse($assegnazione->link_da_inviare);
$this->assertSame('condividi', $assegnazione->link_canale);
$log = Activity::where('description', 'link_inviato')->sole();
$this->assertSame('condividi', $log->properties['canale']);
$this->assertSame($mario->id, $log->properties['proclamatore_id']);
$this->assertStringNotContainsString('Rossi', json_encode($log->properties));
}
public function test_whatsapp_ed_email_non_sono_piu_canali(): void
{
$this->assegna($this->mario(), $this->territorioConPdf())
->call('canale', 'whatsapp')
->assertSet('canaleUsato', null)
->call('canale', 'email')
->assertSet('canaleUsato', null)
->call('canale', 'copia')
->assertSet('canaleUsato', 'copia');
$this->assertSame(['copia', 'condividi'], Assegnazione::CANALI_ATTIVI);
}
public function test_se_non_si_conclude_l_invio_l_assegnazione_lo_segnala(): void
{
$this->assegna($this->mario(), $this->territorioConPdf());
$assegnazione = Assegnazione::sole();
// La pagina viene chiusa senza rispondere: l'invio è perso, l'avviso resta
// nell'elenco, e "Invia link" chiede alla pagina di riaprirlo.
Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class)
->assertSee('Link ancora da inviare')
->assertSeeHtml("\$dispatch('apri-invio', { assegnazioneId: {$assegnazione->id} })");
Livewire::actingAs($this->admin)->test(Assegna::class)
->assertSet('invioId', null)
->dispatch('apri-invio', assegnazioneId: $assegnazione->id)
->assertSee('Hai inviato il link?')
->call('concludiInvio', false)
->assertRedirect(route('assegnazioni.assegna'));
// "No, non ancora": la procedura è finita, ma il link resta da inviare.
$this->assertTrue($assegnazione->fresh()->link_in_sospeso);
$this->get(route('territori.show', $assegnazione->territorio_id))
->assertSee('Il link della piantina è ancora da inviare');
// Segnato come inviato dall'elenco: l'avviso sparisce.
Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class)
->call('toggleLinkSent', $assegnazione->id)
->assertDontSee('Link ancora da inviare');
$this->assertFalse($assegnazione->fresh()->link_in_sospeso);
}
public function test_condividere_dall_elenco_segna_il_link_come_inviato(): void
{
$this->assegna($this->mario(), $this->territorioConPdf());
$assegnazione = Assegnazione::sole();
Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class)
->assertSeeHtml('data-id="' . $assegnazione->id . '"')
->assertDontSee('wa.me', false)
->call('segnaCondiviso', $assegnazione->id)
->assertDontSee('Link ancora da inviare');
$this->assertTrue($assegnazione->fresh()->link_sent);
$this->assertSame('condividi', $assegnazione->fresh()->link_canale);
}
public function test_dalla_scheda_territorio_si_riprende_l_invio(): void
{
$this->assegna($this->mario(), $this->territorioConPdf());
$assegnazione = Assegnazione::sole();
$this->actingAs($this->admin)
->get(route('assegnazioni.assegna', ['invia' => $assegnazione->id]))
->assertOk()
->assertSee('Hai inviato il link?');
}
public function test_senza_pdf_non_c_e_un_link_da_inviare(): void
{
$territorio = Territorio::create(['numero' => '9', 'attivo' => true]);
$this->assegna($this->mario(), $territorio)
->assertSee('non ha un PDF')
->call('concludiInvio', false);
$this->assertFalse(Assegnazione::sole()->link_in_sospeso);
}
public function test_le_assegnazioni_gia_esistenti_non_vengono_segnalate(): void
{
$vecchia = Assegnazione::create([
'territorio_id' => $this->territorioConPdf()->id,
'proclamatore_id' => $this->mario()->id,
'anno_teocratico_id' => AnnoTeocratico::sole()->id,
'assigned_at' => now(),
'created_by' => $this->admin->id,
]);
$this->assertFalse($vecchia->fresh()->link_in_sospeso);
}
public function test_l_xml_porta_con_se_lo_stato_dell_invio(): void
{
$this->actingAs($this->admin);
$this->assegna($this->mario(), $this->territorioConPdf())->call('canale', 'copia')->call('concludiInvio', true);
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$assegnazione = Assegnazione::sole();
$this->assertTrue($assegnazione->link_sent);
$this->assertFalse($assegnazione->link_da_inviare);
$this->assertSame('copia', $assegnazione->link_canale);
}
// ─── Nuovo territorio dal designer ──────────────────────────
public function test_nuovo_territorio_porta_al_designer_delle_piantine(): void
{
$this->actingAs($this->admin)
->get(route('territori.index'))
->assertSee(route('piantine.editor', ['nuovo' => 1]), false);
}
}

View File

@@ -62,6 +62,29 @@ class PiantineEditorTest extends TestCase
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk(); $this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
} }
public function test_da_nuovo_territorio_la_prima_piantina_disegnata_crea_un_territorio(): void
{
Livewire::withQueryParams(['nuovo' => 1])
->actingAs($this->admin())
->test(PiantineEditor::class)
->assertSet('creaTerritorio', true)
->dispatch('geometria-modificata', geometry: self::GEOMETRIA, nuova: true)
->assertSet('territorioScelto', 'nuovo')
->assertSet('creaTerritorio', false)
// Solo la prima: la seconda piantina disegnata è una bozza qualunque.
->dispatch('geometria-modificata', geometry: self::ALTRA_GEOMETRIA, nuova: true)
->assertSet('territorioScelto', '');
}
public function test_senza_nuovo_l_editor_non_prepara_un_territorio(): void
{
Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->assertSet('creaTerritorio', false)
->dispatch('geometria-modificata', geometry: self::GEOMETRIA, nuova: true)
->assertSet('territorioScelto', '');
}
public function test_la_pagina_si_apre_su_una_piantina_esistente(): void public function test_la_pagina_si_apre_su_una_piantina_esistente(): void
{ {
$mappa = TerritorioMappa::create(['nome' => 'Zona porto', 'geojson' => json_encode(self::GEOMETRIA)]); $mappa = TerritorioMappa::create(['nome' => 'Zona porto', 'geojson' => json_encode(self::GEOMETRIA)]);

View File

@@ -0,0 +1,111 @@
<?php
namespace Tests\Feature;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* L'informativa privacy legge i suoi valori dalle impostazioni e dai dati,
* al momento: nessun numero scritto a mano.
*/
class PrivacyTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->seed(RolesAndPermissionsSeeder::class);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
}
public function test_i_valori_arrivano_dalle_impostazioni(): void
{
Setting::firstOrCreate([], ['congregazione_nome' => 'x'])->update([
'congregazione_nome' => 'Congregazione Zisa',
'audit_retention_days' => 400,
'giorni_per_smarrito' => 95,
'pdf_viewer_show_download' => false,
]);
$this->actingAs($this->admin)->get(route('privacy'))
->assertOk()
->assertSee('Congregazione Zisa')
->assertSee('conservato per')
->assertSee('400 giorni')
->assertSee('al massimo per 95 giorni')
->assertSee('si può solo consultare, non scaricare');
}
public function test_cambiare_un_impostazione_cambia_l_informativa(): void
{
$impostazioni = Setting::firstOrCreate([], ['congregazione_nome' => 'Prova']);
$impostazioni->update(['pdf_viewer_show_download' => true, 'giorni_per_smarrito' => 60]);
$this->actingAs($this->admin)->get(route('privacy'))
->assertSee('al massimo per 60 giorni')
->assertSee('si può anche scaricare');
}
public function test_i_ruoli_sono_quelli_veri_con_i_loro_permessi(): void
{
Setting::firstOrCreate([], ['congregazione_nome' => 'Prova']);
$this->actingAs($this->admin)->get(route('privacy'))
->assertSee('Amministratore')
->assertSee('Assistente')
->assertSee('Operatore')
->assertSee('assegnazione dei territori')
->assertDontSee('Servitore di Territorio')
->assertDontSee('Proclamatore Semplificato');
}
public function test_conta_i_dati_conservati_senza_mostrarli(): void
{
Setting::firstOrCreate([], ['congregazione_nome' => 'Prova']);
Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => false]);
$this->actingAs($this->admin)->get(route('privacy'))
->assertSee('2 proclamatori, di cui 1 attivi;')
->assertSee('Nessun numero di telefono né indirizzo email')
->assertDontSee('Rossi');
}
public function test_senza_posta_dice_che_non_si_inviano_email(): void
{
Setting::firstOrCreate([], ['congregazione_nome' => 'Prova']);
config(['mail.default' => 'log']);
$this->actingAs($this->admin)->get(route('privacy'))
->assertSee('nessun server di posta configurato');
}
public function test_con_la_posta_dell_app_ne_mostra_il_server(): void
{
Setting::firstOrCreate([], ['congregazione_nome' => 'Prova'])
->update(['mail_host' => 'smtp.example.test', 'mail_port' => 587]);
$this->actingAs($this->admin)->get(route('privacy'))
->assertSee('smtp.example.test');
}
public function test_e_riservata_all_amministratore(): void
{
Setting::firstOrCreate([], ['congregazione_nome' => 'Prova']);
$operatore = User::create(['name' => 'Op', 'email' => 'op@example.test', 'password' => 'password-di-prova']);
$operatore->assignRole('operatore');
$this->actingAs($operatore)->get(route('privacy'))->assertForbidden();
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Rientra;
use App\Livewire\Proclamatori\ProclamatoreEdit;
use App\Livewire\Proclamatori\ProclamatoreIndex;
use App\Livewire\Territori\TerritorioIndex;
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 App\Support\Ritorno;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* Dopo un'operazione (confermata o annullata) si torna alla pagina da cui il
* percorso era partito, con i suoi filtri: ?da=… (App\Support\Ritorno).
*/
class RitornoTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
}
// ─── Validazione ────────────────────────────────────────────
public function test_un_percorso_interno_diventa_l_url_di_ritorno(): void
{
$this->assertSame(url('/territori?filterZona=3&page=2'), Ritorno::valida('/territori?filterZona=3&page=2'));
$this->assertSame(url('/'), Ritorno::valida('/'));
}
#[DataProvider('ritorniNonValidi')]
public function test_un_ritorno_esterno_o_malformato_si_ignora(mixed $valore): void
{
$this->assertNull(Ritorno::valida($valore));
}
public static function ritorniNonValidi(): array
{
return [
'vuoto' => [''],
'null' => [null],
'array' => [['/territori']],
'url completo' => ['https://altro.sito/x'],
'senza schema' => ['//altro.sito/x'],
'barra rovesciata' => ['/\\altro.sito'],
'relativo' => ['territori'],
'javascript' => ['javascript:alert(1)'],
'a capo' => ["/territori\r\nX: y"],
'livewire' => ['/livewire/update'],
];
}
// ─── Dopo la conferma ───────────────────────────────────────
private function assegnazioneAperta(): Assegnazione
{
$territorio = Territorio::create(['numero' => '35', 'attivo' => true]);
$proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
return Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $proclamatore->id,
'anno_teocratico_id' => AnnoTeocratico::sole()->id,
'assigned_at' => now()->subDays(10),
'created_by' => $this->admin->id,
]);
}
public function test_il_rientro_torna_all_elenco_filtrato_da_cui_si_era_partiti(): void
{
$assegnazione = $this->assegnazioneAperta();
Livewire::withQueryParams(['da' => '/territori?filterStato=da_rientrare&page=2'])
->actingAs($this->admin)
->test(Rientra::class, ['assegnazione' => $assegnazione])
->call('save')
->assertRedirect(url('/territori?filterStato=da_rientrare&page=2'));
}
public function test_senza_pagina_di_partenza_il_rientro_va_alla_scheda_come_prima(): void
{
$assegnazione = $this->assegnazioneAperta();
Livewire::actingAs($this->admin)
->test(Rientra::class, ['assegnazione' => $assegnazione])
->call('save')
->assertRedirect(route('territori.show', $assegnazione->territorio_id));
}
public function test_un_ritorno_verso_l_esterno_non_viene_seguito(): void
{
$assegnazione = $this->assegnazioneAperta();
Livewire::withQueryParams(['da' => '//altro.sito/phishing'])
->actingAs($this->admin)
->test(Rientra::class, ['assegnazione' => $assegnazione])
->assertSet('ritorno', null)
->call('save')
->assertRedirect(route('territori.show', $assegnazione->territorio_id));
}
public function test_la_modifica_del_proclamatore_torna_alla_ricerca(): void
{
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Livewire::withQueryParams(['da' => '/proclamatori?search=Rossi'])
->actingAs($this->admin)
->test(ProclamatoreEdit::class, ['proclamatore' => $mario])
->set('nome', 'Mario Luigi')
->call('save')
->assertRedirect(url('/proclamatori?search=Rossi'));
}
// ─── Annulla e "Torna indietro" ─────────────────────────────
public function test_annulla_e_torna_indietro_portano_alla_pagina_di_partenza(): void
{
$assegnazione = $this->assegnazioneAperta();
$partenza = '/territori?search=35';
$this->actingAs($this->admin)
->get(route('assegnazioni.rientra', $assegnazione) . '?da=' . urlencode($partenza))
->assertOk()
->assertSee('Torna indietro')
->assertSee('href="' . e(url($partenza)) . '"', false);
}
public function test_senza_partenza_le_etichette_restano_quelle_di_prima(): void
{
$assegnazione = $this->assegnazioneAperta();
$this->actingAs($this->admin)
->get(route('assegnazioni.rientra', $assegnazione))
->assertOk()
->assertSee('Torna al territorio')
->assertDontSee('Torna indietro');
}
// ─── Gli elenchi ricordano filtri e ordinamento ─────────────
public function test_l_elenco_territori_legge_filtri_e_ordinamento_dall_indirizzo(): void
{
Livewire::withQueryParams(['search' => '35', 'filterStato' => 'assegnato', 'sortField' => 'zona', 'sortDirection' => 'desc'])
->actingAs($this->admin)
->test(TerritorioIndex::class)
->assertSet('search', '35')
->assertSet('filterStato', 'assegnato')
->assertSet('sortField', 'zona')
->assertSet('sortDirection', 'desc');
}
public function test_l_elenco_proclamatori_legge_la_ricerca_dall_indirizzo(): void
{
Livewire::withQueryParams(['search' => 'Rossi', 'filtroStato' => 'attivi'])
->actingAs($this->admin)
->test(ProclamatoreIndex::class)
->assertSet('search', 'Rossi')
->assertSet('filtroStato', 'attivi');
}
public function test_i_link_verso_schede_e_operazioni_portano_il_ritorno(): void
{
Territorio::create(['numero' => '35', 'attivo' => true]);
$html = $this->actingAs($this->admin)->get(route('territori.index'))->assertOk()->getContent();
// Il link alla scheda è marcato: il JS gli aggiunge ?da= al momento del clic.
$this->assertMatchesRegularExpression('/<a\b(?=[^>]*\bdata-ritorno\b)(?=[^>]*href="[^"]*\/territori\/\d+")[^>]*>/s', $html);
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace Tests\Feature;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Piantine\PiantineEditor;
use App\Livewire\Registro;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\TerritorioMappa;
use App\Models\User;
use App\Models\Zona;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
/**
* I campi di scelta con ricerca (<x-scelta>) per territori e proclamatori:
* le opzioni che il server prepara. Il modo in cui si filtrano e ordinano
* mentre si scrive è provato in tests/js/scelta.test.mjs.
*/
class SceltaRicercaTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Storage::fake('public');
Storage::fake('local');
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
$this->admin->assignRole('amministratore');
}
private function etichette(array $opzioni): array
{
return array_column($opzioni, 'etichetta');
}
public function test_assegna_usa_i_campi_con_ricerca(): void
{
$this->actingAs($this->admin)
->get(route('assegnazioni.assegna'))
->assertOk()
->assertSee('role="combobox"', false)
->assertSee('id="territorio_id"', false)
->assertSee('id="proclamatore_id"', false)
->assertDontSee('Filtra territori');
}
public function test_un_territorio_gia_assegnato_si_trova_ma_non_si_sceglie(): void
{
$zona = Zona::create(['nome' => 'Zisa', 'attivo' => true]);
$libero = Territorio::create(['numero' => '12', 'attivo' => true, 'zona_id' => $zona->id]);
$occupato = Territorio::create(['numero' => '35', 'attivo' => true]);
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Assegnazione::create([
'territorio_id' => $occupato->id, 'proclamatore_id' => $mario->id,
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
]);
$opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniTerritori');
$perValore = collect($opzioni)->keyBy('valore');
$this->assertSame(['valore' => (string) $libero->id, 'etichetta' => 'N° 12', 'dettaglio' => 'Zisa', 'chiavi' => ['12', 'Zisa', '']], $perValore[(string) $libero->id]);
$this->assertSame('già assegnato a Rossi Mario', $perValore[(string) $occupato->id]['motivo']);
$this->assertArrayNotHasKey('motivo', $perValore[(string) $libero->id]);
}
public function test_i_territori_sono_in_ordine_numerico(): void
{
foreach (['100', '9', '35', '12a', '12'] as $numero) {
Territorio::create(['numero' => $numero, 'attivo' => true]);
}
$opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniTerritori');
$this->assertSame(['N° 9', 'N° 12', 'N° 12a', 'N° 35', 'N° 100'], $this->etichette($opzioni));
}
public function test_i_proclamatori_sono_in_ordine_di_cognome_decifrato(): void
{
// Nel database sono cifrati: un ORDER BY li metterebbe a caso.
Proclamatore::create(['nome' => 'Paolo', 'cognome' => 'Verdi', 'attivo' => true]);
Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Rosselli', 'attivo' => true]);
Proclamatore::create(['nome' => 'Luca', 'cognome' => 'Bianchi', 'attivo' => true]);
$opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniProclamatori');
$this->assertSame(['Bianchi Luca', 'Rosselli Anna', 'Verdi Paolo'], $this->etichette($opzioni));
$this->assertSame(['Bianchi', 'Luca'], $opzioni[0]['chiavi']);
}
public function test_assegna_propone_solo_proclamatori_attivi(): void
{
Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => false]);
$opzioni = Livewire::actingAs($this->admin)->test(Assegna::class)->viewData('opzioniProclamatori');
$this->assertSame(['Rossi Mario'], $this->etichette($opzioni));
}
public function test_si_assegna_con_il_valore_scelto_nel_campo(): void
{
// Il campo manda a Livewire il valore come stringa, come faceva <select>.
Storage::disk('public')->put('territori-pdf/t7.pdf', '%PDF-1.4');
$territorio = Territorio::create(['numero' => '7', 'attivo' => true, 'pdf_path' => 'territori-pdf/t7.pdf']);
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
Livewire::actingAs($this->admin)->test(Assegna::class)
->set('territorio_id', (string) $territorio->id)
->set('proclamatore_id', (string) $mario->id)
->call('save')
->assertHasNoErrors();
$this->assertSame($mario->id, Assegnazione::sole()->proclamatore_id);
}
public function test_nel_registro_i_disattivati_ci_sono_ma_non_si_scelgono(): void
{
Territorio::create(['numero' => '5', 'attivo' => false]);
Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => false]);
$pagina = Livewire::actingAs($this->admin)->test(Registro::class);
$this->assertSame('territorio disattivato', $pagina->viewData('opzioniTerritori')[0]['motivo']);
$this->assertSame('non attivo', $pagina->viewData('opzioniProclamatori')[0]['motivo']);
}
public function test_nell_editor_le_voci_fisse_restano_in_cima(): void
{
Territorio::create(['numero' => '12', 'attivo' => true]);
$mappa = TerritorioMappa::create(['geojson' => json_encode([
'type' => 'Polygon', 'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
])]);
$opzioni = Livewire::actingAs($this->admin)
->test(PiantineEditor::class, ['mappa' => $mappa->id])
->viewData('opzioniTerritori');
$this->assertSame(['— nessun territorio —', '+ Crea un territorio nuovo', 'Territorio 12'], $this->etichette($opzioni));
$this->assertTrue($opzioni[0]['speciale']);
$this->assertSame('', $opzioni[0]['valore']);
}
}

98
tests/js/scelta.test.mjs Normal file
View File

@@ -0,0 +1,98 @@
// Prove della ricerca nei campi di scelta (resources/js/scelta.js).
// Si eseguono con: node --test tests/js/
// Gli esempi sono quelli concordati con l'utente il 19 ottobre 2026.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { proponi, normalizza, distanza } from '../../resources/js/scelta.js';
const territorio = (numero, zona = 'Montepellegrino', tipologia = 'Residenziale', motivo) => ({
valore: numero, etichetta: `N° ${numero}`, dettaglio: `${zona} (${tipologia})`,
chiavi: [numero, zona, tipologia], ...(motivo ? { motivo } : {}),
});
const proclamatore = (cognome, nome, motivo) => ({
valore: `${cognome}-${nome}`, etichetta: `${cognome} ${nome}`, chiavi: [cognome, nome], ...(motivo ? { motivo } : {}),
});
const etichette = (risultati) => risultati.map((o) => o.etichetta);
const territori = [
territorio('12', 'Zisa'), territorio('35'), territorio('135', 'Noce'), territorio('235', 'Cuba'),
territorio('350'), territorio('351'), territorio('36'),
];
const proclamatori = [
proclamatore('Bianchi', 'Rosa'), proclamatore('Grosso', 'Luca'), proclamatore('Neri', 'Nicolò'),
proclamatore('Rosselli', 'Anna'), proclamatore('Rossi', 'Mario'), proclamatore('Verdi', 'Paolo'),
];
test('esempio 1: "35" mette prima l\'uguale, poi chi inizia, poi chi contiene', () => {
assert.deepEqual(etichette(proponi(territori, '35')), ['N° 35', 'N° 350', 'N° 351', 'N° 135', 'N° 235']);
});
test('i numeri brevi non hanno tolleranza: "35" non trova il 36', () => {
assert.ok(!etichette(proponi(territori, '35')).includes('N° 36'));
});
test('esempio 1: "monte" trova i territori della zona Montepellegrino', () => {
assert.deepEqual(etichette(proponi(territori, 'monte')), ['N° 35', 'N° 350', 'N° 351', 'N° 36']);
});
test('esempio 2: "ros" mette prima il cognome, poi il nome, poi chi contiene', () => {
assert.deepEqual(etichette(proponi(proclamatori, 'ros')), ['Rosselli Anna', 'Rossi Mario', 'Bianchi Rosa', 'Grosso Luca']);
});
test('maiuscole e accenti non contano', () => {
assert.deepEqual(etichette(proponi(proclamatori, 'NICOLO')), ['Neri Nicolò']);
assert.equal(normalizza(' Nicolò '), 'nicolo');
});
test('nome e cognome in qualsiasi ordine', () => {
assert.deepEqual(etichette(proponi(proclamatori, 'mario ros')), ['Rossi Mario']);
assert.deepEqual(etichette(proponi(proclamatori, 'rossi mario')), ['Rossi Mario']);
});
test('esempio 3: "rosi" propone Rossi come "forse cercavi"', () => {
const risultati = proponi(proclamatori, 'rosi');
const rossi = risultati.find((o) => o.etichetta === 'Rossi Mario');
assert.ok(rossi, 'Rossi Mario deve essere proposto');
assert.equal(rossi.forse, true);
});
test('una corrispondenza vera viene prima di un errore di battitura', () => {
const elenco = [proclamatore('Rossi', 'Mario'), proclamatore('Rosi', 'Carla')];
const risultati = proponi(elenco, 'rosi');
assert.equal(risultati[0].etichetta, 'Rosi Carla');
assert.equal(risultati[0].forse, false);
});
test('campo vuoto: la tendina completa, senza le voci non sceglibili', () => {
const elenco = [territorio('7'), territorio('8', 'Zisa', 'Residenziale', 'già assegnato a Rossi Mario')];
assert.deepEqual(etichette(proponi(elenco, '')), ['N° 7']);
assert.deepEqual(etichette(proponi(elenco, ' ')), ['N° 7']);
});
test('cercando, le voci non sceglibili compaiono con il motivo', () => {
const elenco = [territorio('7'), territorio('8', 'Zisa', 'Residenziale', 'già assegnato a Rossi Mario')];
const [occupato] = proponi(elenco, '8');
assert.equal(occupato.etichetta, 'N° 8');
assert.equal(occupato.motivo, 'già assegnato a Rossi Mario');
});
test('le opzioni speciali restano sempre in cima', () => {
const elenco = [
{ valore: '', etichetta: '— nessun territorio —', speciale: true },
{ valore: 'nuovo', etichetta: '+ Crea un territorio nuovo', speciale: true },
territorio('35'), territorio('12', 'Zisa'),
];
assert.deepEqual(etichette(proponi(elenco, 'zisa')), ['— nessun territorio —', '+ Crea un territorio nuovo', 'N° 12']);
});
test('nessun risultato: elenco vuoto', () => {
assert.deepEqual(proponi(proclamatori, 'zzzz'), []);
});
test('distanza di modifica', () => {
assert.equal(distanza('rosi', 'rossi'), 1);
assert.equal(distanza('kitten', 'sitting', 3), 3);
assert.equal(distanza('abc', 'abcdef', 1), 2); // oltre il limite: limite + 1
});