Compare commits
33 Commits
fc8623f7db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f262d89d | ||
|
|
1ddb97371f | ||
| eb756ad3b1 | |||
|
|
d3314a9d17 | ||
|
|
7384a4b39d | ||
|
|
3000ca9939 | ||
|
|
3451663cfb | ||
|
|
e4549f8ff8 | ||
|
|
d8c519542b | ||
|
|
034329471b | ||
|
|
840fbe4c16 | ||
|
|
a10d06aa6c | ||
|
|
81a8f2ba33 | ||
|
|
fda428f242 | ||
|
|
2873424c3c | ||
|
|
bc465f53f5 | ||
|
|
0c530b65b3 | ||
|
|
f8539b58bd | ||
|
|
bde7a4a8a8 | ||
|
|
f8911afe64 | ||
|
|
ac787039c3 | ||
|
|
2f42cf937c | ||
|
|
1708890d48 | ||
|
|
097aa4ff30 | ||
|
|
6ef301cb14 | ||
|
|
358cbfc81c | ||
|
|
40b4ca0b0b | ||
|
|
2f0a24bca4 | ||
|
|
223d44300c | ||
| 754adf9878 | |||
|
|
76ea127d5b | ||
|
|
3ec2422fbc | ||
|
|
6b11ab7028 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
286
HANDOFF.md
286
HANDOFF.md
@@ -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 |
|
||||||
|
|||||||
85
README.md
85
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
app/Livewire/Assegnazioni/ElencoAssegnati.php
Normal file
67
app/Livewire/Assegnazioni/ElencoAssegnati.php
Normal 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)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
33
app/Livewire/Concerns/RicordaRitorno.php
Normal file
33
app/Livewire/Concerns/RicordaRitorno.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
49
app/Support/Ritorno.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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/*
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
101
resources/js/scelta.js
Normal 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 }))];
|
||||||
|
}
|
||||||
96
resources/views/components/scelta.blade.php
Normal file
96
resources/views/components/scelta.blade.php
Normal 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>
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
109
tests/Feature/AssegnaPrestazioniTest.php
Normal file
109
tests/Feature/AssegnaPrestazioniTest.php
Normal 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"'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
263
tests/Feature/InvioLinkTest.php
Normal file
263
tests/Feature/InvioLinkTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]);
|
||||||
|
|||||||
111
tests/Feature/PrivacyTest.php
Normal file
111
tests/Feature/PrivacyTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
191
tests/Feature/RitornoTest.php
Normal file
191
tests/Feature/RitornoTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
tests/Feature/SceltaRicercaTest.php
Normal file
158
tests/Feature/SceltaRicercaTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tests/Unit/VersionePhpTest.php
Normal file
54
tests/Unit/VersionePhpTest.php
Normal 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
98
tests/js/scelta.test.mjs
Normal 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
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user