Compare commits

..

33 Commits

Author SHA1 Message Date
Francesco Picone
c3f262d89d ++ HANDOFF: correzione di PHP in produzione il 21 ottobre 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:50:51 +02:00
Francesco Picone
1ddb97371f ++ php.ini: sessioni native chiuse, mitigazione di CVE-2026-109618/109619
session.upload_progress.enabled = 0 e session.use_strict_mode = 1, come
indicato da Sansec in attesa della patch; restano anche dopo, perche'
all'applicazione non servono (Laravel ha le sue sessioni, in Redis).
Test di guardia in VersionePhpTest. HANDOFF: stato della mitigazione e
scelta ancora aperta sul rilascio in produzione. README: sicurezza.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:03:23 +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
66 changed files with 3083 additions and 805 deletions

2
.gitignore vendored
View File

@@ -18,6 +18,7 @@
/.phpunit.cache/ /.phpunit.cache/
/.idea/ /.idea/
/.vscode/ /.vscode/
.claude
*.swp *.swp
*.swo *.swo
*.sql *.sql
@@ -29,3 +30,4 @@
docker-compose.override.yml docker-compose.override.yml
db_data/ db_data/
redis_data/ redis_data/
.claude

View File

@@ -4,7 +4,52 @@ 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: **21 ottobre 2026** (rilascio di sicurezza di PHP) — vedi [Sicurezza: PHP](#sicurezza-php-cve-2026-109618--cve-2026-109619-21-ottobre-2026).
> **Questo è l'HANDOFF di `main` (produzione).** Il 21/10/2026 in produzione
> è andata **solo la correzione di PHP**, scelta dell'utente: le funzioni nuove
> di `dev` (gruppi di servizio e lavori del 20 ottobre) restano sulla demo.
> Il quadro completo e aggiornato del progetto è nell'HANDOFF di `dev`, che
> sostituirà questo quando `dev` verrà portato in produzione.
---
## Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026)
Due esecuzioni di codice da remoto nel core di PHP (CVSS v4 9.2), patch alle
14:00 UTC del 21/10/2026; versione corretta per il nostro ramo: **8.3.36**.
La produzione girava su 8.3.35. Rilasciati su `main` solo i due commit di
sicurezza di `dev` (cherry-pick):
- `docker/php/Dockerfile`: `ARG PHP_VERSION=8.3.36` e `FROM
php:${PHP_VERSION}-fpm`. Prima era `php:8.3-fpm`, un tag che si sposta ma
che Docker tiene in cache: una ricostruzione senza `--pull` (anche su
Dokploy) poteva riusare la base vulnerabile. Ora una build o prende la
8.3.36 o fallisce. Test `tests/Unit/VersionePhpTest.php` contro il ritorno a
un tag mobile o a una versione inferiore.
- phpredis da GitHub (`ARG PHPREDIS_VERSION=6.3.0`) invece di `pecl install`:
quel giorno pecl.php.net rispondeva 504 e la build falliva.
- `docker/php/php.ini`: `session.upload_progress.enabled = 0` e
`session.use_strict_mode = 1`, la mitigazione indicata da Sansec in attesa
della patch, da tenere anche dopo (l'applicazione usa le sessioni di
Laravel, in Redis).
- Solo per provare in locale prima che l'immagine sia su Docker Hub:
`PHP_VERSION=8.3.35 docker compose ...` (mai per un deploy).
**In produzione dal 21 ottobre 2026, ore 18:48** (16:48 UTC; `main` =
`1ddb973`). Su Docker Hub il tag `php:8.3.36-fpm` è comparso alle 15:38 UTC
ma vuoto: l'immagine per amd64 (il server) è arrivata alle 16:41 UTC, e prima
la build falliva con "no match for platform in manifest". Prima del deploy:
test di questa versione su PHP 8.3.36 (524 PHP + 13 JS verdi) e backup
`/etc/dokploy/termanager2-backups/prod-20261021-164333` (database, 78 MB di
`storage/app`, conteggi). Deploy lanciato a mano (l'autodeploy non è
partito), uno solo. Verifica: PHP 8.3.36, `session.upload_progress.enabled`
Off e `session.use_strict_mode` On dal `php.ini` dell'immagine, nessuna
migrazione in sospeso, conteggi identici al backup (274 territori, 1130
assegnazioni di cui 77 aperte, 111 proclamatori, 4 utenti, 1251 righe di log,
35 migrazioni), login 200, container sani, nessun errore nei log. La
mitigazione applicata a mano ai container la mattina (in `conf.d`) è
sparita con la ricostruzione, come previsto: vale il `php.ini`.
--- ---
@@ -106,11 +151,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 +172,237 @@ 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.
### 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 |

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,31 @@ 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)
### Campagne ### Campagne
- CRUD con date di inizio e fine - CRUD con date di inizio e fine
@@ -764,8 +797,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
@@ -779,15 +812,32 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione - **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php` - **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
restituisce 404 restituisce 404
- **Versione di PHP fissata alla patch** (`ARG PHP_VERSION` nel Dockerfile,
oggi 8.3.36 per CVE-2026-109618/109619): una build o prende quella versione
o fallisce, mai una base vecchia rimasta in cache. Va alzata a ogni rilascio
di sicurezza del ramo 8.3
- **Sessioni native di PHP chiuse**: `session.upload_progress.enabled = 0` e
`session.use_strict_mode = 1` in `php.ini` (l'applicazione usa le sessioni
di Laravel; è la mitigazione indicata per le stesse CVE)
### GDPR ### GDPR
- **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
- **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
--- ---
@@ -799,11 +849,11 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| `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) |
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete | | `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
| `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`) |
| `activity_log` | Audit trail | | `activity_log` | Audit trail |
**Vincoli e indici principali** **Vincoli e indici principali**
@@ -842,8 +892,17 @@ 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".
--- ---
@@ -854,7 +913,7 @@ quando la soglia è scattata.
| Componente | Tecnologia | | Componente | Tecnologia |
|-------------------|--------------------------------------------| |-------------------|--------------------------------------------|
| Framework | Laravel 11 | | Framework | Laravel 11 |
| PHP | 8.3 (FPM) | | PHP | 8.3 (FPM), versione fissata nel Dockerfile |
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js | | Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
| Database | MariaDB 11 | | Database | MariaDB 11 |
| Cache/Sessioni/Code| Redis 7 | | Cache/Sessioni/Code| Redis 7 |

View File

@@ -2,8 +2,10 @@
namespace App\Livewire\Assegnazioni; namespace App\Livewire\Assegnazioni;
use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use Livewire\Attributes\Computed; use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
@@ -12,14 +14,30 @@ 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';
// ─── Invio del link ─────────────────────────────────────────
//
// Dopo l'assegnazione non si torna subito alla pagina: si sceglie il
// canale (copia link o Condividi: il destinatario lo sceglie chi usa
// l'app dai suoi contatti, i proclamatori non hanno numero né email),
// si dice se il link è partito, e solo allora si va avanti. Se la
// procedura si interrompe, l'assegnazione resta con link_da_inviare e lo
// si segnala dove compare.
/** L'assegnazione di cui si sta mandando il link. */
#[Locked]
public ?int $invioId = null;
/** Il canale usato in questo invio (Assegnazione::CANALI_LINK). */
#[Locked]
public ?string $canaleUsato = null;
public string $erroreInvio = '';
public ?int $territorio_id = null; public ?int $territorio_id = null;
public ?int $proclamatore_id = null; public ?int $proclamatore_id = null;
public string $assigned_at = ''; public string $assigned_at = '';
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 +47,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
@@ -88,6 +112,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 +136,130 @@ 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. */ /**
public function esitoInvio(int $assegnazioneId, bool $inviato): void * Apre l'invio del link: subito dopo l'assegnazione, o da un'assegnazione
* rimasta in sospeso ("Invia link" nell'elenco, che è un componente a sé
* e lo chiede con l'evento apri-invio).
*/
#[On('apri-invio')]
public function apriInvio(int $assegnazioneId): void
{ {
$this->authorize('territori.assign'); $this->authorize('territori.assign');
$assegnazione = Assegnazione::find($assegnazioneId); $assegnazione = Assegnazione::aperte()->find($assegnazioneId);
$assegnazione?->forceFill(['link_sent' => $inviato])->saveQuietly(); if (! $assegnazione) {
return;
}
session([self::DA_INVIARE => array_values(array_diff(session(self::DA_INVIARE, []), [$assegnazioneId]))]); $this->invioId = $assegnazione->id;
$this->canaleUsato = null;
$this->erroreInvio = '';
} }
public function toggleLinkSent(int $assegnazioneId): void /**
* Copia link o Condividi: l'invio vero lo fa chi usa l'app (il
* destinatario lo sceglie lui dai suoi contatti), qui si annota il canale.
*/
public function canale(string $canale): void
{ {
$this->authorize('territori.assign'); $assegnazione = $this->assegnazioneInInvio();
$assegnazione = Assegnazione::findOrFail($assegnazioneId); if (! $assegnazione || ! in_array($canale, Assegnazione::CANALI_ATTIVI, true) || ! $assegnazione->shortPdfUrl()) {
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly(); return;
}
$this->canaleUsato = $canale;
$this->erroreInvio = '';
} }
#[Computed] /**
public function selectedThumbnailUrl(): ?string * 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)
{ {
if (!$this->territorio_id) { $assegnazione = $this->assegnazioneInInvio();
if (! $assegnazione) {
$this->invioId = null;
return null; return null;
} }
$t = Territorio::find($this->territorio_id);
return $t?->miniaturaUrl(); if ($inviato && $this->canaleUsato === null) {
$this->erroreInvio = 'Scegli prima come mandare il link.';
return null;
}
$assegnazione->registraInvioLink($inviato, $this->canaleUsato);
// Nel log il canale e l'id del proclamatore, mai nome, numero o email.
activity()->causedBy(auth()->user())
->performedOn($assegnazione)
->withProperties([
'territorio' => $assegnazione->territorio?->numero,
'proclamatore_id' => $assegnazione->proclamatore_id,
'canale' => $inviato ? $this->canaleUsato : null,
])
->log($inviato ? 'link_inviato' : 'link_non_inviato');
$numero = $assegnazione->territorio?->numero;
$nome = $assegnazione->proclamatore?->nome_completo;
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
session()->flash('success', $inviato
? "Territorio {$numero} assegnato a {$nome}: link inviato."
: "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco.");
return $this->redirect($this->destinazione(route('assegnazioni.assegna')), navigate: true);
}
private function assegnazioneInInvio(): ?Assegnazione
{
if (! $this->invioId) {
return null;
}
$this->authorize('territori.assign');
return Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
} }
public function render() public function render()
{ {
$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) !== '') { $territori = Territorio::attivi()
$term = trim($this->territorioSearch); ->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore'])
$territoriQuery->where(function ($q) use ($term) {
$q->where('numero', 'like', "%{$term}%")
->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(
->with(['territorio.zona', 'proclamatore']) $t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null
->get() ) + array_filter(['miniatura' => $t->miniaturaUrl()]))
->sortBy(fn($a) => (int) $a->territorio?->numero); ->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). // L'elenco dei territori assegnati sta in ElencoAssegnati.
$sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120)); // L'invio in corso; se nel frattempo l'assegnazione è rientrata o
// sparita non c'è più niente da mandare.
// I riquadri in evidenza: solo le assegnazioni ancora aperte (una $invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
// rientrata o cancellata nel frattempo non ha più un link da mandare). if ($this->invioId && ! $invio) {
$idDaInviare = session(self::DA_INVIARE, []); $this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
$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()]);
} }
return view('livewire.assegnazioni.assegna', [ return view('livewire.assegnazioni.assegna', [
'daInviare' => $daInviare, 'invio' => $invio,
'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;
@@ -98,16 +101,37 @@ 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(route('territori.show', $territorio)), navigate: true);
} }
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,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

@@ -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,84 @@
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',
'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,6 +3,7 @@
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\Proclamatore; use App\Models\Proclamatore;
@@ -12,9 +13,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 = [

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

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

@@ -629,9 +629,8 @@ class XmlExchange extends Component
'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']++;
@@ -768,6 +767,8 @@ 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,
'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,
@@ -828,11 +829,11 @@ class XmlExchange extends Component
])->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', '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']), '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']),
'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', 'pdf_access_code', 'created_at', 'updated_at']),
]; ];
} }
@@ -1263,8 +1264,6 @@ 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('deleted_at', (string) ($proclamatore['deleted_at'] ?? '')); $node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
$this->addTimestampNodes($node, $proclamatore); $this->addTimestampNodes($node, $proclamatore);
@@ -1321,6 +1320,8 @@ 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)));
$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 = [

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)

View File

@@ -20,6 +20,8 @@ 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',
@@ -32,6 +34,7 @@ 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',
]; ];
} }
@@ -133,7 +136,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 +215,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 +286,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

@@ -13,16 +13,16 @@ 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'];
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',
]; ];
} }
@@ -30,7 +30,7 @@ class Proclamatore extends Model
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 +44,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,8 +94,6 @@ 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->save(); $this->save();
$this->delete(); // soft delete $this->delete(); // soft delete

View File

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

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

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

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

View File

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

View File

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

View File

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

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

@@ -1,75 +1,83 @@
<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 {{-- Invio del link (Assegna::apriInvio): blocca la pagina finché non si
la scelta "inviato / non ancora". Resta finché non si sceglie risponde "inviato / non ancora". Se si chiude la scheda prima,
(Assegna::DA_INVIARE in sessione). --}} l'assegnazione resta segnalata con "link ancora da inviare". --}}
@foreach($daInviare as $nuova) @if($invio)
@php @php
$link = $nuova->shortPdfUrl(); $link = $invio->shortPdfUrl();
$messaggio = $link $messaggio = $invio->messaggioLink($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 @endphp
<section class="tm-da-inviare" wire:key="da-inviare-{{ $nuova->id }}" aria-labelledby="da-inviare-{{ $nuova->id }}"> <div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-gray-900/50 p-4 sm:items-center"
<h2 id="da-inviare-{{ $nuova->id }}" class="tm-da-inviare-titolo"> role="dialog" aria-modal="true" aria-labelledby="invio-titolo" wire:key="invio-{{ $invio->id }}">
Territorio N° {{ $nuova->territorio->numero }} assegnato a {{ $nuova->proclamatore->nome_completo }} <section class="tm-da-inviare tm-da-inviare-finestra">
</h2> <h2 id="invio-titolo" class="tm-da-inviare-titolo">
Territorio N° {{ $invio->territorio?->numero }} assegnato a {{ $invio->proclamatore?->nome_completo }}
</h2>
@if($link) @if($link)
<div x-data="{ copiato: false }" class="tm-da-inviare-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>
<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"> <div class="tm-da-inviare-link">
@if($whatsapp) <code id="invio-link" x-data x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
<a href="{{ $whatsapp }}" target="_blank" rel="noopener" class="tm-whatsapp tm-whatsapp-grande"> </div>
<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> {{-- Il messaggio sta in data-messaggio: dentro <x-btn> le direttive
<span class="tm-whatsapp-numero">{{ $nuova->proclamatore->telefono }}</span> Blade (@js) non si compilano, quindi i pulsanti lo leggono da qui. --}}
</a> <div class="tm-da-inviare-azioni" x-data="{ copiato: false, condivisibile: !! navigator.share }"
@else data-messaggio="{{ $messaggio }}" data-titolo="Territorio N° {{ $invio->territorio?->numero }}">
<p class="text-sm text-gray-600"> <x-btn :variante="$canaleUsato === 'copia' ? 'primary' : 'secondary'"
{{ $nuova->proclamatore->nome_completo }} non ha un numero di telefono: copia il link e mandalo come preferisci. x-on:click="navigator.clipboard?.writeText(document.getElementById('invio-link').textContent.trim()).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) }).catch(() => {}); $wire.canale('copia')">
@can('settings.manage') <span x-show="! copiato">Copia link</span>
<a href="{{ route('proclamatori.edit', $nuova->proclamatore) }}" class="text-indigo-700 underline">Aggiungi il numero</a> <span x-show="copiato" x-cloak>Copiato</span>
@endcan </x-btn>
</p>
{{-- 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 @endif
</div>
<div class="tm-da-inviare-scelta"> <div class="tm-da-inviare-scelta">
<p>Il link è stato inviato?</p> <p>Hai inviato il link?</p>
<div> <div>
<x-btn variante="primary" wire:click="esitoInvio({{ $nuova->id }}, true)">Sì, inviato</x-btn> <x-btn variante="primary" wire:click="concludiInvio(true)" :disabled="$canaleUsato === null"
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Non ancora</x-btn> :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> </div>
</div> @else
@else <p class="text-sm text-gray-600">
<p class="text-sm text-gray-600"> Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio.
Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio. </p>
</p> <div class="tm-da-inviare-scelta">
<div class="tm-da-inviare-scelta"> <div>
<div> <x-btn variante="secondary" wire:click="concludiInvio(false)">Ho capito</x-btn>
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Ho capito</x-btn> </div>
</div> </div>
</div> @endif
@endif </section>
</section> </div>
@endforeach @endif
<div x-data="{ formOpen: true }"> <div x-data="{ formOpen: true }">
@@ -87,54 +95,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 +135,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>
<x-btn variante="secondary" x-on:click="formOpen = false">Annulla</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>
@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 ?? 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">
<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' : 'Torna al territorio' }}
</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 ?? route('territori.show', $assegnazione->territorio)">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

@@ -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>
@hasanyrole('amministratore|assistente') {{-- "Vedi tutti" in testata, non in fondo all'elenco. --}}
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto" <div class="ml-auto flex flex-none items-center gap-2">
wire:click="downloadPdfDaAssegnare" @if($territoriDaAssegnare->count() >= $homeLimit)
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da assegnare"> <x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('territori.index', ['filterStato' => 'in_reparto'])">Vedi tutti</x-btn>
<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> @endif
</x-btn> @hasanyrole('amministratore|assistente')
@endhasanyrole <x-btn variante="secondary" tono="tenue" dimensione="sm" icona
wire:click="downloadPdfDaAssegnare"
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>
</x-btn>
@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>
@hasanyrole('amministratore|assistente') <div class="ml-auto flex flex-none items-center gap-2">
{{-- Scaricare un elenco non è distruttivo: era rosso solo perché @if($daRientrare->count() >= $homeLimit)
lo è la scheda che lo contiene. --}} <x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('territori.index', ['filterStato' => 'da_rientrare'])">Vedi tutti</x-btn>
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto" @endif
wire:click="downloadPdfDaRientrare" @hasanyrole('amministratore|assistente')
title="Scarica PDF" aria-label="Scarica in PDF l elenco dei territori da rientrare"> {{-- Scaricare un elenco non è distruttivo: era rosso solo perché
<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> lo è la scheda che lo contiene. --}}
</x-btn> <x-btn variante="secondary" tono="tenue" dimensione="sm" icona
@endhasanyrole wire:click="downloadPdfDaRientrare"
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>
</x-btn>
@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 }}), e se sono attivi. 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), password conservata solo come impronta non reversibile, ruolo.</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.</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.</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,7 +82,7 @@
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>
@@ -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

@@ -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>
@@ -112,7 +116,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>
@@ -143,7 +147,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 +158,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 +168,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">
@@ -99,7 +109,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>

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,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']);
}
}

View File

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

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