Compare commits
67 Commits
fc8623f7db
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635982bf90 | ||
|
|
726af6d5c3 | ||
|
|
d198ad0b54 | ||
|
|
2e1da51080 | ||
|
|
5b4903d1cb | ||
|
|
1da31ce600 | ||
|
|
7904c96db8 | ||
|
|
5e40f0fe74 | ||
|
|
18fe914da5 | ||
|
|
2fb9838a0e | ||
|
|
9e473cd7fd | ||
|
|
8b6bc0796a | ||
|
|
8ce3d6d10d | ||
| db5772d7f5 | |||
|
|
5fb42b05c7 | ||
|
|
7d479a2c34 | ||
|
|
f1f340803a | ||
|
|
6f88c65d73 | ||
|
|
ab7d26ae41 | ||
|
|
cd3569a327 | ||
|
|
eda3c3f989 | ||
|
|
699e2ef173 | ||
|
|
0ce5bd8c89 | ||
|
|
ce98165667 | ||
|
|
d2d6b1bdce | ||
|
|
ec2e7bd7e9 | ||
|
|
69250ecb8c | ||
|
|
aaffeb7de7 | ||
|
|
a93539242a | ||
|
|
7a54c15852 | ||
|
|
4d9e445cba | ||
|
|
9999a3b031 | ||
|
|
93bc9cf2fe | ||
|
|
6489fb627d | ||
|
|
f48d0e96c0 | ||
|
|
96084499f5 | ||
|
|
5d0ae06eca | ||
|
|
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 |
@@ -30,6 +30,8 @@ public/build
|
||||
public/storage
|
||||
public/hot
|
||||
|
||||
_ARCHIVE
|
||||
|
||||
db_data
|
||||
redis_data
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@
|
||||
docker-compose.override.yml
|
||||
db_data/
|
||||
redis_data/
|
||||
.claude
|
||||
849
HANDOFF.md
849
HANDOFF.md
@@ -4,91 +4,613 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
|
||||
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
||||
può dare: perché le cose sono come sono, e cosa resta aperto.
|
||||
|
||||
Ultimo aggiornamento: **12 ottobre 2026** — vedi [Lavori in corso](#lavori-in-corso--12-ottobre-2026-riprendere-da-qui).
|
||||
Ultimo aggiornamento: **21 ottobre 2026** — si riprende da [Stato attuale](#stato-attuale).
|
||||
|
||||
---
|
||||
|
||||
## Stato attuale
|
||||
|
||||
L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è
|
||||
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti,
|
||||
redeploy, login, tutte le pagine, code e scheduler.
|
||||
**✅ 21 ottobre 2026 — PHP 8.3.36 su produzione e demo** (CVE-2026-109618/109619,
|
||||
esecuzione di codice da remoto), con la mitigazione nel `php.ini`. Vedi
|
||||
[Sicurezza: PHP](#sicurezza-php-cve-2026-109618--cve-2026-109619-21-ottobre-2026).
|
||||
|
||||
**Produzione** (`main` → compose `termanager2-core-production`) in esercizio
|
||||
su Dokploy dal 19 ottobre 2026. Ultimo rilascio: `1ddb973` del 21/10, ore
|
||||
18:48 (solo la correzione di PHP sopra il rilascio del 19/10: proclamatori
|
||||
senza contatti, il link si copia o si condivide). Backup fatti prima dei
|
||||
rilasci in `/etc/dokploy/termanager2-backups/` sul server.
|
||||
|
||||
**Demo** (`dev` → compose `termanager2-core-demo`,
|
||||
`demo-termanager.pyconetwork.it`): avanti di una ventina di commit, tutti
|
||||
provati con la suite sul server e pubblicati sulla demo. **Non ancora in
|
||||
produzione** — l'utente ha chiesto di aspettare il suo ok:
|
||||
|
||||
- [gruppi di servizio](#gruppi-di-servizio-19-20-ottobre-2026-solo-su-dev):
|
||||
sorvegliante con email fittizia, pacchetto di territori, "Il mio gruppo",
|
||||
scioglimento; migrazioni `2026_10_20_000010` … `000040`;
|
||||
- priorità e filtri nel pacchetto, con la regola di priorità della Home in un
|
||||
metodo solo (`Territorio::ordinaPerPriorita()`);
|
||||
- pulsante **Rientro** nell'elenco dei territori assegnati;
|
||||
- storico assegnazioni in una tabella sola, con le colonne in linea;
|
||||
- *Assegna* aperta da "Assegna →" della Home senza l'elenco dei territori
|
||||
assegnati;
|
||||
- password del sorvegliante dal pannello *Utenti*, anteprima dal numero in "Il
|
||||
mio gruppo";
|
||||
- [modulo pubblico delle richieste, cartacei, S-13 per email](#richieste-dal-modulo-pubblico-cartacei-s-13-per-email-21-ottobre-2026-solo-su-dev)
|
||||
(migrazione `2026_10_21_000010`).
|
||||
|
||||
**Per portarli in produzione**: backup del database di produzione, merge di
|
||||
`dev` in `main` (dopo il rilascio di sicurezza del 21/10 non è più un
|
||||
fast-forward), deploy (l'autodeploy non parte: vedi
|
||||
[Dove gira davvero](#dove-gira-davvero)), poi controllo di migrazioni e
|
||||
conteggi. In produzione non ci sono gruppi né richieste: le migrazioni non
|
||||
hanno dati da convertire.
|
||||
|
||||
| Aspetto | Stato |
|
||||
|-----------------------------|-------|
|
||||
| Deploy Dokploy (Compose) | ✅ verificato |
|
||||
| Deploy Dokploy (Compose) | ✅ produzione e demo |
|
||||
| Autodeploy al push | ⚠️ configurato, ma dal 19/10 il push non fa partire il deploy: si avvia a mano (`compose.deploy`) |
|
||||
| Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
|
||||
| Redeploy senza interventi | ✅ ~25 secondi |
|
||||
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
|
||||
| Backup / ripristino / azzeramento | ✅ verificati end-to-end in locale |
|
||||
| Backup / ripristino / azzeramento | ✅ verificati end-to-end |
|
||||
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
|
||||
| Test automatici | ⚠️ **impalcatura pronta, 294 test** — copertura ancora parziale |
|
||||
| Test automatici | ✅ **613 test PHP + 13 JavaScript, verdi** (21/10/2026); copertura ancora parziale |
|
||||
| Prove nel browser | ⚠️ le funzioni del 19-21 ottobre sono verificate dai test e sul server; nel browser le usa l'utente sulla demo (richieste, cartacei e S-13 non ancora provati da lui) |
|
||||
|
||||
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto:
|
||||
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre
|
||||
di avanzamento reali e console di log (commit `f909791`, deploy verificato
|
||||
confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo**
|
||||
su develop; la produzione non è stata toccata e resta sul suo deploy
|
||||
precedente, senza quella variabile.
|
||||
|
||||
**Il 31/07 è nata la sotto-app "Piantine"** (nuova voce nel menu
|
||||
Amministrazione): KML dell'area città, editor dei poligoni su OpenStreetMap,
|
||||
immagini HD dal container `map-renderer` e PDF sul modulo S-12 con storico
|
||||
versioni. Architettura e agganci sono descritti in [Decisioni
|
||||
architetturali](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||||
Al primo deploy il compose costruisce anche l'immagine del renderer; non
|
||||
servono variabili nuove in Dokploy.
|
||||
|
||||
**Il 28/08 la sotto-app è stata rifatta e l'interfaccia ripassata.** Le piantine
|
||||
si sono staccate dai territori (una piantina vive anche scollegata, il territorio
|
||||
si crea dall'editor), il disegno è passato su una pagina a schermo intero, la
|
||||
cartolina S-12 è stata reimpaginata (confini sulla prima pagina, piantina al 98%
|
||||
sulla seconda) e la rigenerazione massiva è stata rimossa. In più il sistema
|
||||
visivo è stato ripassato su tutta l'applicazione, non solo qui: vedi [Il sistema
|
||||
visivo sta nei token](#il-sistema-visivo-sta-nei-token-non-nelle-viste).
|
||||
|
||||
Nello stesso giro è emerso e corretto un guasto d'ambiente che bloccava **tutti**
|
||||
gli upload in sviluppo: vedi [`HTTP_HOST` a nginx deve conservare la
|
||||
porta](#http_host-a-nginx-deve-conservare-la-porta).
|
||||
|
||||
Il 30/07 sono state fatte due cose.
|
||||
|
||||
**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando,
|
||||
senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione
|
||||
della logica di business — vedi *Cosa resta aperto*. Non fanno parte
|
||||
dell'immagine di produzione e non girano su un deploy: sono uno strumento
|
||||
locale.
|
||||
|
||||
**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con
|
||||
un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con
|
||||
gli stati di focus, che prima non c'erano), e **una sola console** per tutte le
|
||||
operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di
|
||||
log sparsi. L'azzeramento è diventato un componente a sé per poter stare in
|
||||
fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel
|
||||
browser — apertura automatica della console, scorrimento, lampeggio delle zone —
|
||||
non è stato provato**, perché richiede un browser: è la prima cosa da guardare
|
||||
aprendo la pagina.
|
||||
|
||||
Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché
|
||||
non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto*
|
||||
e l'unica che richiede una decisione, non del lavoro.
|
||||
La storia precedente (luglio-agosto: backup e ripristino, sotto-app Piantine,
|
||||
sistema visivo, prima suite di test) è in [Da dove veniamo](#da-dove-veniamo).
|
||||
|
||||
---
|
||||
|
||||
## Lavori in corso — 12 ottobre 2026 (riprendere da qui)
|
||||
## Lavori recenti — ottobre 2026
|
||||
|
||||
Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo):
|
||||
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
|
||||
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione
|
||||
(integrità dati).
|
||||
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
|
||||
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
|
||||
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||||
- `426a952` — blocchi 2 e 3 della revisione (sotto); le ultime due righe
|
||||
della tabella sono nel commit successivo.
|
||||
Dal più recente. Le sezioni del 12-19 ottobre sono in produzione; quelle del
|
||||
20-21 ottobre e i gruppi di servizio solo sulla demo.
|
||||
|
||||
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di
|
||||
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
|
||||
### Richieste dal modulo pubblico, cartacei, S-13 per email (21 ottobre 2026, solo su dev)
|
||||
|
||||
Dal documento `_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx`
|
||||
(attività per la versione 2): analizzato con l'utente, che ha deciso così.
|
||||
|
||||
- **Modulo pubblico** `/richiesta` (rotta `richiesta`, fuori dal login,
|
||||
`throttle:60,1`; componente `Richieste\ModuloRichiesta`, layout guest):
|
||||
nome e cognome, *nuovo territorio* o *riconsegna* (con numero), nota.
|
||||
Nella riconsegna il numero si sceglie con `<x-scelta>` (richiesta
|
||||
dell'utente del 21/10: "il territorio fallo inserire con completamento
|
||||
automatico"): opzioni con solo numero e zona, valore = numero; i territori
|
||||
attivi più quelli disattivati ma ancora fuori; l'elenco va nella pagina
|
||||
solo con *riconsegna* scelta. Il server accetta solo un numero di
|
||||
quell'elenco (confronto senza maiuscole). Nessun altro dato esistente: né
|
||||
nomi né stato delle assegnazioni. Anti-spam: campo trappola `sito` (classe
|
||||
`.tm-trappola`), tempo minimo `SECONDI_MINIMI` = 3 s (in questi due casi
|
||||
risponde "inviata" senza salvare), `MASSIMO_ORA` = 5 per IP (chiave del
|
||||
RateLimiter con lo sha1 dell'IP). Il tempo si legge con `now()`, così i
|
||||
test possono viaggiare nel tempo.
|
||||
- **Tabella `richieste`** (migrazione `2026_10_21_000010`, che crea anche il
|
||||
permesso `richieste.gestisci` per amministratore e assistente e la colonna
|
||||
`assegnazioni.cartaceo`): nome e note cifrati; nel registro delle attività
|
||||
solo tipo, stato, numero (`richiesta_ricevuta` all'arrivo, poi i cambi di
|
||||
stato). `RichiesteServizio`: `registra()`, `destinatari()`, `nomiSimili()`,
|
||||
`assegnazioneAperta()`.
|
||||
- **Email di avviso** (`Notifications\RichiestaRicevuta`) a chi ha
|
||||
`richieste.gestisci` con l'email confermata, solo se la posta funziona
|
||||
(`ConfigurazionePosta::funzionante()`). Non in coda: contiene il nome.
|
||||
- **Conferma dalla Home** (riquadro *Richieste*, solo con il permesso):
|
||||
scelta dell'utente, "così non si modifica troppo il codice del form
|
||||
precompilato". Per un nuovo territorio la Home propone i proclamatori attivi
|
||||
con un nome simile (punteggio per parola: uguale 3, iniziale 2, una lettera
|
||||
di differenza 1; serve almeno 2) e ognuno apre *Assegna* con
|
||||
`?proclamatore=&richiesta=&dalla_home=1`; per una riconsegna, *Rientra* con
|
||||
`?richiesta=`. I due moduli leggono `richiesta` solo se aperta e se l'utente
|
||||
ha il permesso (`#[Locked] richiestaId`), la mostrano sopra il form e, fatta
|
||||
l'operazione, la segnano **evasa** (`Richiesta::evadi()`). Altrimenti
|
||||
*Scarta*. La Home "messaggi" del documento sono solo queste richieste.
|
||||
- **Pulizia**: `richieste:pulizia` ogni notte cancella le richieste gestite da
|
||||
più di `PuliziaRichieste::GIORNI` = 90 giorni. Le richieste non sono
|
||||
nell'XML né nel `.zip` (un ripristino non le tocca); l'azzeramento le
|
||||
cancella (passo "Richieste" di `ResetApplication`, ora 12 passi).
|
||||
- **Cartaceo sull'assegnazione** (scelta dell'utente: "va sull'assegnazione"):
|
||||
spunta su *Assegna* e in "Il mio gruppo" (`GruppiServizio::assegna(...,
|
||||
cartaceo:)`), etichetta "cartaceo" nell'elenco degli assegnati e in "Il mio
|
||||
gruppo". Nell'elenco è un'espressione, non un blocco `@if`: Livewire mette
|
||||
due commenti segnaposto attorno a ogni `@if`, 58 byte a riga anche quando è
|
||||
falso, e la riga superava i 2,6 KB del test di peso. Pagina
|
||||
**Cartacei** (`assegnazioni/cartacei`, `territori.assign`, voce di menu sotto
|
||||
*Assegnazioni*) con i territori cartacei fuori. Passa per l'XML.
|
||||
- **S-13 per email**: `registro:invia-s13 {--anno=}` manda
|
||||
`Mail\ModuloS13Mail` (PDF allegato, non in coda) agli amministratori con
|
||||
l'email confermata; nello scheduler `weeklyOn(0, '08:00')` con
|
||||
`when(settimana dopo in un altro mese)` = **ultima domenica del mese**. Senza
|
||||
posta non parte e lo dice. Nel registro `s13_inviato` con anno e quanti.
|
||||
- Informativa privacy aggiornata (dati, conteggi, posta, conservazione;
|
||||
revisione 21 ottobre 2026). Test in `tests/Feature/RichiesteTest.php` e
|
||||
`GruppiServizioTest` (cartaceo dal sorvegliante, cartaceo nell'XML).
|
||||
|
||||
### 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**.
|
||||
Il progetto girava su 8.3.35.
|
||||
|
||||
- `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.
|
||||
- Solo per provare in locale prima che l'immagine sia su Docker Hub:
|
||||
`PHP_VERSION=8.3.35 docker compose ...` (mai per un deploy).
|
||||
- **Mitigazione in attesa della patch** (indicata da Sansec, approvata
|
||||
dall'utente): `session.upload_progress.enabled = 0` e
|
||||
`session.use_strict_mode = 1` in `docker/php/php.ini`, da tenere anche dopo
|
||||
(all'applicazione non servono: Laravel ha le sue sessioni, in Redis).
|
||||
Nei container già in esecuzione di demo e produzione la stessa cosa va in
|
||||
`conf.d/zz-mitigazione-cve-2026-109618.ini` con ricarica morbida di
|
||||
php-fpm (USR2): si perde quando il container viene ricreato, e da lì vale il
|
||||
`php.ini` dell'immagine. *Stato dell'applicazione ai container: vedi
|
||||
sotto, aggiornato a ogni passo.*
|
||||
- **Scelta dell'utente (21/10): in produzione solo la correzione di PHP.** I
|
||||
due commit di sicurezza sono andati su `main` con un cherry-pick (`eb756ad`
|
||||
e `1ddb973`, sopra `d3314a9`); gruppi di servizio e lavori dal 20 ottobre
|
||||
restano sulla demo, da rilasciare un altro giorno. Ora `main` e `dev`
|
||||
divergono: per portare `dev` in produzione servirà un merge (i cherry-pick
|
||||
sono già in `dev`, conflitti attesi solo in HANDOFF).
|
||||
- **Rilascio fatto il 21/10.** 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. Una build prima di quell'ora fallisce con "no
|
||||
match for platform in manifest": per sapere se si può costruire va
|
||||
controllata l'architettura, non solo il nome del tag. Poi, nell'ordine:
|
||||
1. test della versione di produzione su 8.3.36: 524 PHP + 13 JS verdi;
|
||||
2. backup della produzione (`prod-20261021-164333`: database, 78 MB di
|
||||
`storage/app`, conteggi);
|
||||
3. demo: tolta la variabile provvisoria `PHP_VERSION=8.3.35` (vedi sotto) e
|
||||
ricostruita: PHP 8.3.36, così si è visto che senza variabile la build
|
||||
prende la versione del Dockerfile;
|
||||
4. `main` = `1ddb973`, deploy della produzione alle 16:48 UTC (uno solo:
|
||||
l'autodeploy non è partito, lanciato a mano).
|
||||
Verifica in produzione: 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.
|
||||
- Il passaggio di consegne di quella sessione (`HANDOFF-chat.md`) sta nella
|
||||
cartella del progetto ma non nel repository (escluso in `.git/info/exclude`):
|
||||
contiene dettagli dell'infrastruttura.
|
||||
|
||||
**Demo costruita per qualche ora con PHP 8.3.35 (21/10, dalle 14:40 alle
|
||||
16:44 UTC).** L'utente aveva chiesto il deploy della demo prima che uscisse
|
||||
`php:8.3.36-fpm`: nelle variabili d'ambiente del compose della demo in
|
||||
Dokploy era stata messa `PHP_VERSION=8.3.35`, che il `docker-compose.yml`
|
||||
passa al Dockerfile. **Tolta alle 16:44 UTC** e demo ricostruita su 8.3.36;
|
||||
nell'env della demo e della produzione non c'è più nessuna `PHP_VERSION`. Se
|
||||
servisse di nuovo, va tolta subito dopo, altrimenti la demo resta indietro.
|
||||
|
||||
Prima della patch (21/10, 11:00 UTC circa) la mitigazione era stata
|
||||
**applicata anche ai container in esecuzione** di demo e produzione, ancora
|
||||
su 8.3.35 (`conf.d/zz-mitigazione-cve-2026-109618.ini`, php-fpm ricaricato).
|
||||
Con la ricostruzione quei file sono spariti, come previsto: ora vale il
|
||||
`php.ini` dell'immagine, con gli stessi valori.
|
||||
|
||||
### Password del sorvegliante dal pannello Utenti (21 ottobre 2026, solo su dev)
|
||||
|
||||
Richiesta dell'utente: poter reimpostare la password del sorvegliante dal
|
||||
pannello di amministrazione. Prima si poteva solo dalla pagina del gruppo; in
|
||||
*Utenti* la sua riga non aveva azioni. Ora ha "Nuova password temporanea" (con
|
||||
conferma) e "Apri il gruppo"; la password compare una volta sola in cima
|
||||
all'elenco, con l'email, fino a "Ho annotato la password". La logica è una
|
||||
sola, `GruppiServizio::nuovaPasswordTemporanea()`, usata anche da
|
||||
`GruppoShow`; rifiuta gli utenti che non sono sorveglianti (per loro c'è il
|
||||
modulo di modifica). Test in `GruppiServizioTest`.
|
||||
|
||||
### Anteprima dal numero in "Il mio gruppo" (21 ottobre 2026, solo su dev)
|
||||
|
||||
Richiesta dell'utente: anche il sorvegliante deve poter vedere l'anteprima del
|
||||
territorio, con un popup al clic del numero. Il numero diventa un pulsante
|
||||
(stesso aspetto, `button.tm-assegnato-numero` in `app.css`) che apre una
|
||||
finestra (`tm-modale`, stato Alpine `anteprima` sulla radice della vista) con
|
||||
la miniatura e "Apri la piantina (PDF)". La miniatura passa da una rotta
|
||||
nuova, `mio-gruppo.miniatura` (`TerritorioFileController::miniaturaGruppo`,
|
||||
`Territorio::miniaturaGruppoUrl()`), con la stessa regola di
|
||||
`mio-gruppo.pdf`: solo i territori del pacchetto del gruppo che l'utente
|
||||
sorveglia. La rotta normale delle miniature resta chiusa al sorvegliante.
|
||||
Senza miniatura né PDF il numero resta testo.
|
||||
|
||||
### Assegna aperta dalla Home: senza l'elenco dei territori assegnati (20 ottobre 2026)
|
||||
|
||||
Richiesta dell'utente: "quando provengo da un territorio dalla home, non
|
||||
mostrare lo storico assegnazioni". Avevo capito la scheda del territorio e
|
||||
ne avevo nascosto lo storico (commit `f1f3408`, poi annullato con un revert):
|
||||
intendeva la pagina *Assegna* aperta da "Assegna →" della Home, e come
|
||||
"storico" l'elenco "Territori Assegnati" sotto il modulo. Ora quel link porta
|
||||
`?dalla_home=1` e `Assegna::$elencoNascosto` toglie `ElencoAssegnati` (che
|
||||
così non si carica nemmeno). Dal menu o dalla scheda del territorio l'elenco
|
||||
c'è come prima. Non si usa il `?da=` del ritorno per capirlo: si trasmette di
|
||||
pagina in pagina.
|
||||
|
||||
### Storico assegnazioni in linea (20 ottobre 2026)
|
||||
|
||||
Richiesta dell'utente (screenshot della scheda territorio): le colonne dello
|
||||
storico cambiavano posizione da un anno all'altro, perché ogni anno era una
|
||||
tabella a sé dimensionata sul suo contenuto. Ora, nella scheda del territorio
|
||||
e in quella del proclamatore, è **una tabella sola** con un'intestazione,
|
||||
colonne a larghezza fissa (`table-fixed`, `w-[..%]`), e l'anno come riga di
|
||||
separazione `tr.tm-riga-gruppo` (in `app.css`, su telefono resta un titolo e
|
||||
non diventa una scheda). Ordine esplicito: anni dal più recente
|
||||
(`sortKeysDesc`), dentro l'anno per data di assegnazione. Nella scheda del
|
||||
proclamatore la colonna Campagna ora dice "Sì — …" / "No" come in quella del
|
||||
territorio, e c'è il badge "via gruppo …". Test: `StoricoAssegnazioniTest`.
|
||||
|
||||
### Rientro dall'elenco dei territori assegnati (20 ottobre 2026)
|
||||
|
||||
Richiesta dell'utente ("un tasto che va alla registrazione", dallo screenshot
|
||||
della pagina *Assegna* con l'elenco filtrato per proclamatore): in
|
||||
`ElencoAssegnati` ogni riga ha **Rientro** (solo con `territori.return`), che
|
||||
apre la registrazione del rientro e, con `data-ritorno`, torna all'elenco.
|
||||
Anche le righe senza PDF lo hanno. L'elenco ha un tetto di peso per riga
|
||||
(`AssegnaPrestazioniTest`, 2,6 KB) e il pulsante lo sforava di 21 byte: per
|
||||
starci l'indirizzo è relativo (`route(..., false)`, `data-ritorno` lo
|
||||
gestisce) e il pulsante sta sulla stessa riga del `<div>`, senza a capo né
|
||||
commenti dentro il ciclo (i commenti Blade spariscono, gli spazi no).
|
||||
|
||||
### Gruppi di servizio (19-20 ottobre 2026, solo su dev)
|
||||
|
||||
Richiesta dell'utente: un **gruppo di servizio** con un sorvegliante che,
|
||||
entrato con una sua utenza, distribuisce ai membri un pacchetto di territori
|
||||
e non vede nient'altro. Le decisioni sono cambiate più volte in due giorni:
|
||||
qui sotto **com'è oggi**, in fondo la storia.
|
||||
|
||||
**Come funziona**
|
||||
|
||||
- **Chi**: un gruppo ha un nome (unico), un sorvegliante (proclamatore
|
||||
attivo) e dei membri. Un proclamatore sta in **un solo** gruppo; il
|
||||
sorvegliante è anche membro e assegna solo ai membri, a sé compreso.
|
||||
Gruppi, membri e pacchetti li gestisce **solo l'amministratore** (menu
|
||||
*Gruppi di servizio*).
|
||||
- **Utenza del sorvegliante**: nasce con il gruppo. Email **fittizia**
|
||||
(`GruppiServizio::emailFittizia()`: iniziale del nome e cognome, minuscoli,
|
||||
senza accenti, spazi né apostrofi, su `GruppiServizio::DOMINIO_EMAIL` =
|
||||
`termanager.it` — Francesco Fontana → `ffontana@termanager.it`, numero in
|
||||
coda se già presa), la stessa convenzione degli altri utenti. Password
|
||||
temporanea, mostrata una volta: al primo accesso la pagina del cambio dice
|
||||
"Password temporanea" (`users.password_temporanea`, che il ripristino e
|
||||
l'import XML spengono, perché lì il motivo è il ripristino). Nessuna
|
||||
conferma dell'email (`User::hasVerifiedEmail()` vero per il sorvegliante),
|
||||
e dal profilo l'email non si cambia. Ruolo `sorvegliante`, con il solo
|
||||
permesso `gruppo.gestisci`; non compare nella pagina *Utenti*, dove le
|
||||
utenze legate a un proclamatore non si modificano né si eliminano.
|
||||
"Nuova password temporanea" dalla pagina del gruppo o dalla riga del
|
||||
sorvegliante nel pannello *Utenti* (stessa logica,
|
||||
`GruppiServizio::nuovaPasswordTemporanea()`: password provvisoria, cambio
|
||||
obbligato, "ricordami" azzerato, log `password_reimpostata`; le sessioni
|
||||
aperte si chiudono perché cambia l'hash). In *Utenti* è l'unica azione su
|
||||
quell'utenza, con "Apri il gruppo".
|
||||
- **Cosa vede**: `LimitaSorvegliante` (gruppo `web`) lascia a chi ha **solo**
|
||||
quel ruolo `mio-gruppo*`, `assegnazioni.rientra` (solo per il suo gruppo:
|
||||
`Rientra::autorizza`), profilo, cambio password e le azioni Livewire —
|
||||
`*livewire.*`, perché in Livewire 3.7 la rotta si chiama
|
||||
`default.livewire.update` (con `livewire.*` ogni clic era un 403, e
|
||||
`Livewire::test()` non passa dai middleware: un test chiama la rotta vera).
|
||||
La Home lo rimanda al gruppo, il resto è 403. La voce di menu "Il mio
|
||||
gruppo" guarda il **ruolo**, non `@can`: l'amministratore passa ogni `@can`
|
||||
(`Gate::before`).
|
||||
- **Pacchetto**: **non è un'assegnazione** ("la media non deve essere
|
||||
alterata"): i territori sono "In gruppo …" (stato `in_gruppo`), fuori da
|
||||
"Da assegnare" e dalla pagina *Assegna* (`Territorio::scopeInReparto`
|
||||
esclude `gruppo_servizio_id`). Si aggiungono in blocco dai territori in
|
||||
reparto in ordine di priorità (`Territorio::ordinaPerPriorita()`, la regola
|
||||
della Home), con ricerca, zona, tipologia, "solo prioritari" e "Seleziona
|
||||
tutti quelli mostrati" (`GruppoShow`).
|
||||
- **"Il mio gruppo"** (`MioGruppo`): sotto il titolo nome e cognome del
|
||||
sorvegliante; prima i territori da dare in ordine di priorità, poi i già
|
||||
dati dal più vecchio; filtri stato, zona, tipologia, "solo prioritari"
|
||||
(nell'indirizzo). Il sorvegliante assegna, manda il link (copia o
|
||||
condividi: trait `Concerns\InviaLink` + partial `assegnazioni/_invio-link`,
|
||||
gli stessi di *Assegna*), registra i rientri, apre la piantina, rimanda in
|
||||
reparto quelli non dati. Il numero del territorio apre l'anteprima (finestra
|
||||
Alpine nella vista, miniatura e collegamento al PDF).
|
||||
- **Assegnazione dal pacchetto**: un'**assegnazione normale**, con
|
||||
`gruppo_servizio_id` e `gruppo_nome` ("via gruppo …" in registro e schede).
|
||||
`GruppiServizio::assegna()` rilegge territorio e membro e ricontrolla il
|
||||
pacchetto sotto lock. Al **rientro** il territorio esce dal pacchetto e
|
||||
torna in reparto (hook `updated` in `Assegnazione`, da qualunque pagina).
|
||||
- **Scioglimento**: "Togli il ruolo e sciogli il gruppo", oppure il
|
||||
sorvegliante non è più attivo — reso non attivo, nel cestino o anonimizzato
|
||||
(`Proclamatore::booted`, eventi `updated` su `attivo` e `deleted`; l'elenco
|
||||
chiede conferma, la modifica avvisa sotto "Attivo"). `GruppiServizio::sciogli()`
|
||||
**elimina il gruppo**: i territori solo nel pacchetto tornano in reparto;
|
||||
quelli già dati restano ai membri e l'assegnazione ne conserva il nome
|
||||
(confermato dall'utente); i membri restano senza gruppo; l'**utenza del
|
||||
sorvegliante si elimina** con `User::eliminaConservandoStorico()` — la
|
||||
stessa eliminazione della pagina *Utenti* — che passa le sue assegnazioni a
|
||||
chi scioglie (o al primo amministratore) e lascia le sue voci nel registro
|
||||
a nome "Sorvegliante del gruppo …". Il motivo va nel registro
|
||||
(`gruppo_sciolto`, `motivo`). Un membro non attivo resta nel gruppo ma non
|
||||
riceve territori; nel cestino ne esce.
|
||||
- **Segnalazioni**: `Proclamatore::gruppo_etichetta` ("Gruppo Zisa" /
|
||||
"Sorvegliante del gruppo Zisa") come badge nell'elenco dei proclamatori
|
||||
(filtro *In un gruppo di servizio*, `filtroStato=gruppo`) e in testa alla
|
||||
scheda; nelle opzioni di `<x-scelta>` come `dettaglio` (Assegna, Registro),
|
||||
non come `motivo`: assegnare direttamente a un membro resta possibile. La
|
||||
scheda del proclamatore mostra il gruppo che guida
|
||||
(`GruppoServizio::riepilogo()`, email, password temporanea o no, ultimo
|
||||
accesso) o quello di cui è membro; il *Profilo* del sorvegliante il suo
|
||||
gruppo.
|
||||
- **Dati personali**: nel registro delle attività `User::tapActivity()`
|
||||
sostituisce nome ed email dell'utenza del sorvegliante con
|
||||
"proclamatore #id", come per tutti i proclamatori; nella tabella `users`
|
||||
restano in chiaro, come per ogni utente. Informativa privacy aggiornata.
|
||||
- **Backup**: l'XML porta gruppi, membri, pacchetto, provenienza delle
|
||||
assegnazioni (id e nome del gruppo) e il legame utenza ↔ proclamatore; da
|
||||
un file del 19-20 ottobre non si importano i gruppi con `<sciolto_at>` né
|
||||
le utenze con `<disattivato_at>`. L'azzeramento (*Zona pericolosa*)
|
||||
cancella anche i gruppi.
|
||||
|
||||
**Dove sta**
|
||||
|
||||
- Migrazioni: `2026_10_20_000010_gruppi_di_servizio` (tabella, colonne
|
||||
`gruppo_servizio_id` su proclamatori, territori e assegnazioni,
|
||||
`users.proclamatore_id` unico, permesso e ruolo; il seeder dà
|
||||
all'amministratore tutti i permessi **tranne** `gruppo.gestisci`),
|
||||
`000020` (`assegnazioni.gruppo_nome`, via `sciolto_at` e i gruppi sciolti),
|
||||
`000030` (via `users.username` e `users.disattivato_at` e le utenze
|
||||
disattivate, email fittizia a chi non l'aveva, email di nuovo obbligatoria),
|
||||
`000040` (`users.password_temporanea`).
|
||||
- Regole in `App\Services\GruppiServizio`; i componenti `Gruppi\GruppiIndex`,
|
||||
`Gruppi\GruppoShow` (amministratore) e `Gruppi\MioGruppo` (sorvegliante) le
|
||||
chiamano soltanto.
|
||||
- Test: `tests/Feature/GruppiServizioTest.php`.
|
||||
|
||||
**Com'è cambiato** (decisioni dell'utente, in ordine)
|
||||
|
||||
1. 19/10 — gruppo con membri; il pacchetto non conta nelle statistiche;
|
||||
utenza con email e password, senza conferma; togliere il ruolo scioglie il
|
||||
gruppo, che restava archiviato (`sciolto_at`) con l'utenza disattivata.
|
||||
2. 20/10 — sorvegliante non più attivo ⇒ gruppo sciolto; dettagli nella
|
||||
scheda e nel profilo; anche i membri segnalati.
|
||||
3. 20/10 — il gruppo sciolto non resta nello storico (si elimina); traccia
|
||||
"via gruppo" sulle assegnazioni confermata. Per qualche ora il sorvegliante
|
||||
è entrato con un nome utente ricavato dal nome del gruppo.
|
||||
4. 20/10 — al posto del nome utente l'email fittizia iniziale+cognome; sciolto
|
||||
il gruppo l'utenza si elimina ("torna proclamatore normale, non tenere
|
||||
traccia").
|
||||
5. 20/10 — "Password temporanea" al primo accesso; il 403 sulle azioni;
|
||||
priorità e filtri; nome del sorvegliante sotto il titolo.
|
||||
|
||||
### Proclamatori senza contatti; il link si copia o si condivide (19 ottobre 2026)
|
||||
|
||||
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: link alla piantina, invio dal telefono di chi assegna, mappe
|
||||
OpenStreetMap, conferma email, blocco dopo troppi tentativi. (Email e
|
||||
telefono dei proclamatori, che il primo testo citava, sono stati tolti la
|
||||
sera stessa; dal 20 ottobre il testo parla anche del gruppo di servizio e
|
||||
dell'utenza del sorvegliante.)
|
||||
- Titolare e base giuridica sono rimasti com'erano: sono valutazioni legali,
|
||||
da far rivedere a chi ne ha la competenza.
|
||||
- **Aperto — backup in chiaro**: il pacchetto di backup usa l'export XML, che
|
||||
legge i proclamatori dal modello e quindi esporta nome e cognome
|
||||
**decifrati**. L'informativa lo dice; valutare se cifrare l'archivio (per
|
||||
esempio con una password scelta al download).
|
||||
- `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli,
|
||||
i conteggi senza nomi e l'accesso solo amministratore.
|
||||
|
||||
### 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 (oggi trait `Concerns\InviaLink`,
|
||||
usato anche da "Il mio gruppo") con i canali. *Superato la sera stessa:
|
||||
WhatsApp ed email sono stati tolti, restano Copia link e Condividi… — vedi
|
||||
"Proclamatori senza contatti".* Com'era il primo giorno:
|
||||
- *Copia link*: sempre;
|
||||
- *WhatsApp*: solo se il proclamatore ha un numero utilizzabile;
|
||||
- *Email*: solo se ha un indirizzo **e** la posta funziona
|
||||
(`ConfigurazionePosta::funzionante`), inviata subito e mai in coda.
|
||||
|
||||
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
|
||||
possibile. Solo dopo la risposta si ricarica la pagina.
|
||||
- **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` (oggi `copia`/`condividi`,
|
||||
`Assegnazione::CANALI_ATTIVI`; `whatsapp`/`email` restano in
|
||||
`CANALI_LINK` solo per le assegnazioni già inviate così). Entrambe nell'XML.
|
||||
- Nel log `link_inviato` / `link_non_inviato` con canale e `proclamatore_id`:
|
||||
mai nomi.
|
||||
- **"Nuovo territorio"** (elenco territori) apre il designer delle piantine
|
||||
con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in
|
||||
disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto.
|
||||
L'editor è solo dell'amministratore: chi ha `territori.manage` ma non
|
||||
`settings.manage` resta sul form `territori.create`, che esiste ancora.
|
||||
|
||||
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026)
|
||||
|
||||
@@ -102,23 +624,19 @@ Richieste dell'utente, con le sue scelte:
|
||||
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la
|
||||
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era
|
||||
un valore non valido per Laravel 11: ora vuoto.
|
||||
- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome,
|
||||
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
|
||||
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
|
||||
considera italiano).
|
||||
- **Dopo l'assegnazione** si resta sulla pagina Assegna: in cima un riquadro
|
||||
per ogni assegnazione appena fatta con il link del PDF, "Invia su WhatsApp"
|
||||
(messaggio già scritto con il link) e la scelta "Sì, inviato / Non ancora",
|
||||
che imposta `link_sent`. Resta finché non si sceglie
|
||||
(`Assegna::DA_INVIARE` in sessione).
|
||||
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
|
||||
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`).
|
||||
L'admin crea l'utente con la password come prima; parte l'email di conferma
|
||||
(in italiano, `VerifyEmail::toMailUsing`); il link funziona anche senza
|
||||
login. Cambiare email (profilo o admin) la rende da confermare e rimanda il
|
||||
link. In Utenti: stato "da confermare" e "Reinvia conferma".
|
||||
Raggiungibili senza conferma: profilo, cambio password, pagina di conferma
|
||||
e — per l'amministratore — il server di posta.
|
||||
- ~~Proclamatori: email e telefono facoltativi~~ — *tolti il 19 ottobre 2026,
|
||||
vedi "Proclamatori senza contatti" qui sotto*.
|
||||
- **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
|
||||
dopo l'assegnazione" qui sotto*. Il riquadro in sessione
|
||||
(`Assegna::DA_INVIARE`) non esiste più.
|
||||
- **Conferma email obbligatoria** per tutti gli utenti (tranne il sorvegliante
|
||||
di gruppo), anche quelli già esistenti (la migrazione `2026_10_13_000010`
|
||||
azzera `email_verified_at`). L'admin crea l'utente con la password come
|
||||
prima; parte l'email di conferma (in italiano, `VerifyEmail::toMailUsing`);
|
||||
il link funziona anche senza login. Cambiare email (profilo o admin) la
|
||||
rende da confermare e rimanda il link. In Utenti: stato "da confermare" e
|
||||
"Reinvia conferma". Raggiungibili senza conferma: profilo, cambio password,
|
||||
pagina di conferma e — per l'amministratore — il server di posta.
|
||||
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
|
||||
creato da riga di comando nasce confermato.
|
||||
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
|
||||
@@ -126,8 +644,18 @@ Richieste dell'utente, con le sue scelte:
|
||||
|
||||
Provato nel browser in locale con mailpit come server configurato dall'app:
|
||||
email di prova e di conferma arrivate, link aperto senza login, account
|
||||
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
|
||||
e dati di prova rimossi dopo.
|
||||
sbloccato. Configurazione e dati di prova rimossi dopo.
|
||||
|
||||
### Modulo S-13, editor piantine, revisione (12 ottobre 2026)
|
||||
|
||||
In produzione dal 19 ottobre 2026 (erano su `dev` dal 12):
|
||||
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
|
||||
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione
|
||||
(integrità dati).
|
||||
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
|
||||
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
|
||||
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||||
- `426a952` — blocchi 2 e 3 della revisione (sotto).
|
||||
|
||||
### Revisione profonda: blocchi 2 e 3 chiusi
|
||||
|
||||
@@ -183,20 +711,92 @@ a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione.
|
||||
|
||||
Da qui la ristrutturazione descritta sotto.
|
||||
|
||||
### Luglio-agosto 2026
|
||||
|
||||
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto:
|
||||
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre
|
||||
di avanzamento reali e console di log (commit `f909791`, deploy verificato
|
||||
confrontando l'impronta degli asset). `MAX_UPLOAD_MB=2048` è impostato **solo**
|
||||
su develop; la produzione non è stata toccata e resta sul suo deploy
|
||||
precedente, senza quella variabile.
|
||||
|
||||
**Il 31/07 è nata la sotto-app "Piantine"** (nuova voce nel menu
|
||||
Amministrazione): KML dell'area città, editor dei poligoni su OpenStreetMap,
|
||||
immagini HD dal container `map-renderer` e PDF sul modulo S-12 con storico
|
||||
versioni. Architettura e agganci sono descritti in [Decisioni
|
||||
architetturali](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||||
Al primo deploy il compose costruisce anche l'immagine del renderer; non
|
||||
servono variabili nuove in Dokploy.
|
||||
|
||||
**Il 28/08 la sotto-app è stata rifatta e l'interfaccia ripassata.** Le piantine
|
||||
si sono staccate dai territori (una piantina vive anche scollegata, il territorio
|
||||
si crea dall'editor), il disegno è passato su una pagina a schermo intero, la
|
||||
cartolina S-12 è stata reimpaginata (confini sulla prima pagina, piantina al 98%
|
||||
sulla seconda) e la rigenerazione massiva è stata rimossa. In più il sistema
|
||||
visivo è stato ripassato su tutta l'applicazione, non solo qui: vedi [Il sistema
|
||||
visivo sta nei token](#il-sistema-visivo-sta-nei-token-non-nelle-viste).
|
||||
|
||||
Nello stesso giro è emerso e corretto un guasto d'ambiente che bloccava **tutti**
|
||||
gli upload in sviluppo: vedi [`HTTP_HOST` a nginx deve conservare la
|
||||
porta](#http_host-a-nginx-deve-conservare-la-porta).
|
||||
|
||||
Il 30/07 sono state fatte due cose.
|
||||
|
||||
**La suite di test, che prima non esisteva**: 79 test eseguibili con un comando,
|
||||
senza dipendere da altri servizi. Sono **verdi** ma coprono ancora una frazione
|
||||
della logica di business — vedi *Cosa resta aperto*. Non fanno parte
|
||||
dell'immagine di produzione e non girano su un deploy: sono uno strumento
|
||||
locale.
|
||||
|
||||
**La pagina "Backup e import" è stata riorganizzata**: cinque zone numerate con
|
||||
un indice che ci salta sopra evidenziandole, un solo sistema di pulsanti (con
|
||||
gli stati di focus, che prima non c'erano), e **una sola console** per tutte le
|
||||
operazioni, agganciata in fondo alla finestra, al posto dei cinque riquadri di
|
||||
log sparsi. L'azzeramento è diventato un componente a sé per poter stare in
|
||||
fondo alla pagina. Tutto verificato lato server dai test; **il comportamento nel
|
||||
browser — apertura automatica della console, scorrimento, lampeggio delle zone —
|
||||
non è stato provato**, perché richiede un browser: è la prima cosa da guardare
|
||||
aprendo la pagina.
|
||||
|
||||
Il ripristino dall'interfaccia resta impraticabile per pacchetti grandi finché
|
||||
non si decide come aggirare Cloudflare: è la prima voce di *Cosa resta aperto*
|
||||
e l'unica che richiede una decisione, non del lavoro.
|
||||
|
||||
---
|
||||
|
||||
## Dove gira davvero
|
||||
|
||||
Due ambienti su Dokploy, stesso progetto `TerManager2`, entrambi alimentati dal
|
||||
repository Gitea `francesco/termanager2` con **autoDeploy attivo**.
|
||||
repository Gitea `francesco/termanager2` su `repository.pyconetwork.it` (il
|
||||
vecchio `git.pyconetwork.it` non è più la sorgente).
|
||||
|
||||
| Ambiente | Branch | Dominio | Compose (`appName`) |
|
||||
|--------------|--------|-------------------------------------|----------------------------|
|
||||
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-vizcxr` |
|
||||
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-vbekis` |
|
||||
| Ambiente | Branch | Dominio | Compose (nome / `appName` / `composeId`) |
|
||||
|--------------|--------|-------------------------------------|------------------------------------------|
|
||||
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-production` / `termanager2-core-vizcxr` / `vh_NtgaPyfQintCbcB3p6` |
|
||||
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-demo` / `termanager2-core-vbekis` / `20DoOKciTzmhlPWdcqcxz` |
|
||||
|
||||
Conseguenze pratiche, tutte imparate sul campo:
|
||||
|
||||
- **L'autodeploy non parte.** È attivo in Dokploy, ma dal 19 ottobre 2026
|
||||
nessun push ha avviato un deploy (il webhook di Gitea è da verificare):
|
||||
dopo un push si aspettano un paio di minuti e, se in `deployment` non
|
||||
compare niente di nuovo, si avvia a mano (`compose.deploy` via API, o
|
||||
*Deploy* nell'interfaccia). Controllare prima di avviare, per non fare
|
||||
deploy doppi.
|
||||
|
||||
- **Test sul server, prima del push.** In locale (Windows) non ci sono PHP,
|
||||
Docker né Node: la suite gira sul server Dokploy con una *schedule* di tipo
|
||||
`dokploy-server` usa e getta, che costruisce lo stage `test` del Dockerfile
|
||||
e lancia anche `node --test tests/js/`. Per provare un commit non ancora
|
||||
pubblicato (il push su `dev` lo pubblicherebbe): `git bundle create x
|
||||
<commit della checkout>..dev`, bundle in base64 dentro lo script, `git
|
||||
clone` della checkout di Dokploy in `/tmp` e `git fetch` del bundle. La
|
||||
checkout di Dokploy (`/etc/dokploy/compose/<appName>/code`) è **shallow** e
|
||||
può essere indietro rispetto a `origin/dev` (i commit di sola
|
||||
documentazione non la aggiornano): il bundle parte dal suo `git log -1`. La
|
||||
schedule gira dentro il container di Dokploy: `docker run -v /tmp/...` non
|
||||
vede quei file, si passano con `tar | docker run -i`.
|
||||
|
||||
- **Il branch decide l'ambiente.** Pubblicare su `dev` non tocca la produzione,
|
||||
che segue `main`. È la separazione su cui contare quando si vuole provare
|
||||
qualcosa senza rischi.
|
||||
@@ -1171,7 +1771,9 @@ blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
|
||||
docker compose --profile test run --build --rm test
|
||||
```
|
||||
|
||||
**Coperto oggi — 294 test, tutti verdi:**
|
||||
**Coperto oggi — 613 test PHP e 13 JavaScript, tutti verdi (21/10/2026).**
|
||||
Senza Docker in locale si fanno girare sul server: vedi [Dove gira
|
||||
davvero](#dove-gira-davvero). I file storici, con cosa proteggono:
|
||||
|
||||
| File | Cosa protegge |
|
||||
|------|---------------|
|
||||
@@ -1188,6 +1790,23 @@ docker compose --profile test run --build --rm test
|
||||
| `tests/Unit/PiantinaRendererTest.php` | Il client del renderer: formato, qualita' e lato massimo davvero spediti, JPEG salvato con l'estensione giusta, il file di un formato precedente rimosso invece che lasciato orfano, errori e risposte non-immagine trasformati in messaggi leggibili, geometria corrotta bloccata prima della chiamata |
|
||||
| `tests/Feature/HomeMediaDurataTest.php` | La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio |
|
||||
|
||||
Arrivati dopo (ottobre 2026), uno per funzione: `GruppiServizioTest` (gruppi
|
||||
di servizio, compresa la rotta vera delle azioni Livewire del sorvegliante,
|
||||
priorità e filtri), `RichiesteTest` (modulo pubblico e anti-spam, nomi
|
||||
simili, Home, richiesta evasa da Assegna e Rientra, pulizia, cartacei, S-13
|
||||
per email e la regola dell'ultima domenica), `InvioLinkTest` (invio del link,
|
||||
pulsante Rientro, Assegna dalla Home), `StoricoAssegnazioniTest`, `SceltaRicercaTest` e
|
||||
`tests/js/scelta.test.mjs` (scelta con ricerca), `RitornoTest`,
|
||||
`AssegnaPrestazioniTest` (peso della pagina Assegna e delle righe
|
||||
dell'elenco), `AssegnazioneSovrapposizioneTest`, `CampagnaCalcoliTest`,
|
||||
`CambioPasswordTest`, `ConfermaEmailTest`, `PostaElettronicaTest`,
|
||||
`PrivacyTest`, `LogSenzaNomiTest`, `AvvisiTest`, `CodaTest`,
|
||||
`UltimoAmministratoreTest`, i test di import (`XmlExchangeImportTest`,
|
||||
`ImportXmlFileOrfaniTest`, `ImportPdfTerritoriTest`, `PostoUnicoImportTest`,
|
||||
`RipristinoBackupTest`) e dei file (`TerritorioFileTest`,
|
||||
`TerritoriFileOrfaniTest`, `PubblicazionePiantinaTest`,
|
||||
`AzzeramentoPiantineTest`, `PiantineAreaTest`).
|
||||
|
||||
Il primo giro ha già trovato tre difetti reali, tutti corretti: il warning di
|
||||
`ZipArchive::addFile()` che copriva il messaggio d'errore vero, il `GREATEST()`
|
||||
in migrazione, e `@disabled` dentro un tag di componente (vedi *Bug corretti*).
|
||||
@@ -1202,22 +1821,25 @@ nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
|
||||
entry, dimensione della singola entry, totale decompresso. Sono tre `if` che
|
||||
nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio.
|
||||
- `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**:
|
||||
l'ordine staging → database → file, il `failed()` che libera il posto, la
|
||||
sopravvivenza dell'amministratore che ha chiesto l'azzeramento, il
|
||||
`password_change_required` acceso su tutte le righe. È la logica il cui
|
||||
errore si scopre solo quando è troppo tardi.
|
||||
- `Livewire/Settings/XmlExchange`: il **round-trip** export → import senza
|
||||
perdite, che è il presidio contro il tipo di bug già visto con `legacy_id`.
|
||||
in parte coperti (`RipristinoBackupTest`, `ZonaPericolosaTest`,
|
||||
`AzzeramentoPiantineTest`, l'azzeramento in `GruppiServizioTest`); restano
|
||||
da verificare l'ordine staging → database → file e il `failed()` che libera
|
||||
il posto. È la logica il cui errore si scopre solo quando è troppo tardi.
|
||||
- `Livewire/Settings/XmlExchange`: il round-trip export → import è coperto per
|
||||
le parti nuove (gruppi, invio del link) e da `XmlExchangeImportTest`, ma
|
||||
non campo per campo su tutto il dataset.
|
||||
- Le regole di business: anno teocratico (`AnnoTeocratico::perData` a cavallo
|
||||
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`,
|
||||
priorità automatica, percentuale di percorrenza delle campagne.
|
||||
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`.
|
||||
La priorità (`Territorio::ordinaPerPriorita`) e la percorrenza delle
|
||||
campagne ora hanno i loro test.
|
||||
- Il permesso come contratto: che un `assistente` **non** possa modificare o
|
||||
eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È
|
||||
scritto in tre posti (rotta, componente, vista) e un test di richiesta lo
|
||||
verificherebbe tutto insieme.
|
||||
|
||||
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
|
||||
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
|
||||
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: resta solo
|
||||
l'ordinamento di `Registro::render()`, che usa SQL specifico di MySQL (la Home
|
||||
non più: vedi *La suite di test ha un'immagine sua*).
|
||||
|
||||
### 2. Race su doppia assegnazione — chiusa il 09/10/2026
|
||||
|
||||
@@ -1284,6 +1906,18 @@ e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e
|
||||
la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina
|
||||
lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca.
|
||||
|
||||
### 8. L'autodeploy non parte
|
||||
|
||||
Dal 19 ottobre 2026 nessun push su `dev` o `main` ha avviato un deploy, pur
|
||||
con autoDeploy attivo in Dokploy. Da verificare il webhook del repository in
|
||||
Gitea (`repository.pyconetwork.it`). Intanto ogni deploy si avvia a mano:
|
||||
vedi [Dove gira davvero](#dove-gira-davvero).
|
||||
|
||||
### 9. Gruppi di servizio e lavori del 20 ottobre non ancora in produzione
|
||||
|
||||
Sono sulla demo e l'utente li sta provando lì; per il rilascio serve il suo
|
||||
ok. Come portarli in produzione: [Stato attuale](#stato-attuale).
|
||||
|
||||
---
|
||||
|
||||
## Trappole da conoscere
|
||||
@@ -1327,6 +1961,14 @@ stringa invece di proteggere l'apostrofo, perché fra il file e l'attributo non
|
||||
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
|
||||
da nessuna parte; apostrofi italiani solo dentro backtick.
|
||||
|
||||
**In una vista Blade la forma in linea `@php(...)` non va messa prima di un
|
||||
blocco `@php … @endphp`.** Blade estrae i blocchi `@php … @endphp` per primi,
|
||||
con un'espressione che parte dalla prima `@php` che trova: se è quella in
|
||||
linea, ingoia tutto fino al primo `@endphp` e la vista non compila più
|
||||
("unexpected token class"). Successo in *Il mio gruppo* il 19/10/2026. E
|
||||
vale anche dentro i commenti `{{-- --}}`, che Blade toglie *dopo*: lì la
|
||||
parola `@php` non va scritta.
|
||||
|
||||
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
|
||||
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
|
||||
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.
|
||||
@@ -1370,7 +2012,8 @@ cp .env.example .env
|
||||
# SESSION_SECURE_COOKIE=false, e le tre password
|
||||
docker network create dokploy-network
|
||||
|
||||
# 0. La suite passa (non serve nient'altro acceso, ~15 s)
|
||||
# 0. La suite passa (non serve nient'altro acceso). Senza Docker in locale:
|
||||
# sul server Dokploy, vedi "Dove gira davvero"
|
||||
docker compose --profile test run --build --rm test
|
||||
|
||||
# 1. L'immagine builda
|
||||
|
||||
336
README.md
336
README.md
@@ -1,8 +1,11 @@
|
||||
# TerManager2
|
||||
|
||||
Applicazione web per la gestione dell'assegnazione e del rientro dei territori
|
||||
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali
|
||||
e registro storico completo.
|
||||
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali,
|
||||
gruppi di servizio (un sorvegliante distribuisce ai membri un pacchetto di
|
||||
territori), un modulo pubblico con cui i proclamatori chiedono un territorio o
|
||||
ne annunciano la riconsegna, l'elenco delle cartoline cartacee fuori, il
|
||||
modulo S-13 per email ogni mese e registro storico completo.
|
||||
|
||||
I dati personali dei proclamatori sono **cifrati nel database**, e ogni
|
||||
operazione è tracciata in un **audit log** consultabile.
|
||||
@@ -49,7 +52,7 @@ eseguito né `composer install` né `npm build` all'avvio.
|
||||
|----------------|------------------------------------------------|---------|
|
||||
| `app` | nginx + php-fpm — l'applicazione | via Traefik |
|
||||
| `queue-worker` | Job in coda (import PDF massivi) | no |
|
||||
| `scheduler` | Task pianificati (pulizia audit log) | no |
|
||||
| `scheduler` | Task pianificati (pulizie, S-13 mensile) | no |
|
||||
| `mariadb` | Database | no |
|
||||
| `redis` | Cache, sessioni, code | no |
|
||||
|
||||
@@ -338,6 +341,16 @@ Cosa serve sapere:
|
||||
`CAST(... AS UNSIGNED)` nell'ordinamento del Registro — e che quindi va
|
||||
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
|
||||
@@ -401,23 +414,26 @@ Configurabili dall'interfaccia (**Impostazioni**, permesso `settings.manage`),
|
||||
non da variabili d'ambiente:
|
||||
|
||||
| Impostazione | Descrizione |
|
||||
|--------------------------------|------------------------------------------------------|
|
||||
| Nome congregazione | Mostrato nell'intestazione e nei PDF |
|
||||
| Logo | Immagine nell'intestazione |
|
||||
| URL pubblico base | Base dei link PDF condivisibili |
|
||||
| Giorni giacenza "da assegnare" | Soglia oltre cui un territorio è da assegnare |
|
||||
| Mesi giacenza "prioritari" | Soglia oltre cui un territorio diventa prioritario |
|
||||
| Giorni per "smarrito" | Soglia oltre cui un'assegnazione è da rientrare |
|
||||
| Elementi nelle liste della home| Quanti record mostrare nelle tre liste rapide |
|
||||
| Validità link PDF (mesi) | Scadenza dei link pubblici alle cartoline |
|
||||
| Download PDF visibile | Mostra o nasconde il pulsante di download |
|
||||
| Retention audit log (giorni) | Dopo quanti giorni i log vengono cancellati |
|
||||
|------------------------------------|------------------------------------------------------|
|
||||
| Nome congregazione | Mostrato nell'intestazione, nei PDF e nell'informativa |
|
||||
| Soglia da assegnare (giorni) | Dopo quanti giorni dal rientro un territorio è "da assegnare" |
|
||||
| Soglia priorità (giorni) | Dopo quanti giorni dal rientro un territorio diventa prioritario |
|
||||
| Soglia rientro (giorni) | Oltre questa un'assegnazione è "da rientrare"; è anche la durata del link alla piantina |
|
||||
| Limite liste Home | Quanti territori mostrare nelle due liste della Home |
|
||||
| "Scarica PDF" nel viewer | Mostra o nasconde il pulsante di download a chi apre il link |
|
||||
| Conservazione audit (giorni) | Dopo quanti giorni le voci del registro delle attività si cancellano |
|
||||
|
||||
Il server di posta ha una pagina sua (*Impostazioni → Server di posta*). Il
|
||||
logo nell'intestazione, se c'è, arriva da un backup: la pagina non lo carica.
|
||||
|
||||
---
|
||||
|
||||
## Aggiornamenti
|
||||
|
||||
Push su `main` → **Deploy** su Dokploy. Ad ogni avvio il container:
|
||||
Push su `main` → **Deploy** su Dokploy. Se l'autodeploy non parte entro un
|
||||
paio di minuti (succede: vedi HANDOFF, *Dove gira davvero*), il deploy si
|
||||
avvia a mano dall'interfaccia o con `compose.deploy`. Ad ogni avvio il
|
||||
container:
|
||||
|
||||
1. ricrea le directory di storage e risolve `APP_KEY`
|
||||
2. attende il database
|
||||
@@ -616,12 +632,21 @@ docker exec -it <app> php artisan audit:cleanup
|
||||
docker exec -it <app> php artisan backup:cleanup
|
||||
docker exec -it <app> php artisan backup:cleanup --ore=0 # tutti
|
||||
|
||||
# Pulizia manuale delle richieste dal modulo pubblico gestite da più di 90 giorni
|
||||
docker exec -it <app> php artisan richieste:pulizia
|
||||
|
||||
# Mandare subito il modulo S-13 agli amministratori (anno in corso, o un altro)
|
||||
docker exec -it <app> php artisan registro:invia-s13
|
||||
docker exec -it <app> php artisan registro:invia-s13 --anno=2025-2026
|
||||
|
||||
# Svuotare le cache
|
||||
docker exec -it <app> php artisan optimize:clear
|
||||
```
|
||||
|
||||
Entrambe le pulizie girano già da sole ogni notte (container `scheduler`); i
|
||||
comandi qui sopra servono solo per anticiparle.
|
||||
Le pulizie girano già da sole ogni notte e il modulo S-13 parte da solo
|
||||
l'ultima domenica del mese alle 8 (container `scheduler`); i comandi qui sopra
|
||||
servono solo per anticiparli. Senza un server di posta funzionante il modulo
|
||||
S-13 non parte (il comando lo dice).
|
||||
|
||||
---
|
||||
|
||||
@@ -677,14 +702,24 @@ docker compose --profile dev up -d --build
|
||||
- Anno teocratico corrente (creato automaticamente, settembre → agosto)
|
||||
- Media mensile di percorrenza e durata media delle assegnazioni
|
||||
- Campagna attiva con barra di progresso
|
||||
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
|
||||
**da rientrare**
|
||||
- Due liste rapide con azione diretta: **da assegnare** (prima i prioritari,
|
||||
con il badge ★, poi quelli fermi da più tempo) e **da rientrare**; quando
|
||||
l'elenco è troncato, **"Vedi tutti"** è un pulsante in testata che apre
|
||||
l'elenco territori già filtrato. "Assegna →" apre la pagina *Assegna* con
|
||||
il territorio scelto e senza l'elenco dei territori assegnati
|
||||
- **Richieste** dal modulo pubblico (amministratore e assistenti): vedi
|
||||
[Richieste dal modulo pubblico](#richieste-dal-modulo-pubblico)
|
||||
|
||||
### Territori
|
||||
- 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)
|
||||
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico
|
||||
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico:
|
||||
una tabella sola con le colonne in linea, l'anno come riga di separazione,
|
||||
dal più recente (lo stesso nella scheda del proclamatore)
|
||||
- Link PDF pubblico condivisibile, a scadenza configurabile
|
||||
- Soft delete con cestino (ripristino o eliminazione definitiva)
|
||||
- Import massivo di PDF da cartella o archivio ZIP, elaborato in background
|
||||
@@ -696,12 +731,124 @@ docker compose --profile dev up -d --build
|
||||
- Soft delete con cestino
|
||||
|
||||
### 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
|
||||
- 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; ogni territorio ha il pulsante **Rientro**, che
|
||||
porta alla registrazione del rientro e poi torna all'elenco. Aperta da
|
||||
"Assegna →" della Home, la pagina mostra solo il modulo, senza l'elenco
|
||||
- Rientro con calcolo automatico dei giorni
|
||||
- **Prompt campagna**: se il rientro ricade nel periodo di una campagna, viene
|
||||
chiesto se conteggiarlo
|
||||
- **Prompt campagna**: se il territorio era fuori durante una campagna viene
|
||||
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)
|
||||
- **Cartolina cartacea**: assegnando (anche il sorvegliante di gruppo) si può
|
||||
spuntare *Ha chiesto la cartolina cartacea*. Il segno sta sull'assegnazione:
|
||||
nell'elenco degli assegnati compare l'etichetta "cartaceo" e la pagina
|
||||
**Cartacei** (menu, sotto *Assegnazioni*) elenca i territori cartacei fuori
|
||||
e a chi, con i giorni e il pulsante Rientro. Al rientro escono dall'elenco
|
||||
|
||||
### Richieste dal modulo pubblico
|
||||
- **Modulo pubblico** all'indirizzo `/richiesta`, senza login (l'indirizzo
|
||||
completo è scritto nella Home, da passare ai proclamatori): nome e cognome,
|
||||
*nuovo territorio* o *riconsegna*, una nota. Nella riconsegna il territorio
|
||||
si sceglie con il **completamento automatico**: si scrive il numero o la
|
||||
zona e compaiono i territori (attivi, o disattivati ma ancora fuori), con
|
||||
numero e zona soltanto; si accetta solo un territorio dell'elenco. A parte
|
||||
questo elenco il modulo non mostra nessun dato già presente: né nomi, né a
|
||||
chi è assegnato un territorio o se lo è
|
||||
- **Contro lo spam**: un campo trappola nascosto, un tempo minimo fra apertura
|
||||
e invio (in questi due casi risponde "inviata" senza salvare) e al massimo 5
|
||||
richieste all'ora dallo stesso indirizzo
|
||||
- **Avviso per email** ad amministratori e assistenti (permesso
|
||||
`richieste.gestisci`) con l'email confermata, se il server di posta
|
||||
funziona; la richiesta resta comunque nella Home
|
||||
- **Nella Home**, riquadro *Richieste*: per un nuovo territorio i proclamatori
|
||||
attivi con un nome simile a quello scritto (uguale, iniziale, una lettera
|
||||
sbagliata, in qualsiasi ordine e senza accenti), ognuno porta al modulo
|
||||
*Assegna* già con quel proclamatore; *Nessuno di questi* apre il modulo
|
||||
vuoto. Per una riconsegna, a chi risulta assegnato il territorio e *Registra
|
||||
rientro →*. Fatta l'assegnazione o il rientro, la richiesta risulta evasa;
|
||||
altrimenti si può **scartare**
|
||||
- Nome e nota sono cifrati nel database e non vanno nel registro delle
|
||||
attività; le richieste gestite si cancellano da sole dopo 90 giorni
|
||||
(`richieste:pulizia`, ogni notte)
|
||||
|
||||
### Gruppi di servizio
|
||||
- Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome,
|
||||
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
|
||||
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
|
||||
anche membro
|
||||
- Creando il gruppo il sorvegliante riceve un'**utenza** con un'**email
|
||||
fittizia** creata dall'applicazione: iniziale del nome e cognome sul
|
||||
dominio `termanager.it` (Francesco Fontana → `ffontana@termanager.it`; se
|
||||
è già presa, un numero in coda), e una password temporanea. Si vedono una
|
||||
volta sola; la password va cambiata al primo accesso (la pagina dice
|
||||
"Password temporanea", non "Ripristino da backup"). L'email non si
|
||||
chiede, non riceve posta e non va confermata. Due gruppi non possono avere
|
||||
lo stesso nome. Se perde la password, l'amministratore ne genera un'altra
|
||||
temporanea dalla pagina del gruppo o dal pannello *Utenti* ("Nuova password
|
||||
temporanea" sulla riga del sorvegliante): si vede una volta sola, va
|
||||
cambiata al primo accesso, e le sessioni aperte con quella vecchia si
|
||||
chiudono
|
||||
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto,
|
||||
**in ordine di priorità** come "Da assegnare" nella Home (prima i
|
||||
prioritari, poi quelli fermi da più tempo), con ricerca, filtri per zona,
|
||||
tipologia e "solo prioritari", e "Seleziona tutti quelli mostrati".
|
||||
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
|
||||
cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
|
||||
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo
|
||||
(ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne
|
||||
nasce un'**assegnazione normale**, che conta come le altre ed è segnata
|
||||
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
|
||||
apre la piantina e rimanda in reparto quelli non ancora dati. Il suo
|
||||
pacchetto è in ordine di priorità (prima i da dare, prioritari in testa,
|
||||
poi quelli già dati) e si filtra per stato, zona, tipologia e "solo
|
||||
prioritari"; sotto il nome del gruppo compaiono nome e cognome del
|
||||
sorvegliante. Toccando il numero di un territorio si apre l'**anteprima**
|
||||
(la miniatura della piantina) con il collegamento al PDF
|
||||
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
|
||||
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante e **elimina il
|
||||
gruppo**, che non resta nello storico: i territori solo nel pacchetto
|
||||
tornano in reparto, i membri si liberano e l'**utenza del sorvegliante si
|
||||
elimina** (anche una sessione aperta si chiude): torna un proclamatore come
|
||||
gli altri. Le assegnazioni che aveva fatto passano a chi scioglie il
|
||||
gruppo; nel registro delle attività le sue voci restano a nome
|
||||
"Sorvegliante del gruppo …". I territori già dati restano ai membri come
|
||||
assegnazioni qualsiasi, che del gruppo conservano solo il nome ("via
|
||||
gruppo …"). Per ripartire si crea un gruppo nuovo
|
||||
- **Un sorvegliante che non è più attivo scioglie il gruppo**, come togliergli
|
||||
il ruolo: succede se lo si rende non attivo (dall'elenco o dalla modifica),
|
||||
se lo si sposta nel cestino o lo si anonimizza. Prima di disattivarlo o
|
||||
cestinarlo dall'elenco si chiede conferma; riattivarlo non ricostruisce il
|
||||
gruppo. Un membro non attivo resta nel gruppo ma non riceve territori; nel
|
||||
cestino esce dal gruppo
|
||||
- **Chi fa parte di un gruppo è segnalato**: badge "Gruppo …" o
|
||||
"Sorvegliante del gruppo …" nell'elenco dei proclamatori (con il filtro
|
||||
*In un gruppo di servizio*) e in testa alla scheda; nella scelta del
|
||||
proclamatore (Assegna, Registro) la nota "gruppo …" accanto al nome, che
|
||||
resta comunque sceglibile
|
||||
- **Dettagli del gruppo**: la scheda del proclamatore dice se è sorvegliante
|
||||
(gruppo, membri, territori nel pacchetto e da dare, assegnazioni nate dal
|
||||
gruppo, email per entrare, stato della password e ultimo accesso) o membro; il
|
||||
sorvegliante ritrova gli stessi numeri nel suo
|
||||
*Profilo*
|
||||
|
||||
### Campagne
|
||||
- CRUD con date di inizio e fine
|
||||
@@ -712,6 +859,10 @@ docker compose --profile dev up -d --build
|
||||
### Registro assegnazioni
|
||||
- Filtri per anno teocratico, zona, tipologia, stato e ricerca testuale
|
||||
- Colonne: territorio, proclamatore, date, giorni, campagna
|
||||
- **Modulo S-13 per email**: l'ultima domenica di ogni mese alle 8 il modulo
|
||||
S-13 dell'anno di servizio in corso parte in PDF agli amministratori,
|
||||
all'email della loro utenza se confermata (`registro:invia-s13`, anche a
|
||||
mano). Non passa dalla coda: il PDF contiene i nomi dei proclamatori
|
||||
|
||||
### Audit log
|
||||
- Tracciamento automatico su tutti i modelli
|
||||
@@ -738,24 +889,33 @@ docker compose --profile dev up -d --build
|
||||
|
||||
## Ruoli e permessi
|
||||
|
||||
Tre ruoli con permessi granulari (spatie/laravel-permission):
|
||||
Quattro ruoli con permessi granulari (spatie/laravel-permission):
|
||||
|
||||
| Permesso | Amministratore | Assistente | Operatore |
|
||||
|-----------------------|:--------------:|:----------:|:---------:|
|
||||
| `settings.manage` | ✅ | | |
|
||||
| `territori.manage` | ✅ | | |
|
||||
| `proclamatori.manage` | ✅ | ✅ | |
|
||||
| `campagne.manage` | ✅ | ✅ | |
|
||||
| `registro.view` | ✅ | ✅ | |
|
||||
| `registro.export` | ✅ | | |
|
||||
| `territori.assign` | ✅ | ✅ | ✅ |
|
||||
| `territori.return` | ✅ | ✅ | ✅ |
|
||||
| `audit.view` | ✅ | | |
|
||||
| `audit.export` | ✅ | | |
|
||||
| Permesso | Amministratore | Assistente | Operatore | Sorvegliante |
|
||||
|-----------------------|:--------------:|:----------:|:---------:|:------------:|
|
||||
| `settings.manage` | ✅ | | | |
|
||||
| `territori.manage` | ✅ | | | |
|
||||
| `proclamatori.manage` | ✅ | ✅ | | |
|
||||
| `campagne.manage` | ✅ | ✅ | | |
|
||||
| `registro.view` | ✅ | ✅ | | |
|
||||
| `registro.export` | ✅ | | | |
|
||||
| `territori.assign` | ✅ | ✅ | ✅ | |
|
||||
| `territori.return` | ✅ | ✅ | ✅ | |
|
||||
| `audit.view` | ✅ | | | |
|
||||
| `audit.export` | ✅ | | | |
|
||||
| `richieste.gestisci` | ✅ | ✅ | | |
|
||||
| `gruppo.gestisci` | | | | ✅ |
|
||||
|
||||
L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
|
||||
Il menu laterale mostra solo le voci per cui l'utente ha i permessi.
|
||||
|
||||
Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando
|
||||
un gruppo di servizio e sparisce sciogliendolo. Della sua utenza, in *Utenti*,
|
||||
si può solo generare una nuova password temporanea (e aprire il gruppo). Chi ha solo quel ruolo è
|
||||
confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro
|
||||
dei territori del gruppo e al profilo; le sue azioni ricontrollano che
|
||||
territorio e assegnazione siano del suo gruppo.
|
||||
|
||||
I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi
|
||||
una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
|
||||
@@ -764,13 +924,14 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
## Sicurezza e GDPR
|
||||
|
||||
### Sicurezza
|
||||
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con AES-256-CBC
|
||||
tramite `APP_KEY`
|
||||
- **Cifratura a riposo**: nome e cognome dei proclamatori cifrati con
|
||||
AES-256-CBC tramite `APP_KEY`
|
||||
- **Password**: hash bcrypt
|
||||
- **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle
|
||||
richieste Livewire
|
||||
- **CSRF**: token su tutti i form
|
||||
- **Rate limiting**: sul login
|
||||
- **Rate limiting**: sul login e sul modulo pubblico delle richieste (5
|
||||
all'ora per indirizzo, più campo trappola e tempo minimo contro lo spam)
|
||||
- **Host header**: accettate solo richieste il cui `Host` corrisponde a `APP_URL`
|
||||
- **Scheme forwarding**: lo schema è deciso dal server, non dall'header del
|
||||
client, per non falsare la validazione dei link firmati
|
||||
@@ -779,15 +940,41 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
|
||||
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
|
||||
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
|
||||
- **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
|
||||
- **Audit trail**: registro completo delle operazioni
|
||||
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
||||
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
||||
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
||||
in chiaro
|
||||
in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome
|
||||
di un proclamatore)
|
||||
- **Richieste dal modulo pubblico**: nome e nota cifrati come i dati dei
|
||||
proclamatori, fuori dal registro delle attività e dalla coda (l'email di
|
||||
avviso parte subito); cancellate 90 giorni dopo essere state gestite. Non
|
||||
sono nei backup (né XML né `.zip`, che contiene lo stesso XML): sono
|
||||
messaggi in arrivo, non dati gestionali, e un ripristino non le tocca.
|
||||
L'azzeramento (*Zona pericolosa*) le cancella
|
||||
- **Email**: il modulo S-13 (con i nomi) va solo agli amministratori con
|
||||
l'email confermata, l'avviso delle richieste solo a chi le gestisce
|
||||
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
|
||||
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
|
||||
in un luogo protetto e cancellati quando non servono
|
||||
|
||||
---
|
||||
|
||||
@@ -795,15 +982,17 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||
|
||||
| Tabella | Descrizione |
|
||||
|-------------------|--------------------------------------------------------------|
|
||||
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova |
|
||||
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova (`password_temporanea` dice se il motivo è una password provvisoria data dall'amministratore invece di un ripristino); `proclamatore_id` lega l'utenza del sorvegliante (email fittizia) al suo proclamatore; sciolto il gruppo l'utenza si elimina |
|
||||
| `settings` | Configurazione singleton |
|
||||
| `zone` | Zone territoriali |
|
||||
| `tipologie` | Tipologie di territorio |
|
||||
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete |
|
||||
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
|
||||
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
|
||||
| `gruppi_servizio` | Gruppi di servizio attivi: nome (unico), sorvegliante. Sciolto, il gruppo si elimina |
|
||||
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo |
|
||||
| `anni_teocratici` | Periodi settembre → agosto |
|
||||
| `campagne` | Periodi speciali con date e descrizione |
|
||||
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna |
|
||||
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo ancora attivo, `gruppo_nome` che resta anche dopo lo scioglimento; `cartaceo` se il proclamatore ha chiesto la cartolina cartacea |
|
||||
| `richieste` | Richieste dal modulo pubblico: tipo (`nuovo`/`riconsegna`), nome e nota cifrati, numero del territorio, stato (`aperta`/`evasa`/`scartata`), l'assegnazione che l'ha evasa, chi e quando l'ha gestita |
|
||||
| `activity_log` | Audit trail |
|
||||
|
||||
**Vincoli e indici principali**
|
||||
@@ -830,20 +1019,45 @@ Calcolato dinamicamente, non memorizzato:
|
||||
|
||||
| Stato | Condizione |
|
||||
|----------------|-----------------------------------------------------|
|
||||
| `in_reparto` | Attivo, nessuna assegnazione aperta |
|
||||
| `in_reparto` | Attivo, nessuna assegnazione aperta, fuori dai pacchetti dei gruppi |
|
||||
| `in_gruppo` | Nel pacchetto di un gruppo di servizio, non ancora dato a un membro |
|
||||
| `assegnato` | Ha un'assegnazione con `returned_at` nullo |
|
||||
| `da_rientrare` | Assegnato da più giorni della soglia configurata |
|
||||
| `inattivo` | Contrassegnato come non attivo |
|
||||
|
||||
### Priorità
|
||||
Un territorio è prioritario se lo è per **flag manuale** o perché non viene
|
||||
assegnato da più mesi della soglia configurata. La soglia automatica **vince
|
||||
sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
|
||||
quando la soglia è scattata.
|
||||
Un territorio è prioritario se lo è per **flag manuale** o perché è fermo
|
||||
(dall'ultimo rientro, o dalla creazione se mai assegnato) da più giorni della
|
||||
soglia configurata. La soglia automatica **vince sempre** sul flag:
|
||||
l'amministratore può aggiungere priorità, non toglierla quando la soglia è
|
||||
scattata. Assegnare il territorio azzera il flag.
|
||||
|
||||
Dove si sceglie un territorio da dare — "Da assegnare" nella Home, l'aggiunta
|
||||
al pacchetto di un gruppo, "Il mio gruppo" — l'ordine è lo stesso: prima i
|
||||
prioritari, poi quelli fermi da più tempo, poi il numero
|
||||
(`Territorio::ordinaPerPriorita()`).
|
||||
|
||||
### Percentuale di percorrenza delle campagne
|
||||
- **Denominatore**: tutti i territori assegnati nel periodo della campagna
|
||||
- **Numeratore**: le assegnazioni contrassegnate come conteggiate
|
||||
- **Denominatore**: tutti i territori attivi
|
||||
- **Numeratore**: i territori attivi distinti con almeno un'assegnazione
|
||||
collegata alla campagna e contrassegnata come conteggiata al rientro (un
|
||||
territorio percorso due volte conta una volta sola)
|
||||
|
||||
Al rientro la campagna viene proposta se il territorio era fuori almeno in
|
||||
parte del periodo: assegnato durante la campagna, rientrato durante la
|
||||
campagna, oppure fuori per tutta la sua durata. Se sia stato davvero percorso
|
||||
lo decide l'operatore con la spunta. Se più campagne corrispondono viene
|
||||
proposta la più recente. I territori assegnati durante la campagna e non
|
||||
ancora rientrati compaiono nel dettaglio come "in corso".
|
||||
|
||||
### Gruppi di servizio e statistiche
|
||||
Mettere un territorio nel pacchetto di un gruppo **non** è un'assegnazione:
|
||||
non crea righe in `assegnazioni`, quindi non tocca medie, durate, percorrenza
|
||||
delle campagne né il registro. Conta solo quando il sorvegliante lo dà a un
|
||||
membro: da lì è un'assegnazione come le altre (con `gruppo_servizio_id` per
|
||||
sapere da dove viene). Il tempo passato nel pacchetto prima di essere dato non
|
||||
viene contato. La priorità automatica guarda l'ultima assegnazione, come
|
||||
sempre.
|
||||
|
||||
---
|
||||
|
||||
@@ -854,7 +1068,7 @@ quando la soglia è scattata.
|
||||
| Componente | Tecnologia |
|
||||
|-------------------|--------------------------------------------|
|
||||
| Framework | Laravel 11 |
|
||||
| PHP | 8.3 (FPM) |
|
||||
| PHP | 8.3 (FPM), versione fissata nel Dockerfile |
|
||||
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
|
||||
| Database | MariaDB 11 |
|
||||
| Cache/Sessioni/Code| Redis 7 |
|
||||
@@ -869,14 +1083,16 @@ quando la soglia è scattata.
|
||||
```
|
||||
TerManager2/
|
||||
├── app/
|
||||
│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin
|
||||
│ ├── Console/Commands/ # audit:cleanup, backup:cleanup, app:create-initial-admin,
|
||||
│ │ # utenti:conferma-email, territori:file-orfani
|
||||
│ ├── Http/Controllers/ # PDF pubblici, import, backup, login, cambio password
|
||||
│ ├── Http/Middleware/ # RequirePasswordChange
|
||||
│ ├── Http/Middleware/ # RequirePasswordChange, LimitaSorvegliante
|
||||
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
|
||||
│ ├── Livewire/ # Tutti i componenti di interfaccia
|
||||
│ ├── Models/ # 9 modelli Eloquent
|
||||
│ ├── Livewire/ # Tutti i componenti di interfaccia (Gruppi/: gruppi di servizio)
|
||||
│ ├── Models/ # 12 modelli Eloquent
|
||||
│ ├── Providers/ # AppServiceProvider
|
||||
│ └── Services/ # Miniature PDF, stato import, archivio backup
|
||||
│ └── Services/ # Miniature PDF, import, archivio backup, posta,
|
||||
│ # piantine, modulo S-13, gruppi di servizio
|
||||
├── bootstrap/app.php # Middleware, proxy e host fidati
|
||||
├── config/ # Configurazione Laravel
|
||||
├── database/
|
||||
@@ -898,7 +1114,7 @@ TerManager2/
|
||||
│ ├── components/btn.blade.php # L'unico pulsante dell'applicazione
|
||||
│ └── livewire/ # Una vista per componente
|
||||
├── routes/web.php # Rotte con i relativi permessi
|
||||
├── tests/ # Suite PHPUnit (profilo compose "test")
|
||||
├── tests/ # Suite PHPUnit (profilo compose "test") e tests/js (Node)
|
||||
├── phpunit.xml # Configurazione e ambiente dei test
|
||||
├── docker-compose.yml # Unico, produzione e sviluppo
|
||||
├── .env.example # Tutta la configurazione
|
||||
|
||||
BIN
_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx
Normal file
BIN
_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx
Normal file
Binary file not shown.
75
app/Console/Commands/InviaModuloS13.php
Normal file
75
app/Console/Commands/InviaModuloS13.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\ModuloS13Mail;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\User;
|
||||
use App\Services\ConfigurazionePosta;
|
||||
use App\Services\Registro\ModuloS13;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Il modulo S-13 dell'anno di servizio in corso, per email agli
|
||||
* amministratori (l'indirizzo della loro utenza, se confermato). Lo lancia lo
|
||||
* scheduler l'ultima domenica di ogni mese (routes/console.php); a mano si
|
||||
* può scegliere l'anno con --anno=2025-2026.
|
||||
*
|
||||
* Parte subito, non in coda: il PDF contiene i nomi dei proclamatori, che in
|
||||
* coda starebbero in chiaro. Senza un server di posta funzionante non manda
|
||||
* niente e lo dice.
|
||||
*/
|
||||
class InviaModuloS13 extends Command
|
||||
{
|
||||
protected $signature = 'registro:invia-s13 {--anno= : etichetta dell\'anno di servizio, es. 2025-2026 (predefinito: quello in corso)}';
|
||||
|
||||
protected $description = 'Manda per email agli amministratori il modulo S-13 dell\'anno di servizio';
|
||||
|
||||
public function handle(ModuloS13 $modulo, ConfigurazionePosta $posta): int
|
||||
{
|
||||
if (! $posta->funzionante()) {
|
||||
$this->warn('Nessun server di posta funzionante: modulo S-13 non inviato.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$anno = $this->option('anno')
|
||||
? AnnoTeocratico::where('label', $this->option('anno'))->first()
|
||||
: AnnoTeocratico::corrente();
|
||||
if (! $anno) {
|
||||
$this->error("Anno di servizio {$this->option('anno')} non trovato.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$destinatari = User::role('amministratore')->get()
|
||||
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
|
||||
->values();
|
||||
if ($destinatari->isEmpty()) {
|
||||
$this->warn('Nessun amministratore con un\'email confermata: modulo S-13 non inviato.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$pdf = $modulo->genera($anno);
|
||||
|
||||
// Uno per uno: un indirizzo rifiutato non ferma gli altri.
|
||||
$inviati = 0;
|
||||
foreach ($destinatari as $utente) {
|
||||
try {
|
||||
Mail::to($utente->email)->send(new ModuloS13Mail($utente, $anno, $pdf));
|
||||
$inviati++;
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$this->error("Invio a {$utente->email} non riuscito: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// Nel registro quanti, non chi: gli indirizzi stanno nelle utenze.
|
||||
activity()->withProperties(['anno' => $anno->label, 'destinatari' => $inviati])->log('s13_inviato');
|
||||
$this->info("Modulo S-13 {$anno->label} inviato a {$inviati} amministratori su {$destinatari->count()}.");
|
||||
|
||||
return $inviati === $destinatari->count() ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
34
app/Console/Commands/PuliziaRichieste.php
Normal file
34
app/Console/Commands/PuliziaRichieste.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Richiesta;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Le richieste dal modulo pubblico portano nome e note scritti da chi non ha
|
||||
* un account: gestite (evase o scartate) servono ancora un po' per
|
||||
* riferimento, poi si cancellano. Quelle aperte restano finché qualcuno non
|
||||
* le gestisce.
|
||||
*/
|
||||
class PuliziaRichieste extends Command
|
||||
{
|
||||
/** Dopo quanti giorni dalla gestione una richiesta si cancella. */
|
||||
public const GIORNI = 90;
|
||||
|
||||
protected $signature = 'richieste:pulizia';
|
||||
|
||||
protected $description = 'Cancella le richieste del modulo pubblico gestite da più di ' . self::GIORNI . ' giorni';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$cancellate = Richiesta::query()
|
||||
->where('stato', '!=', Richiesta::APERTA)
|
||||
->where('gestita_at', '<', now()->subDays(self::GIORNI))
|
||||
->delete();
|
||||
|
||||
$this->info("Richieste cancellate: {$cancellate}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,11 @@ class LoginController extends Controller
|
||||
$request->session()->regenerate();
|
||||
activity()->causedBy(auth()->user())->log('login');
|
||||
|
||||
// Il sorvegliante di gruppo va dritto al suo gruppo.
|
||||
if (Auth::user()->isSorvegliante() && Auth::user()->roles->count() === 1) {
|
||||
return redirect()->route('mio-gruppo');
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
/**
|
||||
* Cambio password obbligatorio, imposto dopo il ripristino di un backup.
|
||||
* Cambio password obbligatorio, imposto dopo il ripristino di un backup o al
|
||||
* primo accesso con una password temporanea data dall'amministratore
|
||||
* (sorvegliante di gruppo): `password_temporanea` dice quale dei due.
|
||||
*
|
||||
* Volutamente NON e' un componente Livewire: la pagina deve restare
|
||||
* raggiungibile mentre RequirePasswordChange respinge tutto il resto, incluse
|
||||
@@ -27,6 +29,8 @@ class PasswordChangeController extends Controller
|
||||
// Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro;
|
||||
// chi e' stato dirottato dal middleware no.
|
||||
'forced' => (bool) $user->password_change_required,
|
||||
// Perché: una password temporanea, oppure un ripristino da backup.
|
||||
'temporanea' => (bool) $user->password_temporanea,
|
||||
// Il ripristino gira in coda e non ha una sessione in cui lasciare
|
||||
// un flash; deposita l'esito in cache a nome di chi l'ha avviato e
|
||||
// lo si legge qui, che e' la prima pagina che quella persona vede.
|
||||
@@ -71,6 +75,7 @@ class PasswordChangeController extends Controller
|
||||
$user->update([
|
||||
'password' => $validated['password'],
|
||||
'password_change_required' => false,
|
||||
'password_temporanea' => false,
|
||||
]);
|
||||
|
||||
// La sessione corrente sopravvive al cambio, le altre no: dopo un
|
||||
|
||||
@@ -25,11 +25,37 @@ class TerritorioFileController extends Controller
|
||||
return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Il PDF di un territorio del pacchetto, per il sorvegliante di gruppo:
|
||||
* solo se il territorio è nel pacchetto del gruppo che sorveglia.
|
||||
*/
|
||||
public function pdfGruppo(Territorio $territorio): Response
|
||||
{
|
||||
$this->delPacchetto($territorio);
|
||||
|
||||
return $this->pdf($territorio);
|
||||
}
|
||||
|
||||
public function miniatura(Territorio $territorio): Response
|
||||
{
|
||||
return $this->servi($territorio->thumbnail_path, null, null);
|
||||
}
|
||||
|
||||
/** La miniatura per l'anteprima in "Il mio gruppo", con la stessa regola del PDF. */
|
||||
public function miniaturaGruppo(Territorio $territorio): Response
|
||||
{
|
||||
$this->delPacchetto($territorio);
|
||||
|
||||
return $this->miniatura($territorio);
|
||||
}
|
||||
|
||||
/** Il territorio deve stare nel pacchetto del gruppo che l'utente sorveglia. */
|
||||
private function delPacchetto(Territorio $territorio): void
|
||||
{
|
||||
$gruppo = auth()->user()->gruppoSorvegliato();
|
||||
abort_unless($gruppo && $territorio->gruppo_servizio_id === $gruppo->id, 403);
|
||||
}
|
||||
|
||||
public function versione(TerritorioPdfVersione $versione): Response
|
||||
{
|
||||
$numero = $versione->territorio?->numero ?? 'senza-territorio';
|
||||
|
||||
58
app/Http/Middleware/LimitaSorvegliante.php
Normal file
58
app/Http/Middleware/LimitaSorvegliante.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Il sorvegliante di gruppo vede solo il proprio gruppo e il proprio
|
||||
* profilo. La Home lo porta a "Il mio gruppo"; ogni altra pagina è 403,
|
||||
* anche scrivendone l'indirizzo a mano. Le azioni Livewire passano: i
|
||||
* componenti che può aprire controllano da sé che territori e assegnazioni
|
||||
* siano del suo gruppo, e gli altri non può caricarli.
|
||||
*
|
||||
* Vale solo per chi ha il ruolo sorvegliante e nessun altro: un utente con
|
||||
* anche un altro ruolo resta regolato dai permessi di quel ruolo. Sciolto il
|
||||
* gruppo l'utenza si elimina: una sessione ancora aperta non ha più utente.
|
||||
*/
|
||||
class LimitaSorvegliante
|
||||
{
|
||||
private const ROTTE_AMMESSE = [
|
||||
'mio-gruppo',
|
||||
'mio-gruppo.*',
|
||||
'assegnazioni.rientra',
|
||||
'profile',
|
||||
'password.change',
|
||||
'password.change.store',
|
||||
'logout',
|
||||
'verification.*',
|
||||
// Le azioni Livewire: la rotta si chiama "default.livewire.update"
|
||||
// (Livewire 3.7), non "livewire.update". Con 'livewire.*' ogni clic
|
||||
// del sorvegliante finiva in 403.
|
||||
'*livewire.*',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $user->isSorvegliante() || $user->roles->count() > 1) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->routeIs(self::ROTTE_AMMESSE)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->routeIs('dashboard')) {
|
||||
return redirect()->route('mio-gruppo');
|
||||
}
|
||||
|
||||
abort(403, 'Il sorvegliante di gruppo può usare solo la pagina del proprio gruppo.');
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ namespace App\Jobs;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
@@ -75,10 +77,12 @@ class ResetApplication implements ShouldQueue
|
||||
$stato->avanza($this->operationId, 1);
|
||||
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
||||
|
||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10);
|
||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 12);
|
||||
|
||||
DB::transaction(function () use ($stato) {
|
||||
$passi = [
|
||||
// Le richieste dal modulo pubblico, prima delle assegnazioni che evadono.
|
||||
'Richieste' => fn () => Richiesta::query()->delete(),
|
||||
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
|
||||
'Campagne' => fn () => Campagna::query()->delete(),
|
||||
// Prima dei territori, e per esteso: le piantine hanno la
|
||||
@@ -87,6 +91,7 @@ class ResetApplication implements ShouldQueue
|
||||
'Piantine' => fn () => TerritorioMappa::query()->delete(),
|
||||
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
|
||||
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
||||
'Gruppi di servizio' => fn () => GruppoServizio::query()->delete(),
|
||||
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
||||
'Tipologie' => fn () => Tipologia::query()->delete(),
|
||||
'Zone' => fn () => Zona::query()->delete(),
|
||||
|
||||
@@ -250,6 +250,9 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
{
|
||||
DB::table('users')->update([
|
||||
'password_change_required' => true,
|
||||
// Il motivo ora è il ripristino, anche per chi aveva una
|
||||
// password temporanea (pagina del cambio password).
|
||||
'password_temporanea' => false,
|
||||
'remember_token' => null,
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,33 +2,70 @@
|
||||
|
||||
namespace App\Livewire\Assegnazioni;
|
||||
|
||||
use App\Livewire\Concerns\InviaLink;
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Computed;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Richiesta;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
||||
class Assegna extends Component
|
||||
{
|
||||
/** Chiave di sessione delle assegnazioni appena fatte, in attesa della scelta sul link. */
|
||||
public const DA_INVIARE = 'assegnazioni_da_inviare';
|
||||
use RicordaRitorno;
|
||||
use InviaLink;
|
||||
|
||||
public ?int $territorio_id = null;
|
||||
public ?int $proclamatore_id = null;
|
||||
public string $assigned_at = '';
|
||||
public string $territorioSearch = '';
|
||||
public string $linkSearch = '';
|
||||
|
||||
/** Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). */
|
||||
public bool $cartaceo = false;
|
||||
|
||||
/**
|
||||
* La richiesta dal modulo pubblico da cui si è partiti (?richiesta=,
|
||||
* dalla Home): si mostra sopra il modulo e, fatta l'assegnazione, risulta
|
||||
* evasa. Locked: la imposta solo mount().
|
||||
*/
|
||||
#[Locked]
|
||||
public ?int $richiestaId = null;
|
||||
|
||||
// Optional pre-selection from parent context
|
||||
public ?int $preselectedTerritorioId = null;
|
||||
|
||||
/**
|
||||
* Aperta da "Assegna →" della Home (?dalla_home=1, lo mette solo la Home)
|
||||
* la pagina serve ad assegnare quel territorio: l'elenco dei territori
|
||||
* assegnati sotto il modulo non si mostra (né si carica).
|
||||
*/
|
||||
public bool $elencoNascosto = false;
|
||||
|
||||
public function mount(?int $territorioId = null)
|
||||
{
|
||||
$this->preselectedTerritorioId = $territorioId;
|
||||
$this->territorio_id = $territorioId;
|
||||
$this->assigned_at = now()->format('Y-m-d');
|
||||
$this->elencoNascosto = request()->boolean('dalla_home');
|
||||
|
||||
// Dalla Home, da una richiesta: il proclamatore scelto fra i nomi
|
||||
// simili arriva già impostato (?proclamatore=), il resto del modulo è
|
||||
// quello di sempre.
|
||||
if (($id = request()->integer('proclamatore')) && Proclamatore::attivi()->whereKey($id)->exists()) {
|
||||
$this->proclamatore_id = $id;
|
||||
}
|
||||
if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci')
|
||||
&& Richiesta::aperte()->whereKey($id)->exists()) {
|
||||
$this->richiestaId = $id;
|
||||
}
|
||||
|
||||
// ?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
|
||||
@@ -59,6 +96,12 @@ class Assegna extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
// Nel pacchetto di un gruppo di servizio: lo assegna il sorvegliante.
|
||||
if ($territorio->gruppo_servizio_id) {
|
||||
$this->addError('territorio_id', "Il territorio {$territorio->numero} è nel pacchetto del gruppo {$territorio->gruppo?->nome}: lo assegna il sorvegliante, oppure va prima tolto dal pacchetto.");
|
||||
return;
|
||||
}
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
||||
if (!$proclamatore->attivo) {
|
||||
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||
@@ -88,6 +131,9 @@ class Assegna extends Component
|
||||
'anno_teocratico_id' => $annoTeocratico->id,
|
||||
'assigned_at' => $assignedDate,
|
||||
'created_by' => auth()->id(),
|
||||
// C'è un link da mandare solo se il territorio ha il PDF.
|
||||
'link_da_inviare' => (bool) $territorio->pdf_path,
|
||||
'cartaceo' => $this->cartaceo,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -110,112 +156,60 @@ class Assegna extends Component
|
||||
])
|
||||
->log('assigned');
|
||||
|
||||
// Si resta qui: in cima compare il riquadro con il link da mandare al
|
||||
// proclamatore (WhatsApp) e la scelta "inviato / non ancora". La lista
|
||||
// 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))]);
|
||||
// Partita da una richiesta del modulo pubblico: ora è evasa.
|
||||
if ($this->richiestaId) {
|
||||
Richiesta::find($this->richiestaId)?->evadi($assegnazione, auth()->user());
|
||||
$this->richiestaId = null;
|
||||
}
|
||||
$this->cartaceo = false;
|
||||
|
||||
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}.");
|
||||
return $this->redirect(route('assegnazioni.assegna'), navigate: true);
|
||||
// Non si va avanti: si apre l'invio del link (vedi in cima).
|
||||
$this->apriInvio($assegnazione->id);
|
||||
}
|
||||
|
||||
/** La scelta nel riquadro: il link è stato mandato, oppure non ancora. */
|
||||
public function esitoInvio(int $assegnazioneId, bool $inviato): void
|
||||
// ─── Invio del link (trait InviaLink) ──────────────────────
|
||||
|
||||
protected function puoInviare(Assegnazione $assegnazione): bool
|
||||
{
|
||||
$this->authorize('territori.assign');
|
||||
|
||||
$assegnazione = Assegnazione::find($assegnazioneId);
|
||||
$assegnazione?->forceFill(['link_sent' => $inviato])->saveQuietly();
|
||||
|
||||
session([self::DA_INVIARE => array_values(array_diff(session(self::DA_INVIARE, []), [$assegnazioneId]))]);
|
||||
return auth()->user()->can('territori.assign');
|
||||
}
|
||||
|
||||
public function toggleLinkSent(int $assegnazioneId): void
|
||||
protected function paginaDopoInvio(): string
|
||||
{
|
||||
$this->authorize('territori.assign');
|
||||
$assegnazione = Assegnazione::findOrFail($assegnazioneId);
|
||||
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedThumbnailUrl(): ?string
|
||||
{
|
||||
if (!$this->territorio_id) {
|
||||
return null;
|
||||
}
|
||||
$t = Territorio::find($this->territorio_id);
|
||||
return $t?->miniaturaUrl();
|
||||
return $this->destinazione(route('assegnazioni.assegna'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$territoriQuery = Territorio::where('attivo', true)
|
||||
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte())
|
||||
->with(['zona', 'tipologia']);
|
||||
|
||||
if (trim($this->territorioSearch) !== '') {
|
||||
$term = trim($this->territorioSearch);
|
||||
$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()
|
||||
// Le opzioni dei campi di scelta con ricerca (<x-scelta>). Ci sono anche
|
||||
// i territori già assegnati, che non si possono scegliere: cercandone
|
||||
// uno si legge a chi è, invece di un "nessun risultato". La miniatura
|
||||
// serve all'anteprima, che si aggiorna nel browser.
|
||||
// Quelli nel pacchetto di un gruppo di servizio li assegna il
|
||||
// sorvegliante: qui si trovano ma non si scelgono.
|
||||
$territori = Territorio::attivi()
|
||||
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'gruppo'])
|
||||
->get()
|
||||
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome));
|
||||
->sortBy('numero', SORT_NATURAL);
|
||||
|
||||
// All currently assigned territories with links
|
||||
$assegnazioniAperte = Assegnazione::aperte()
|
||||
->with(['territorio.zona', 'proclamatore'])
|
||||
->get()
|
||||
->sortBy(fn($a) => (int) $a->territorio?->numero);
|
||||
$opzioniTerritori = $territori
|
||||
->map(fn (Territorio $t) => $t->opzioneScelta(match (true) {
|
||||
(bool) $t->assegnazioneCorrente => 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—'),
|
||||
(bool) $t->gruppo_servizio_id => 'nel pacchetto del gruppo ' . ($t->gruppo?->nome ?? ''),
|
||||
default => null,
|
||||
}) + array_filter(['miniatura' => $t->miniaturaUrl()]))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// Ricerca rapida nella lista dei link: numero territorio, zona,
|
||||
// proclamatore in un solo campo. In memoria perché nome/cognome del
|
||||
// proclamatore sono cifrati e non filtrabili via SQL.
|
||||
$assegnazioniAperteVisibili = $assegnazioniAperte;
|
||||
if (trim($this->linkSearch) !== '') {
|
||||
$term = mb_strtolower(trim($this->linkSearch));
|
||||
$assegnazioniAperteVisibili = $assegnazioniAperte->filter(function ($a) use ($term) {
|
||||
$haystack = mb_strtolower(
|
||||
($a->territorio?->numero ?? '') . ' ' .
|
||||
($a->territorio?->zona?->nome ?? '') . ' ' .
|
||||
($a->proclamatore?->nome_completo ?? '')
|
||||
);
|
||||
return str_contains($haystack, $term);
|
||||
})->values();
|
||||
}
|
||||
|
||||
// Il link scade alla soglia rientro (giorni_per_smarrito).
|
||||
$sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
|
||||
|
||||
// I riquadri in evidenza: solo le assegnazioni ancora aperte (una
|
||||
// rientrata o cancellata nel frattempo non ha più un link da mandare).
|
||||
$idDaInviare = session(self::DA_INVIARE, []);
|
||||
$daInviare = $idDaInviare === [] ? collect() : Assegnazione::aperte()
|
||||
->whereIn('id', $idDaInviare)
|
||||
->with(['territorio', 'proclamatore'])
|
||||
->get();
|
||||
if ($daInviare->count() !== count($idDaInviare)) {
|
||||
session([self::DA_INVIARE => $daInviare->pluck('id')->all()]);
|
||||
}
|
||||
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
|
||||
|
||||
// L'elenco dei territori assegnati sta in ElencoAssegnati.
|
||||
return view('livewire.assegnazioni.assegna', [
|
||||
'daInviare' => $daInviare,
|
||||
'territoriDisponibili' => $territoriDisponibili,
|
||||
'proclamatoriAttivi' => $proclamatoriAttivi,
|
||||
'assegnazioniAperte' => $assegnazioniAperte,
|
||||
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili,
|
||||
'sogliaRientro' => $sogliaRientro,
|
||||
'invio' => $this->invioCorrente(),
|
||||
'opzioniTerritori' => $opzioniTerritori,
|
||||
'opzioniProclamatori' => $opzioniProclamatori,
|
||||
'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
|
||||
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Livewire/Assegnazioni/Cartacei.php
Normal file
25
app/Livewire/Assegnazioni/Cartacei.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Assegnazioni;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* I territori fuori in cartaceo, e a chi: le assegnazioni aperte per cui il
|
||||
* proclamatore ha chiesto la cartolina (spunta "cartaceo" su Assegna o in "Il
|
||||
* mio gruppo"). Al rientro escono dall'elenco.
|
||||
*/
|
||||
class Cartacei extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.assegnazioni.cartacei', [
|
||||
'assegnazioni' => Assegnazione::aperte()
|
||||
->where('cartaceo', true)
|
||||
->with(['territorio.zona', 'proclamatore'])
|
||||
->orderBy('assigned_at')
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
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,16 @@
|
||||
namespace App\Livewire\Assegnazioni;
|
||||
|
||||
use Livewire\Attributes\Locked;
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Richiesta;
|
||||
|
||||
class Rientra extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Assegnazione $assegnazione;
|
||||
public string $returned_at = '';
|
||||
public bool $showCampaignPrompt = false;
|
||||
@@ -22,14 +26,27 @@ class Rientra extends Component
|
||||
|
||||
public bool $counted_in_campaign = false;
|
||||
|
||||
/**
|
||||
* La richiesta di riconsegna dal modulo pubblico da cui si è partiti
|
||||
* (?richiesta=, dalla Home): registrato il rientro, risulta evasa.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?int $richiestaId = null;
|
||||
|
||||
public function mount(Assegnazione $assegnazione)
|
||||
{
|
||||
$this->autorizza($assegnazione);
|
||||
$this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']);
|
||||
|
||||
if ($assegnazione->returned_at) {
|
||||
abort(404, 'Assegnazione già rientrata.');
|
||||
}
|
||||
|
||||
if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci')
|
||||
&& Richiesta::aperte()->whereKey($id)->exists()) {
|
||||
$this->richiestaId = $id;
|
||||
}
|
||||
|
||||
$this->returned_at = now()->format('Y-m-d');
|
||||
$this->checkCampaign();
|
||||
}
|
||||
@@ -66,8 +83,28 @@ class Rientra extends Component
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi ha territori.return registra qualsiasi rientro. Il sorvegliante di
|
||||
* gruppo (gruppo.gestisci) solo quelli del suo gruppo: assegnazioni nate
|
||||
* dal gruppo o territori del suo pacchetto.
|
||||
*/
|
||||
private function autorizza(Assegnazione $assegnazione): void
|
||||
{
|
||||
$utente = auth()->user();
|
||||
if ($utente->can('territori.return')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$gruppo = $utente->can('gruppo.gestisci') ? $utente->gruppoSorvegliato() : null;
|
||||
abort_unless(
|
||||
$gruppo && ($assegnazione->gruppo_servizio_id === $gruppo->id || $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id),
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->autorizza($this->assegnazione);
|
||||
$this->validate();
|
||||
|
||||
// mount() rejects an already-returned assignment, but the component
|
||||
@@ -98,16 +135,54 @@ class Rientra extends Component
|
||||
])
|
||||
->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'
|
||||
);
|
||||
}
|
||||
|
||||
// Partito da una richiesta di riconsegna (Home): ora è evasa.
|
||||
if ($this->richiestaId) {
|
||||
Richiesta::find($this->richiestaId)?->evadi($this->assegnazione, auth()->user());
|
||||
}
|
||||
|
||||
session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni.");
|
||||
return $this->redirect(route('territori.show', $territorio), navigate: true);
|
||||
return $this->redirect($this->destinazione($this->paginaPredefinita()), navigate: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dove si torna senza una pagina di partenza: la scheda del territorio,
|
||||
* o "Il mio gruppo" per il sorvegliante, che la scheda non la vede.
|
||||
*/
|
||||
public function paginaPredefinita(): string
|
||||
{
|
||||
return auth()->user()->can('territori.assign')
|
||||
? route('territori.show', $this->assegnazione->territorio_id)
|
||||
: route('mio-gruppo');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$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', [
|
||||
'campagna' => $campagna,
|
||||
'giorniPercorrenza' => $giorniPercorrenza,
|
||||
'sogliaPercorrenza' => $sogliaPercorrenza,
|
||||
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Campagne;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Campagna;
|
||||
|
||||
class CampagnaCreate extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public string $descrizione = '';
|
||||
public string $start_date = '';
|
||||
public string $end_date = '';
|
||||
@@ -34,7 +37,7 @@ class CampagnaCreate extends Component
|
||||
]);
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Campagne;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Campagna;
|
||||
|
||||
class CampagnaEdit extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Campagna $campagna;
|
||||
public string $descrizione = '';
|
||||
public string $start_date = '';
|
||||
@@ -47,7 +50,7 @@ class CampagnaEdit extends Component
|
||||
]);
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Campagne;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Assegnazione;
|
||||
@@ -9,6 +10,8 @@ use App\Models\Territorio;
|
||||
|
||||
class CampagnaShow extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Campagna $campagna;
|
||||
|
||||
public function mount(Campagna $campagna)
|
||||
@@ -28,6 +31,18 @@ class CampagnaShow extends Component
|
||||
->orderBy('returned_at')
|
||||
->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
|
||||
$assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date)
|
||||
->where(function ($q) {
|
||||
@@ -38,6 +53,7 @@ class CampagnaShow extends Component
|
||||
|
||||
return view('livewire.campagne.campagna-show', [
|
||||
'conteggiate' => $conteggiate,
|
||||
'inCorso' => $inCorso,
|
||||
'assegnateNelRange' => $assegnateNelRange,
|
||||
]);
|
||||
}
|
||||
|
||||
132
app/Livewire/Concerns/InviaLink.php
Normal file
132
app/Livewire/Concerns/InviaLink.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
/**
|
||||
* Invio del link della piantina dopo un'assegnazione, condiviso dalla pagina
|
||||
* Assegna e da "Il mio gruppo" del sorvegliante. La vista è il partial
|
||||
* livewire/assegnazioni/_invio-link.
|
||||
*
|
||||
* Dopo l'assegnazione non si va avanti: si sceglie il canale (copia link o
|
||||
* Condividi: il destinatario lo sceglie chi usa l'app dai suoi contatti, i
|
||||
* proclamatori non hanno numero né email), si dice se il link è partito, e
|
||||
* solo allora si va avanti. Se la procedura si interrompe, l'assegnazione
|
||||
* resta con link_da_inviare e lo si segnala dove compare.
|
||||
*
|
||||
* Chi usa il trait dice chi può mandare il link di un'assegnazione
|
||||
* (puoInviare) e dove si va dopo (paginaDopoInvio).
|
||||
*/
|
||||
trait InviaLink
|
||||
{
|
||||
/** L'assegnazione di cui si sta mandando il link. */
|
||||
#[Locked]
|
||||
public ?int $invioId = null;
|
||||
|
||||
/** Il canale usato in questo invio (Assegnazione::CANALI_LINK). */
|
||||
#[Locked]
|
||||
public ?string $canaleUsato = null;
|
||||
|
||||
public string $erroreInvio = '';
|
||||
|
||||
abstract protected function puoInviare(Assegnazione $assegnazione): bool;
|
||||
|
||||
abstract protected function paginaDopoInvio(): string;
|
||||
|
||||
/**
|
||||
* Apre l'invio: subito dopo l'assegnazione, o da un'assegnazione rimasta
|
||||
* in sospeso ("Invia link" nell'elenco, che lo chiede con apri-invio).
|
||||
*/
|
||||
#[On('apri-invio')]
|
||||
public function apriInvio(int $assegnazioneId): void
|
||||
{
|
||||
$assegnazione = Assegnazione::aperte()->find($assegnazioneId);
|
||||
if (! $assegnazione) {
|
||||
return;
|
||||
}
|
||||
abort_unless($this->puoInviare($assegnazione), 403);
|
||||
|
||||
$this->invioId = $assegnazione->id;
|
||||
$this->canaleUsato = null;
|
||||
$this->erroreInvio = '';
|
||||
}
|
||||
|
||||
/** Copia link o Condividi: l'invio vero lo fa chi usa l'app, qui si annota il canale. */
|
||||
public function canale(string $canale): void
|
||||
{
|
||||
$assegnazione = $this->assegnazioneInInvio();
|
||||
if (! $assegnazione || ! in_array($canale, Assegnazione::CANALI_ATTIVI, true) || ! $assegnazione->shortPdfUrl()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->canaleUsato = $canale;
|
||||
$this->erroreInvio = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* La risposta a "Hai inviato il link?": la si registra e solo adesso si
|
||||
* va avanti. "Sì" vuole un canale usato; "No" lascia il link da inviare.
|
||||
*/
|
||||
public function concludiInvio(bool $inviato)
|
||||
{
|
||||
$assegnazione = $this->assegnazioneInInvio();
|
||||
if (! $assegnazione) {
|
||||
$this->invioId = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($inviato && $this->canaleUsato === null) {
|
||||
$this->erroreInvio = 'Scegli prima come mandare il link.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$assegnazione->registraInvioLink($inviato, $this->canaleUsato);
|
||||
|
||||
// Nel log il canale e l'id del proclamatore, mai il nome.
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($assegnazione)
|
||||
->withProperties([
|
||||
'territorio' => $assegnazione->territorio?->numero,
|
||||
'proclamatore_id' => $assegnazione->proclamatore_id,
|
||||
'canale' => $inviato ? $this->canaleUsato : null,
|
||||
])
|
||||
->log($inviato ? 'link_inviato' : 'link_non_inviato');
|
||||
|
||||
$numero = $assegnazione->territorio?->numero;
|
||||
$nome = $assegnazione->proclamatore?->nome_completo;
|
||||
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
|
||||
|
||||
session()->flash('success', $inviato
|
||||
? "Territorio {$numero} assegnato a {$nome}: link inviato."
|
||||
: "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco.");
|
||||
return $this->redirect($this->paginaDopoInvio(), navigate: true);
|
||||
}
|
||||
|
||||
/** L'invio in corso, per la vista; null se l'assegnazione nel frattempo è rientrata o sparita. */
|
||||
protected function invioCorrente(): ?Assegnazione
|
||||
{
|
||||
$invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
|
||||
if ($this->invioId && ! $invio) {
|
||||
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
|
||||
}
|
||||
|
||||
return $invio;
|
||||
}
|
||||
|
||||
private function assegnazioneInInvio(): ?Assegnazione
|
||||
{
|
||||
if (! $this->invioId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$assegnazione = Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
|
||||
abort_unless($assegnazione && $this->puoInviare($assegnazione), 403);
|
||||
|
||||
return $assegnazione;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
88
app/Livewire/Gruppi/GruppiIndex.php
Normal file
88
app/Livewire/Gruppi/GruppiIndex.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Gruppi;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Services\GruppiServizio;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Gruppi di servizio (solo amministratore): elenco e creazione. Creando un
|
||||
* gruppo si sceglie il sorvegliante, che riceve un'utenza con un'email
|
||||
* fittizia ricavata dal suo nome (GruppiServizio::emailFittizia) e una
|
||||
* password temporanea, mostrate qui una volta sola. Membri e pacchetto si
|
||||
* gestiscono nella pagina del gruppo (GruppoShow).
|
||||
*/
|
||||
class GruppiIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
|
||||
public string $nome = '';
|
||||
|
||||
public ?int $sorveglianteId = null;
|
||||
|
||||
/** Gruppo appena creato e password temporanea del sorvegliante, da consegnare. */
|
||||
#[Locked]
|
||||
public ?int $creatoId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $passwordTemporanea = null;
|
||||
|
||||
public function crea(): void
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$this->validate([
|
||||
'nome' => 'required|string|max:100|unique:gruppi_servizio,nome',
|
||||
'sorveglianteId' => 'required|integer|exists:proclamatori,id',
|
||||
], [
|
||||
'nome.unique' => 'Esiste già un gruppo con questo nome.',
|
||||
'sorveglianteId.required' => 'Scegli il sorvegliante.',
|
||||
]);
|
||||
|
||||
try {
|
||||
[$gruppo, $password] = app(GruppiServizio::class)->crea(
|
||||
$this->nome,
|
||||
Proclamatore::findOrFail($this->sorveglianteId),
|
||||
);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->addError('sorveglianteId', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset(['nome', 'sorveglianteId']);
|
||||
$this->creatoId = $gruppo->id;
|
||||
$this->passwordTemporanea = $password;
|
||||
}
|
||||
|
||||
public function chiudiPassword(): void
|
||||
{
|
||||
$this->reset(['creatoId', 'passwordTemporanea']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$gruppi = GruppoServizio::with('sorvegliante')
|
||||
->withCount(['membri', 'territori'])
|
||||
->orderBy('nome')
|
||||
->get();
|
||||
|
||||
// Possono diventare sorveglianti i proclamatori attivi senza gruppo (e
|
||||
// quindi senza utenza: sciolto il gruppo, la si elimina).
|
||||
$candidati = Proclamatore::attivi()
|
||||
->whereNull('gruppo_servizio_id')
|
||||
->whereDoesntHave('utente')
|
||||
->get();
|
||||
|
||||
return view('livewire.gruppi.gruppi-index', [
|
||||
'gruppi' => $gruppi,
|
||||
'opzioniSorveglianti' => Proclamatore::opzioniScelta($candidati),
|
||||
'creato' => $this->creatoId ? GruppoServizio::with('sorvegliante.utente')->find($this->creatoId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
200
app/Livewire/Gruppi/GruppoShow.php
Normal file
200
app/Livewire/Gruppi/GruppoShow.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Gruppi;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Services\GruppiServizio;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Un gruppo di servizio, lato amministratore: membri (aggiungere/togliere),
|
||||
* pacchetto (aggiungere in blocco, rimandare in reparto quelli non dati),
|
||||
* nuova password temporanea per il sorvegliante, scioglimento.
|
||||
*/
|
||||
class GruppoShow extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use RicordaRitorno;
|
||||
|
||||
public GruppoServizio $gruppo;
|
||||
|
||||
/** @var int[] proclamatori da aggiungere */
|
||||
public array $nuoviMembri = [];
|
||||
|
||||
/** @var int[] territori da aggiungere al pacchetto */
|
||||
public array $nuoviTerritori = [];
|
||||
|
||||
// Filtri dei territori in reparto da aggiungere al pacchetto. L'ordine è
|
||||
// quello di priorità (Territorio::ordinaPerPriorita), come nella Home.
|
||||
public string $cercaTerritorio = '';
|
||||
|
||||
public string $filtroZona = '';
|
||||
|
||||
public string $filtroTipologia = '';
|
||||
|
||||
public bool $soloPrioritari = false;
|
||||
|
||||
#[Locked]
|
||||
public ?string $passwordTemporanea = null;
|
||||
|
||||
public function mount(GruppoServizio $gruppo): void
|
||||
{
|
||||
$this->gruppo = $gruppo;
|
||||
}
|
||||
|
||||
private function servizio(): GruppiServizio
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
return app(GruppiServizio::class);
|
||||
}
|
||||
|
||||
private function esegui(callable $azione, string $ok): void
|
||||
{
|
||||
try {
|
||||
$azione();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->avvisaErrore($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->gruppo->refresh();
|
||||
$this->avvisa($ok);
|
||||
}
|
||||
|
||||
public function aggiungiMembri(): void
|
||||
{
|
||||
$ids = array_map('intval', $this->nuoviMembri);
|
||||
if ($ids === []) {
|
||||
$this->avvisaErrore('Spunta almeno un proclamatore.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->esegui(fn () => $this->servizio()->aggiungiMembri($this->gruppo, $ids), count($ids) . ' membri aggiunti.');
|
||||
$this->nuoviMembri = [];
|
||||
}
|
||||
|
||||
public function togliMembro(int $proclamatoreId): void
|
||||
{
|
||||
$membro = Proclamatore::findOrFail($proclamatoreId);
|
||||
$this->esegui(fn () => $this->servizio()->togliMembro($this->gruppo, $membro), "{$membro->nome_completo} tolto dal gruppo.");
|
||||
}
|
||||
|
||||
public function aggiungiTerritori(): void
|
||||
{
|
||||
$ids = array_map('intval', $this->nuoviTerritori);
|
||||
if ($ids === []) {
|
||||
$this->avvisaErrore('Spunta almeno un territorio.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->esegui(fn () => $this->servizio()->aggiungiAlPacchetto($this->gruppo, $ids), count($ids) . ' territori aggiunti al pacchetto.');
|
||||
$this->nuoviTerritori = [];
|
||||
}
|
||||
|
||||
/** Tutti i territori che i filtri mostrano finiscono tra quelli da aggiungere. */
|
||||
public function selezionaMostrati(): void
|
||||
{
|
||||
$this->nuoviTerritori = collect($this->nuoviTerritori)
|
||||
->map(fn ($id) => (string) $id)
|
||||
->merge($this->candidatiFiltrati()->pluck('id')->map(fn ($id) => (string) $id))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function deselezionaTutti(): void
|
||||
{
|
||||
$this->nuoviTerritori = [];
|
||||
}
|
||||
|
||||
/** I territori in reparto, in ordine di priorità. */
|
||||
private function candidati(): \Illuminate\Support\Collection
|
||||
{
|
||||
return Territorio::ordinaPerPriorita(
|
||||
Territorio::inReparto()->with(['zona', 'tipologia', 'ultimaAssegnazione'])->get()
|
||||
);
|
||||
}
|
||||
|
||||
private function candidatiFiltrati(?\Illuminate\Support\Collection $candidati = null): \Illuminate\Support\Collection
|
||||
{
|
||||
$cerca = Str::lower(Str::ascii(trim($this->cercaTerritorio)));
|
||||
|
||||
return ($candidati ?? $this->candidati())
|
||||
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
|
||||
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
|
||||
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
|
||||
->when($cerca !== '', fn ($c) => $c->filter(fn (Territorio $t) => str_contains(
|
||||
Str::lower(Str::ascii("{$t->numero} {$t->zona?->nome} {$t->tipologia?->nome}")),
|
||||
$cerca
|
||||
)))
|
||||
->values();
|
||||
}
|
||||
|
||||
public function rimanda(int $territorioId): void
|
||||
{
|
||||
$territorio = Territorio::findOrFail($territorioId);
|
||||
$this->esegui(fn () => $this->servizio()->rimandaInReparto($this->gruppo, $territorio), "Territorio {$territorio->numero} rimandato in reparto.");
|
||||
}
|
||||
|
||||
/** Il sorvegliante ha perso la password: una nuova temporanea, da cambiare al primo accesso. */
|
||||
public function nuovaPassword(): void
|
||||
{
|
||||
$this->servizio();
|
||||
$utente = User::where('proclamatore_id', $this->gruppo->sorvegliante_id)->first();
|
||||
if (! $utente) {
|
||||
$this->avvisaErrore('Il gruppo non ha un sorvegliante con utenza attiva.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($utente);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toglie il ruolo di sorvegliante: il gruppo si scioglie e si elimina, si
|
||||
* torna all'elenco dei gruppi.
|
||||
*/
|
||||
public function sciogli()
|
||||
{
|
||||
$nome = $this->gruppo->nome;
|
||||
$this->servizio()->sciogli($this->gruppo);
|
||||
|
||||
session()->flash('success', "Gruppo {$nome} sciolto: i territori non ancora dati sono tornati in reparto e l'utenza del sorvegliante è stata eliminata.");
|
||||
return $this->redirect(route('gruppi.index'), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->gruppo->load(['sorvegliante.utente']);
|
||||
|
||||
$membri = $this->gruppo->membri()->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values();
|
||||
$territori = $this->gruppo->territori()->with(['zona', 'assegnazioneCorrente.proclamatore'])->get()->sortBy('numero', SORT_NATURAL)->values();
|
||||
|
||||
// I territori in reparto (attivi, non assegnati, fuori da ogni
|
||||
// pacchetto), in ordine di priorità; zone e tipologie dei filtri sono
|
||||
// quelle che ci sono tra loro.
|
||||
$candidati = $this->candidati();
|
||||
|
||||
return view('livewire.gruppi.gruppo-show', [
|
||||
'membri' => $membri,
|
||||
'territori' => $territori,
|
||||
// Si possono aggiungere i proclamatori attivi senza gruppo...
|
||||
'candidatiMembri' => Proclamatore::attivi()->whereNull('gruppo_servizio_id')->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values(),
|
||||
// ...e i territori in reparto.
|
||||
'totaleCandidati' => $candidati->count(),
|
||||
'candidatiTerritori' => $this->candidatiFiltrati($candidati),
|
||||
'zoneCandidati' => $candidati->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
|
||||
'tipologieCandidati' => $candidati->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
227
app/Livewire/Gruppi/MioGruppo.php
Normal file
227
app/Livewire/Gruppi/MioGruppo.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Gruppi;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Livewire\Concerns\InviaLink;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Services\GruppiServizio;
|
||||
use Carbon\Carbon;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* "Il mio gruppo": la sola pagina del sorvegliante di gruppo. Vede il
|
||||
* pacchetto del suo gruppo e i membri; dà i territori ai membri (o a sé),
|
||||
* manda il link, registra i rientri (pagina Rientra), apre la piantina e
|
||||
* rimanda in reparto quelli non ancora dati. Le regole stanno in
|
||||
* App\Services\GruppiServizio; qui ogni azione ricontrolla che il territorio
|
||||
* o l'assegnazione siano del suo gruppo.
|
||||
*/
|
||||
class MioGruppo extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use InviaLink;
|
||||
|
||||
/** Il territorio del pacchetto che si sta assegnando. */
|
||||
#[Locked]
|
||||
public ?int $territorioDaAssegnare = null;
|
||||
|
||||
public ?int $membroId = null;
|
||||
|
||||
public string $dataAssegnazione = '';
|
||||
|
||||
/** Il membro ha chiesto la cartolina cartacea (elenco Cartacei). */
|
||||
public bool $cartaceo = false;
|
||||
|
||||
// Filtri del pacchetto, nell'indirizzo: tornando dal rientro si ritrovano.
|
||||
#[Url(except: '')]
|
||||
public string $filtroZona = '';
|
||||
|
||||
#[Url(except: '')]
|
||||
public string $filtroTipologia = '';
|
||||
|
||||
/** '' tutti, 'da_dare', 'dati' */
|
||||
#[Url(except: '')]
|
||||
public string $filtroStato = '';
|
||||
|
||||
#[Url(except: false)]
|
||||
public bool $soloPrioritari = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->dataAssegnazione = now()->format('Y-m-d');
|
||||
}
|
||||
|
||||
private function gruppo(): ?GruppoServizio
|
||||
{
|
||||
return auth()->user()->gruppoSorvegliato();
|
||||
}
|
||||
|
||||
private function gruppoOFallisci(): GruppoServizio
|
||||
{
|
||||
$gruppo = $this->gruppo();
|
||||
abort_unless($gruppo, 403, 'Non sei il sorvegliante di un gruppo di servizio attivo.');
|
||||
|
||||
return $gruppo;
|
||||
}
|
||||
|
||||
private function territorioDelPacchetto(int $id): Territorio
|
||||
{
|
||||
$gruppo = $this->gruppoOFallisci();
|
||||
$territorio = Territorio::find($id);
|
||||
abort_unless($territorio && $territorio->gruppo_servizio_id === $gruppo->id, 403);
|
||||
|
||||
return $territorio;
|
||||
}
|
||||
|
||||
// ─── Assegnare un territorio del pacchetto ──────────────────
|
||||
|
||||
public function apriAssegnazione(int $territorioId): void
|
||||
{
|
||||
$this->territorioDelPacchetto($territorioId);
|
||||
$this->territorioDaAssegnare = $territorioId;
|
||||
$this->membroId = null;
|
||||
$this->cartaceo = false;
|
||||
$this->dataAssegnazione = now()->format('Y-m-d');
|
||||
$this->resetValidation();
|
||||
}
|
||||
|
||||
public function annullaAssegnazione(): void
|
||||
{
|
||||
$this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
|
||||
$this->resetValidation();
|
||||
}
|
||||
|
||||
public function assegna(): void
|
||||
{
|
||||
$this->validate([
|
||||
'membroId' => 'required|integer',
|
||||
'dataAssegnazione' => 'required|date|before_or_equal:today',
|
||||
], [
|
||||
'membroId.required' => 'Scegli a chi dare il territorio.',
|
||||
'dataAssegnazione.before_or_equal' => 'La data non può essere nel futuro.',
|
||||
]);
|
||||
|
||||
$gruppo = $this->gruppoOFallisci();
|
||||
$territorio = $this->territorioDelPacchetto((int) $this->territorioDaAssegnare);
|
||||
$membro = Proclamatore::find($this->membroId);
|
||||
|
||||
if (! $membro) {
|
||||
$this->addError('membroId', 'Scegli un membro del gruppo.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione), $this->cartaceo);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->addError('membroId', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
|
||||
|
||||
// Come dalla pagina Assegna: si apre l'invio del link.
|
||||
$this->apriInvio($assegnazione->id);
|
||||
}
|
||||
|
||||
// ─── Rimandare in reparto ───────────────────────────────────
|
||||
|
||||
public function rimanda(int $territorioId): void
|
||||
{
|
||||
$territorio = $this->territorioDelPacchetto($territorioId);
|
||||
|
||||
try {
|
||||
app(GruppiServizio::class)->rimandaInReparto($this->gruppoOFallisci(), $territorio);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->avvisaErrore($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->avvisa("Territorio {$territorio->numero} rimandato in reparto.");
|
||||
}
|
||||
|
||||
// ─── Link già inviato / condiviso dall'elenco ───────────────
|
||||
|
||||
private function assegnazioneDelGruppo(int $id): Assegnazione
|
||||
{
|
||||
$assegnazione = Assegnazione::aperte()->with('territorio')->find($id);
|
||||
abort_unless($assegnazione && $this->puoInviare($assegnazione), 403);
|
||||
|
||||
return $assegnazione;
|
||||
}
|
||||
|
||||
public function toggleLinkSent(int $assegnazioneId): void
|
||||
{
|
||||
$assegnazione = $this->assegnazioneDelGruppo($assegnazioneId);
|
||||
$assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale);
|
||||
}
|
||||
|
||||
/** "Condividi" dall'elenco andato a buon fine (elencoCercabile.condividi). */
|
||||
public function segnaCondiviso(int $assegnazioneId): void
|
||||
{
|
||||
$this->assegnazioneDelGruppo($assegnazioneId)->registraInvioLink(true, 'condividi');
|
||||
}
|
||||
|
||||
// ─── Invio del link (trait InviaLink) ───────────────────────
|
||||
|
||||
protected function puoInviare(Assegnazione $assegnazione): bool
|
||||
{
|
||||
$gruppo = $this->gruppo();
|
||||
|
||||
return $gruppo !== null
|
||||
&& ($assegnazione->gruppo_servizio_id === $gruppo->id
|
||||
|| $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id);
|
||||
}
|
||||
|
||||
protected function paginaDopoInvio(): string
|
||||
{
|
||||
return route('mio-gruppo');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$gruppo = $this->gruppo()?->load('sorvegliante');
|
||||
|
||||
// Prima quelli da dare, in ordine di priorità come "Da assegnare"
|
||||
// nella Home (Territorio::ordinaPerPriorita); poi quelli già dati, dal
|
||||
// più vecchio.
|
||||
$pacchetto = $gruppo
|
||||
? $gruppo->territori()->with(['zona', 'tipologia', 'ultimaAssegnazione', 'assegnazioneCorrente.proclamatore'])->get()
|
||||
: collect();
|
||||
[$dati, $daDare] = $pacchetto->partition(fn (Territorio $t) => $t->assegnazioneCorrente !== null);
|
||||
$pacchetto = Territorio::ordinaPerPriorita($daDare)
|
||||
->concat($dati->sortBy(fn (Territorio $t) => $t->assegnazioneCorrente->assigned_at)->values());
|
||||
|
||||
$territori = $pacchetto
|
||||
->when($this->filtroZona !== '', fn ($c) => $c->where('zona_id', (int) $this->filtroZona))
|
||||
->when($this->filtroTipologia !== '', fn ($c) => $c->where('tipologia_id', (int) $this->filtroTipologia))
|
||||
->when($this->filtroStato === 'da_dare', fn ($c) => $c->whereNull('assegnazioneCorrente'))
|
||||
->when($this->filtroStato === 'dati', fn ($c) => $c->whereNotNull('assegnazioneCorrente'))
|
||||
->when($this->soloPrioritari, fn ($c) => $c->where('in_priorita', true))
|
||||
->values();
|
||||
|
||||
$membri = $gruppo
|
||||
? $gruppo->membri()->where('attivo', true)->get()->sortBy(fn ($p) => mb_strtolower($p->cognome . ' ' . $p->nome))->values()
|
||||
: collect();
|
||||
|
||||
return view('livewire.gruppi.mio-gruppo', [
|
||||
'gruppo' => $gruppo,
|
||||
'pacchetto' => $pacchetto,
|
||||
'territori' => $territori,
|
||||
'zone' => $pacchetto->filter(fn ($t) => $t->zona)->mapWithKeys(fn ($t) => [$t->zona_id => $t->zona->nome])->sort(),
|
||||
'tipologie' => $pacchetto->filter(fn ($t) => $t->tipologia)->mapWithKeys(fn ($t) => [$t->tipologia_id => $t->tipologia->nome])->sort(),
|
||||
'membri' => $membri,
|
||||
'opzioniMembri' => Proclamatore::opzioniScelta($membri, conGruppo: false),
|
||||
'invio' => $this->invioCorrente(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,50 +3,67 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Setting;
|
||||
use App\Services\RichiesteServizio;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
|
||||
class Home extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
|
||||
/**
|
||||
* Le richieste dal modulo pubblico si confermano portandole su Assegna o
|
||||
* Rientra (link nel riquadro); qui si possono solo scartare.
|
||||
*/
|
||||
public function scartaRichiesta(int $id): void
|
||||
{
|
||||
$this->authorize('richieste.gestisci');
|
||||
Richiesta::findOrFail($id)->scarta(auth()->user());
|
||||
$this->avvisa('Richiesta scartata.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Le richieste aperte, con quello che serve a confermarle: per un
|
||||
* territorio nuovo i proclamatori dal nome simile a quello scritto, per
|
||||
* una riconsegna l'assegnazione aperta di quel territorio.
|
||||
*/
|
||||
private function richiesteAperte(): \Illuminate\Support\Collection
|
||||
{
|
||||
if (! auth()->user()->can('richieste.gestisci')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$aperte = Richiesta::aperte()->orderBy('created_at')->get();
|
||||
if ($aperte->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$servizio = app(RichiesteServizio::class);
|
||||
$proclamatori = Proclamatore::attivi()->get();
|
||||
|
||||
return $aperte->map(fn (Richiesta $r) => [
|
||||
'richiesta' => $r,
|
||||
'simili' => $r->tipo === Richiesta::NUOVO ? $servizio->nomiSimili($r->nome, $proclamatori) : collect(),
|
||||
'assegnazione' => $r->tipo === Richiesta::RICONSEGNA ? $servizio->assegnazioneAperta($r->numero_territorio) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadPdfDaAssegnare()
|
||||
{
|
||||
$this->authorize('territori.assign');
|
||||
|
||||
$settings = Setting::instance();
|
||||
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
|
||||
|
||||
$territori = Territorio::inReparto()
|
||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
||||
->get()
|
||||
->map(function (Territorio $territorio) use ($priorityThreshold) {
|
||||
$ultima = $territorio->ultimaAssegnazione;
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
|
||||
} elseif (! $ultima) {
|
||||
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
|
||||
} else {
|
||||
$giorniGiacenza = 0;
|
||||
}
|
||||
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
|
||||
$territorio->setAttribute(
|
||||
'home_is_prioritario',
|
||||
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
|
||||
$territori = Territorio::ordinaPerPriorita(
|
||||
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
|
||||
);
|
||||
return $territorio;
|
||||
})
|
||||
->sort(function (Territorio $left, Territorio $right) {
|
||||
$p = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
|
||||
if ($p !== 0) return $p;
|
||||
$g = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
|
||||
if ($g !== 0) return $g;
|
||||
return strnatcasecmp((string) $left->numero, (string) $right->numero);
|
||||
})
|
||||
->values();
|
||||
|
||||
$pdf = Pdf::loadView('pdf.territori-lista', [
|
||||
'titolo' => 'Territori da Assegnare',
|
||||
@@ -90,7 +107,6 @@ class Home extends Component
|
||||
{
|
||||
$settings = Setting::instance();
|
||||
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
|
||||
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
|
||||
$annoCorrente = AnnoTeocratico::corrente();
|
||||
$campagnaAttiva = Campagna::attiva();
|
||||
|
||||
@@ -149,46 +165,11 @@ class Home extends Component
|
||||
];
|
||||
}
|
||||
|
||||
// Quick lists
|
||||
$territoriDaAssegnare = Territorio::inReparto()
|
||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
||||
->get()
|
||||
->map(function (Territorio $territorio) use ($priorityThreshold) {
|
||||
$ultima = $territorio->ultimaAssegnazione;
|
||||
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
|
||||
} elseif (! $ultima) {
|
||||
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
|
||||
} else {
|
||||
$giorniGiacenza = 0;
|
||||
}
|
||||
|
||||
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
|
||||
$territorio->setAttribute(
|
||||
'home_is_prioritario',
|
||||
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
|
||||
);
|
||||
|
||||
return $territorio;
|
||||
})
|
||||
->sort(function (Territorio $left, Territorio $right) {
|
||||
$priorityComparison = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
|
||||
|
||||
if ($priorityComparison !== 0) {
|
||||
return $priorityComparison;
|
||||
}
|
||||
|
||||
$giacenzaComparison = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
|
||||
|
||||
if ($giacenzaComparison !== 0) {
|
||||
return $giacenzaComparison;
|
||||
}
|
||||
|
||||
return strnatcasecmp((string) $left->numero, (string) $right->numero);
|
||||
})
|
||||
->take($homeLimit)
|
||||
->values();
|
||||
// Quick lists: la logica di priorità sta in Territorio::ordinaPerPriorita
|
||||
// (la usano anche il pacchetto dei gruppi di servizio e "Il mio gruppo").
|
||||
$territoriDaAssegnare = Territorio::ordinaPerPriorita(
|
||||
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
|
||||
)->take($homeLimit)->values();
|
||||
|
||||
$daRientrare = Territorio::daRientrare()
|
||||
->with(['zona', 'assegnazioneCorrente.proclamatore'])
|
||||
@@ -208,6 +189,7 @@ class Home extends Component
|
||||
'homeLimit' => $homeLimit,
|
||||
'territoriDaAssegnare' => $territoriDaAssegnare,
|
||||
'daRientrare' => $daRientrare,
|
||||
'richieste' => $this->richiesteAperte(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,20 @@ class PiantineEditor extends Component
|
||||
|
||||
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
|
||||
{
|
||||
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
|
||||
$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]);
|
||||
$this->carica($mappa);
|
||||
activity()->causedBy(auth()->user())->log('piantina_creata');
|
||||
|
||||
if ($this->creaTerritorio) {
|
||||
$this->creaTerritorio = false;
|
||||
$this->territorioScelto = 'nuovo';
|
||||
}
|
||||
}
|
||||
|
||||
$this->salvato($mappa, seleziona: true);
|
||||
@@ -562,18 +577,33 @@ class PiantineEditor extends Component
|
||||
->pluck('territorio_id');
|
||||
|
||||
$territori = Territorio::attivi()
|
||||
->with(['zona:id,nome', 'tipologia:id,nome'])
|
||||
->whereNotIn('id', $occupati)
|
||||
->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)) {
|
||||
$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', [
|
||||
'mappa' => $mappa,
|
||||
'areaCaricata' => app(PiantineArea::class)->config()['kml_nome'] !== null,
|
||||
'territori' => $territori,
|
||||
'opzioniTerritori' => $opzioniTerritori,
|
||||
'zone' => Zona::orderBy('nome')->get(['id', 'nome']),
|
||||
'tipologie' => Tipologia::orderBy('nome')->get(['id', 'nome']),
|
||||
'title' => $mappa ? $mappa->etichetta() . ' — Editor delle piantine' : 'Editor delle piantine',
|
||||
|
||||
@@ -2,18 +2,89 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Richiesta;
|
||||
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
|
||||
{
|
||||
/** Data dell'ultima revisione del testo dell'informativa. */
|
||||
public const REVISIONE = '21 ottobre 2026';
|
||||
|
||||
/** I permessi spiegati a chi legge l'informativa. */
|
||||
public const PERMESSI = [
|
||||
'settings.manage' => 'impostazioni, utenti, backup e import',
|
||||
'proclamatori.manage' => 'anagrafica dei proclamatori',
|
||||
'gruppo.gestisci' => 'solo il proprio gruppo di servizio: i territori del pacchetto e i membri del gruppo',
|
||||
'territori.manage' => 'gestione dei territori e delle piantine',
|
||||
'campagne.manage' => 'campagne',
|
||||
'registro.view' => 'consultazione del registro delle assegnazioni',
|
||||
'registro.export' => 'esportazione del registro',
|
||||
'territori.assign' => 'assegnazione dei territori',
|
||||
'territori.return' => 'rientro dei territori',
|
||||
'richieste.gestisci' => 'richieste arrivate dal modulo pubblico (nuovo territorio o riconsegna)',
|
||||
'audit.view' => 'consultazione del registro delle attività',
|
||||
'audit.export' => 'esportazione del registro delle attività',
|
||||
];
|
||||
|
||||
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', [
|
||||
'congregazione' => $settings->congregazione_nome ?? 'Congregazione',
|
||||
'auditRetention' => $settings->audit_retention_days ?? 365,
|
||||
'revisione' => self::REVISIONE,
|
||||
'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(),
|
||||
'richieste' => Richiesta::count(),
|
||||
'richiesteAperte' => Richiesta::aperte()->count(),
|
||||
'attivita' => Activity::count(),
|
||||
'attivitaDal' => Activity::min('created_at'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreCreate extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public string $nome = '';
|
||||
public string $cognome = '';
|
||||
public bool $attivo = true;
|
||||
public string $email = '';
|
||||
public string $telefono = '';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
@@ -19,17 +20,6 @@ class ProclamatoreCreate extends Component
|
||||
'nome' => 'required|string|max:100',
|
||||
'cognome' => 'required|string|max:100',
|
||||
'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,
|
||||
'cognome' => $this->cognome,
|
||||
'attivo' => $this->attivo,
|
||||
'email' => trim($this->email) ?: null,
|
||||
'telefono' => trim($this->telefono) ?: null,
|
||||
]);
|
||||
|
||||
activity()->causedBy(auth()->user())
|
||||
@@ -52,7 +40,7 @@ class ProclamatoreCreate extends Component
|
||||
->log('created');
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreEdit extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Proclamatore $proclamatore;
|
||||
public string $nome = '';
|
||||
public string $cognome = '';
|
||||
public bool $attivo = true;
|
||||
public string $email = '';
|
||||
public string $telefono = '';
|
||||
|
||||
public function mount(Proclamatore $proclamatore)
|
||||
{
|
||||
@@ -20,8 +21,6 @@ class ProclamatoreEdit extends Component
|
||||
$this->nome = $proclamatore->nome;
|
||||
$this->cognome = $proclamatore->cognome;
|
||||
$this->attivo = $proclamatore->attivo;
|
||||
$this->email = (string) $proclamatore->email;
|
||||
$this->telefono = (string) $proclamatore->telefono;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
@@ -30,17 +29,6 @@ class ProclamatoreEdit extends Component
|
||||
'nome' => 'required|string|max:100',
|
||||
'cognome' => 'required|string|max:100',
|
||||
'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).',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -50,16 +38,18 @@ class ProclamatoreEdit extends Component
|
||||
|
||||
$this->validate();
|
||||
|
||||
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
|
||||
$gruppo = ! $this->attivo ? $this->proclamatore->gruppoSorvegliato() : null;
|
||||
|
||||
$this->proclamatore->update([
|
||||
'nome' => $this->nome,
|
||||
'cognome' => $this->cognome,
|
||||
'attivo' => $this->attivo,
|
||||
'email' => trim($this->email) ?: null,
|
||||
'telefono' => trim($this->telefono) ?: null,
|
||||
]);
|
||||
|
||||
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");
|
||||
return $this->redirect(route('proclamatori.index'), navigate: true);
|
||||
$avviso = "Proclamatore {$this->proclamatore->nome_completo} aggiornato." . ($gruppo ? ' ' . ProclamatoreIndex::gruppoSciolto($gruppo) : '');
|
||||
session()->flash('success', $avviso);
|
||||
return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
@@ -67,6 +57,7 @@ class ProclamatoreEdit extends Component
|
||||
return view('livewire.proclamatori.proclamatore-form', [
|
||||
'titolo' => "Modifica {$this->proclamatore->nome_completo}",
|
||||
'btnLabel' => 'Salva Modifiche',
|
||||
'gruppoSorvegliato' => $this->proclamatore->gruppoSorvegliato(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreIndex extends Component
|
||||
@@ -12,9 +14,14 @@ class ProclamatoreIndex extends Component
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
// Ricerca, filtro e ordinamento nell'indirizzo: si ritrovano tornando.
|
||||
#[Url(except: '')]
|
||||
public string $search = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroStato = '';
|
||||
#[Url(except: 'cognome')]
|
||||
public string $sortField = 'cognome';
|
||||
#[Url(except: 'asc')]
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
protected $queryString = [
|
||||
@@ -51,7 +58,13 @@ class ProclamatoreIndex extends Component
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
$gruppo = $proclamatore->attivo ? $proclamatore->gruppoSorvegliato() : null;
|
||||
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
|
||||
$proclamatore->update(['attivo' => !$proclamatore->attivo]);
|
||||
|
||||
if ($gruppo) {
|
||||
$this->avvisa("{$proclamatore->nome_completo} non è più attivo. " . self::gruppoSciolto($gruppo));
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteProclamatore(int $id)
|
||||
@@ -65,8 +78,11 @@ class ProclamatoreIndex extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
// Nel cestino lascia il gruppo; se ne era il sorvegliante, il gruppo
|
||||
// si scioglie (Proclamatore::booted).
|
||||
$gruppo = $proclamatore->gruppoSorvegliato();
|
||||
$proclamatore->delete();
|
||||
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
||||
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino." . ($gruppo ? ' ' . self::gruppoSciolto($gruppo) : ''));
|
||||
}
|
||||
|
||||
public function anonimizza(int $id)
|
||||
@@ -74,8 +90,15 @@ class ProclamatoreIndex extends Component
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
$gruppo = $proclamatore->gruppoSorvegliato();
|
||||
$proclamatore->anonimizza();
|
||||
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
|
||||
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR)." . ($gruppo ? ' ' . self::gruppoSciolto($gruppo) : ''));
|
||||
}
|
||||
|
||||
/** L'avviso quando un sorvegliante esce di scena: anche per ProclamatoreEdit. */
|
||||
public static function gruppoSciolto(GruppoServizio $gruppo): string
|
||||
{
|
||||
return "Era il sorvegliante del gruppo {$gruppo->nome}: il gruppo è stato sciolto, i territori non ancora dati sono tornati in reparto e la sua utenza è stata eliminata.";
|
||||
}
|
||||
|
||||
public function render()
|
||||
@@ -83,7 +106,7 @@ class ProclamatoreIndex extends Component
|
||||
// withCount rather than letting the view call
|
||||
// ->assegnazioni()->aperte()->count() per row, which issued one
|
||||
// COUNT(*) per displayed proclamatore on every render.
|
||||
$query = Proclamatore::query()->withCount([
|
||||
$query = Proclamatore::query()->with('gruppo')->withCount([
|
||||
'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'),
|
||||
]);
|
||||
|
||||
@@ -101,6 +124,8 @@ class ProclamatoreIndex extends Component
|
||||
$filtered = $filtered->where('attivo', true);
|
||||
} elseif ($this->filtroStato === 'inattivo') {
|
||||
$filtered = $filtered->where('attivo', false);
|
||||
} elseif ($this->filtroStato === 'gruppo') {
|
||||
$filtered = $filtered->whereNotNull('gruppo_servizio_id');
|
||||
}
|
||||
|
||||
// Sort in-memory
|
||||
@@ -125,6 +150,8 @@ class ProclamatoreIndex extends Component
|
||||
$query->where('attivo', true);
|
||||
} elseif ($this->filtroStato === 'inattivo') {
|
||||
$query->where('attivo', false);
|
||||
} elseif ($this->filtroStato === 'gruppo') {
|
||||
$query->whereNotNull('gruppo_servizio_id');
|
||||
}
|
||||
|
||||
// cognome/nome encrypted, so sort in-memory
|
||||
@@ -147,6 +174,8 @@ class ProclamatoreIndex extends Component
|
||||
|
||||
return view('livewire.proclamatori.proclamatore-index', [
|
||||
'proclamatori' => $proclamatori,
|
||||
// Per avvisare, prima di disattivare o cestinare, che il gruppo si scioglie.
|
||||
'gruppiSorvegliati' => GruppoServizio::pluck('nome', 'sorvegliante_id'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class ProclamatoreShow extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Proclamatore $proclamatore;
|
||||
|
||||
public function mount(Proclamatore $proclamatore)
|
||||
@@ -20,8 +24,11 @@ class ProclamatoreShow extends Component
|
||||
$assegnazioni = Assegnazione::where('proclamatore_id', $this->proclamatore->id)
|
||||
->with(['territorio', 'annoTeocratico', 'campagna'])
|
||||
->orderByDesc('assigned_at')
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label);
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label)
|
||||
// Dal più recente: gli anni ("2026-2027") si ordinano come testo.
|
||||
->sortKeysDesc();
|
||||
|
||||
$stats = [
|
||||
'totale_assegnazioni' => Assegnazione::where('proclamatore_id', $this->proclamatore->id)->count(),
|
||||
@@ -34,9 +41,21 @@ class ProclamatoreShow extends Component
|
||||
),
|
||||
];
|
||||
|
||||
// Gruppo di servizio: sorvegliante (con i dettagli del gruppo e della
|
||||
// sua utenza) o membro. I gruppi sciolti non esistono più.
|
||||
$gruppoSorvegliato = $this->proclamatore->gruppoSorvegliato();
|
||||
$utenza = $gruppoSorvegliato ? $this->proclamatore->utente : null;
|
||||
|
||||
return view('livewire.proclamatori.proclamatore-show', [
|
||||
'assegnazioniPerAnno' => $assegnazioni,
|
||||
'stats' => $stats,
|
||||
'gruppoSorvegliato' => $gruppoSorvegliato,
|
||||
'riepilogoGruppo' => $gruppoSorvegliato?->riepilogo(),
|
||||
'gruppoMembro' => $gruppoSorvegliato ? null : $this->proclamatore->gruppo()->with('sorvegliante')->first(),
|
||||
'utenza' => $utenza,
|
||||
'ultimoAccesso' => $utenza
|
||||
? Activity::whereMorphedTo('causer', $utenza)->where('description', 'login')->max('created_at')
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,21 +27,24 @@ class Profile extends Component
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Il sorvegliante di gruppo entra con l'email fittizia creata col
|
||||
// gruppo (GruppiServizio::emailFittizia): dal profilo non la cambia.
|
||||
$emailFissa = $user->isSorvegliante();
|
||||
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
] + ($emailFissa ? [] : [
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
]);
|
||||
]));
|
||||
|
||||
$user->fill([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
]);
|
||||
$user->fill(['name' => $validated['name']] + ($emailFissa ? [] : ['email' => $validated['email']]));
|
||||
$emailCambiata = $user->isDirty('email');
|
||||
$user->save();
|
||||
|
||||
// L'indirizzo nuovo va confermato (User::booted lo segna da
|
||||
// confermare): finché non apre il link, l'account resta bloccato.
|
||||
if ($emailCambiata) {
|
||||
// Il sorvegliante di gruppo non conferma l'email (User::hasVerifiedEmail).
|
||||
if ($emailCambiata && ! $user->isSorvegliante()) {
|
||||
$user->inviaConfermaEmail()
|
||||
? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.")
|
||||
: session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.');
|
||||
@@ -77,6 +80,7 @@ class Profile extends Component
|
||||
auth()->user()->update([
|
||||
'password' => $this->password,
|
||||
'password_change_required' => false,
|
||||
'password_temporanea' => false,
|
||||
]);
|
||||
|
||||
$this->reset(['current_password', 'password', 'password_confirmation']);
|
||||
@@ -86,8 +90,13 @@ class Profile extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Il sorvegliante vede qui i dettagli del suo gruppo di servizio.
|
||||
$gruppo = auth()->user()->gruppoSorvegliato();
|
||||
|
||||
return view('livewire.profile', [
|
||||
'user' => auth()->user(),
|
||||
'gruppo' => $gruppo,
|
||||
'riepilogoGruppo' => $gruppo?->riepilogo(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Assegnazione;
|
||||
@@ -19,12 +20,20 @@ class Registro extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
// Ricerca, filtri e ordinamento nell'indirizzo: si ritrovano tornando.
|
||||
#[Url(except: '')]
|
||||
public string $search = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroAnno = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroZona = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroTipologia = '';
|
||||
#[Url(except: '')]
|
||||
public string $filtroStato = ''; // aperte, chiuse
|
||||
#[Url(except: 'territorio_numero')]
|
||||
public string $sortField = 'territorio_numero';
|
||||
#[Url(except: 'asc')]
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
// ─── Modal create/edit ──────────────────────────────────────
|
||||
@@ -154,7 +163,10 @@ class Registro extends Component
|
||||
'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();
|
||||
|
||||
$conflitto = Assegnazione::sovrappostaA((int) $data['territorio_id'], $uscita, $rientro, $this->editingId);
|
||||
@@ -163,9 +175,11 @@ class Registro extends Component
|
||||
}
|
||||
|
||||
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 {
|
||||
Assegnazione::create($data + ['created_by' => auth()->id()]);
|
||||
$assegnazione = Assegnazione::create($data + ['created_by' => auth()->id()]);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -177,6 +191,16 @@ class Registro extends Component
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -324,8 +348,14 @@ class Registro extends Component
|
||||
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
|
||||
'zone' => Zona::attive()->orderBy('nome')->get(),
|
||||
'tipologie' => Tipologia::orderBy('nome')->get(),
|
||||
'territori' => Territorio::attivi()->orderBy('numero')->get(),
|
||||
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(),
|
||||
// Campi di scelta con ricerca della finestra di modifica. Ci sono
|
||||
// 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
137
app/Livewire/Richieste/ModuloRichiesta.php
Normal file
137
app/Livewire/Richieste/ModuloRichiesta.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Richieste;
|
||||
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Services\RichiesteServizio;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
|
||||
* territorio nuovo o annuncia una riconsegna. L'unico dato già presente che
|
||||
* mostra è l'elenco dei territori nella riconsegna, per il completamento
|
||||
* automatico del numero: numero e zona, mai a chi è assegnato né se lo è.
|
||||
* Nomi e altri dati non si vedono.
|
||||
*
|
||||
* Contro lo spam, tre difese che a chi compila davvero non costano niente:
|
||||
* - un campo trappola ($sito) nascosto alle persone e riempito dai programmi;
|
||||
* - un tempo minimo fra apertura e invio (i programmi inviano subito);
|
||||
* - al massimo MASSIMO_ORA richieste all'ora per indirizzo.
|
||||
* Nei primi due casi si risponde come se fosse andata bene, senza salvare:
|
||||
* chi prova non impara cosa l'ha fermato.
|
||||
*/
|
||||
class ModuloRichiesta extends Component
|
||||
{
|
||||
public const MASSIMO_ORA = 5;
|
||||
|
||||
public const SECONDI_MINIMI = 3;
|
||||
|
||||
public string $tipo = Richiesta::NUOVO;
|
||||
|
||||
public string $nome = '';
|
||||
|
||||
public string $numero = '';
|
||||
|
||||
public string $note = '';
|
||||
|
||||
/** Trappola: nascosto alle persone, i programmi lo riempiono. */
|
||||
public string $sito = '';
|
||||
|
||||
#[Locked]
|
||||
public int $apertoAlle = 0;
|
||||
|
||||
#[Locked]
|
||||
public bool $inviata = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->apertoAlle = now()->timestamp;
|
||||
}
|
||||
|
||||
public function invia(RichiesteServizio $richieste): void
|
||||
{
|
||||
if ($this->sito !== '' || now()->timestamp - $this->apertoAlle < self::SECONDI_MINIMI) {
|
||||
$this->inviata = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$chiave = 'richiesta-pubblica:' . sha1((string) request()->ip());
|
||||
if (RateLimiter::tooManyAttempts($chiave, self::MASSIMO_ORA)) {
|
||||
$this->addError('nome', "Hai già mandato diverse richieste: riprova fra un po'.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'tipo' => 'required|in:' . Richiesta::NUOVO . ',' . Richiesta::RICONSEGNA,
|
||||
'nome' => 'required|string|min:3|max:100',
|
||||
'numero' => ['nullable', 'required_if:tipo,' . Richiesta::RICONSEGNA, 'string', 'max:20',
|
||||
function ($attributo, $valore, $errore) {
|
||||
if ($this->tipo === Richiesta::RICONSEGNA && ! self::territori()->whereRaw('LOWER(numero) = ?', [Str::lower(trim((string) $valore))])->exists()) {
|
||||
$errore("Scegli il territorio dall'elenco.");
|
||||
}
|
||||
}],
|
||||
'note' => 'nullable|string|max:1000',
|
||||
], [
|
||||
'nome.required' => 'Scrivi nome e cognome.',
|
||||
'nome.min' => 'Scrivi nome e cognome.',
|
||||
'numero.required_if' => 'Scegli il territorio che riconsegni.',
|
||||
]);
|
||||
|
||||
RateLimiter::hit($chiave, 3600);
|
||||
$richieste->registra($this->tipo, $this->nome, $this->numero, $this->note);
|
||||
|
||||
$this->reset(['nome', 'numero', 'note']);
|
||||
$this->inviata = true;
|
||||
}
|
||||
|
||||
public function nuova(): void
|
||||
{
|
||||
$this->reset(['tipo', 'nome', 'numero', 'note', 'inviata']);
|
||||
$this->apertoAlle = now()->timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* I territori che si possono riconsegnare: quelli attivi e quelli ancora
|
||||
* fuori anche se nel frattempo disattivati.
|
||||
*/
|
||||
private static function territori(): Builder
|
||||
{
|
||||
return Territorio::query()->where(fn ($q) => $q->where('attivo', true)
|
||||
->orWhereHas('assegnazioni', fn ($a) => $a->whereNull('returned_at')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le proposte del campo con ricerca (<x-scelta>): solo numero e zona. Il
|
||||
* valore è il numero, come lo salva la richiesta.
|
||||
*/
|
||||
private static function opzioniTerritori(): array
|
||||
{
|
||||
return self::territori()->with('zona')->get()
|
||||
->sortBy('numero', SORT_NATURAL)
|
||||
->map(fn (Territorio $t) => array_filter([
|
||||
'valore' => (string) $t->numero,
|
||||
'etichetta' => "N° {$t->numero}",
|
||||
'dettaglio' => $t->zona?->nome,
|
||||
'chiavi' => [(string) $t->numero, (string) $t->zona?->nome],
|
||||
], fn ($v) => $v !== null))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.richieste.modulo-richiesta', [
|
||||
'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2',
|
||||
// Solo quando serve: chi chiede un territorio nuovo non vede l'elenco.
|
||||
'opzioniTerritori' => $this->tipo === Richiesta::RICONSEGNA && ! $this->inviata ? self::opzioniTerritori() : [],
|
||||
])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\GruppiServizio;
|
||||
use Illuminate\Validation\Rule;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class UsersIndex extends Component
|
||||
@@ -25,6 +25,17 @@ class UsersIndex extends Component
|
||||
#[Locked]
|
||||
public array $availableRoles = [];
|
||||
|
||||
/**
|
||||
* Nuova password temporanea di un sorvegliante di gruppo, mostrata una
|
||||
* volta sola (come nella pagina del gruppo). Locked: le scrive solo
|
||||
* nuovaPasswordSorvegliante().
|
||||
*/
|
||||
#[Locked]
|
||||
public ?int $passwordPerUtenteId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $passwordTemporanea = null;
|
||||
|
||||
public ?int $editingUserId = null;
|
||||
public string $editName = '';
|
||||
public string $editEmail = '';
|
||||
@@ -34,7 +45,10 @@ class UsersIndex extends Component
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// "sorvegliante" si dà solo creando un gruppo di servizio, con
|
||||
// l'utenza collegata al proclamatore: da qui non si assegna.
|
||||
$this->availableRoles = Role::query()
|
||||
->where('name', '!=', 'sorvegliante')
|
||||
->orderBy('name')
|
||||
->pluck('name')
|
||||
->all();
|
||||
@@ -111,9 +125,28 @@ class UsersIndex extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le utenze dei sorveglianti di gruppo si gestiscono
|
||||
* da Gruppi di servizio: cambiarle o cancellarle da qui lascerebbe un
|
||||
* gruppo senza sorvegliante, o un sorvegliante con un ruolo diverso.
|
||||
*/
|
||||
private function gestitaDaiGruppi(User $user): bool
|
||||
{
|
||||
if ($user->proclamatore_id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->avvisaErrore("L'utenza di {$user->name} è di un sorvegliante di gruppo: si gestisce da Gruppi di servizio.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function startEdit(int $userId): void
|
||||
{
|
||||
$user = User::query()->with('roles')->findOrFail($userId);
|
||||
if ($this->gestitaDaiGruppi($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingUserId = $user->id;
|
||||
$this->editName = $user->name;
|
||||
@@ -143,6 +176,9 @@ class UsersIndex extends Component
|
||||
|
||||
$validated = $this->validate($this->editRules());
|
||||
$user = User::query()->findOrFail($this->editingUserId);
|
||||
if ($this->gestitaDaiGruppi($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
|
||||
// rimasto, lascia l'installazione senza nessuno che possa gestire
|
||||
@@ -193,6 +229,9 @@ class UsersIndex extends Component
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
$user = User::query()->with('roles')->findOrFail($userId);
|
||||
if ($this->gestitaDaiGruppi($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $currentUser || $currentUser->id === $user->id) {
|
||||
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
|
||||
@@ -204,37 +243,7 @@ class UsersIndex extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $currentUser) {
|
||||
$causerName = $user->name;
|
||||
$causerEmail = $user->email;
|
||||
$deletedAt = now()->toDateTimeString();
|
||||
|
||||
Activity::query()
|
||||
->where('causer_type', User::class)
|
||||
->where('causer_id', $user->id)
|
||||
->chunkById(200, function ($activities) use ($causerName, $causerEmail, $deletedAt) {
|
||||
foreach ($activities as $activity) {
|
||||
$properties = $activity->properties?->toArray() ?? [];
|
||||
$properties['causer_name'] = $causerName;
|
||||
$properties['causer_email'] = $causerEmail;
|
||||
$properties['causer_deleted_at'] = $deletedAt;
|
||||
|
||||
$activity->properties = $properties;
|
||||
$activity->save();
|
||||
}
|
||||
});
|
||||
|
||||
Assegnazione::query()
|
||||
->where('created_by', $user->id)
|
||||
->update(['created_by' => $currentUser->id]);
|
||||
|
||||
Assegnazione::query()
|
||||
->where('returned_by', $user->id)
|
||||
->update(['returned_by' => $currentUser->id]);
|
||||
|
||||
$user->syncRoles([]);
|
||||
$user->delete();
|
||||
});
|
||||
$user->eliminaConservandoStorico($currentUser, $user->name, $user->email);
|
||||
|
||||
if ($this->editingUserId === $userId) {
|
||||
$this->cancelEdit();
|
||||
@@ -243,10 +252,39 @@ class UsersIndex extends Component
|
||||
$this->avvisa('Utente cancellato. I log sono stati preservati.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Il sorvegliante ha perso la password: una temporanea nuova, da cambiare
|
||||
* al primo accesso. È l'unica cosa che di quell'utenza si fa da qui; il
|
||||
* resto passa dal gruppo (gestitaDaiGruppi).
|
||||
*/
|
||||
public function nuovaPasswordSorvegliante(int $userId): void
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
$user = User::query()->findOrFail($userId);
|
||||
|
||||
try {
|
||||
$this->passwordTemporanea = app(GruppiServizio::class)->nuovaPasswordTemporanea($user);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->avvisaErrore($e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->passwordPerUtenteId = $user->id;
|
||||
}
|
||||
|
||||
public function chiudiPassword(): void
|
||||
{
|
||||
$this->reset(['passwordPerUtenteId', 'passwordTemporanea']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.users-index', [
|
||||
'users' => User::query()->with('roles')->orderBy('name')->get(),
|
||||
// Per ogni sorvegliante il suo gruppo, per il collegamento.
|
||||
'gruppiDeiSorveglianti' => GruppoServizio::pluck('id', 'sorvegliante_id'),
|
||||
'utentePassword' => $this->passwordPerUtenteId ? User::find($this->passwordPerUtenteId) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
@@ -469,6 +470,7 @@ class XmlExchange extends Component
|
||||
try {
|
||||
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
|
||||
Assegnazione::query()->delete();
|
||||
GruppoServizio::query()->delete();
|
||||
Campagna::query()->delete();
|
||||
Territorio::query()->withTrashed()->forceDelete();
|
||||
Proclamatore::query()->withTrashed()->forceDelete();
|
||||
@@ -520,10 +522,18 @@ class XmlExchange extends Component
|
||||
// accounts in the file are (re)created. $userMap remaps the
|
||||
// created_by/returned_by FKs on the assignments below.
|
||||
$userMap = [];
|
||||
$utentiDaCollegare = [];
|
||||
foreach (($xml->users->user ?? []) as $userNode) {
|
||||
$email = trim((string) ($userNode->email ?? ''));
|
||||
$legacyId = (string) ($userNode['legacy_id'] ?? '');
|
||||
|
||||
// Nei file del 19-20 ottobre 2026 c'erano le utenze disattivate
|
||||
// degli ex sorveglianti: ora, sciolto il gruppo, l'utenza non
|
||||
// esiste più. Le loro assegnazioni passano a chi importa.
|
||||
if (trim((string) ($userNode->disattivato_at ?? '')) !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($email === '') {
|
||||
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
|
||||
continue;
|
||||
@@ -572,6 +582,7 @@ class XmlExchange extends Component
|
||||
if ($passwordHash !== '' && ! $eAttore) {
|
||||
$meta['password'] = $passwordHash;
|
||||
$meta['password_change_required'] = true;
|
||||
$meta['password_temporanea'] = false;
|
||||
}
|
||||
if (! $eAttore) {
|
||||
$meta['remember_token'] = null;
|
||||
@@ -589,6 +600,10 @@ class XmlExchange extends Component
|
||||
|
||||
DB::table('users')->where('id', $user->id)->update($meta);
|
||||
|
||||
// Utenza di un sorvegliante: si collega al proclamatore dopo che
|
||||
// i proclamatori sono stati importati (vedi i gruppi di servizio).
|
||||
$utentiDaCollegare[$user->id] = (string) ($userNode->legacy_proclamatore_id ?? '');
|
||||
|
||||
$stats['utenti_importati']++;
|
||||
if ($legacyId !== '') {
|
||||
$userMap[$legacyId] = $user->id;
|
||||
@@ -624,25 +639,57 @@ class XmlExchange extends Component
|
||||
}
|
||||
|
||||
$proclamatoreMap = [];
|
||||
$gruppiDeiProclamatori = [];
|
||||
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
|
||||
$p = Proclamatore::create([
|
||||
'nome' => (string) $proclamatoreNode->nome,
|
||||
'cognome' => (string) $proclamatoreNode->cognome,
|
||||
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
|
||||
// Facoltativi, e assenti nei file di prima dell'ottobre 2026.
|
||||
'email' => trim((string) ($proclamatoreNode->email ?? '')) ?: null,
|
||||
'telefono' => trim((string) ($proclamatoreNode->telefono ?? '')) ?: null,
|
||||
// <email> e <telefono>, presenti nei file del 13-19 ottobre
|
||||
// 2026, si ignorano: i proclamatori non li hanno più.
|
||||
]);
|
||||
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
|
||||
$stats['proclamatori_importati']++;
|
||||
$gruppiDeiProclamatori[$p->id] = (string) ($proclamatoreNode->legacy_gruppo_id ?? '');
|
||||
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
$proclamatoreMap[$legacyId] = $p->id;
|
||||
}
|
||||
}
|
||||
|
||||
$annoMap = [];
|
||||
foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
|
||||
// Gruppi di servizio, dopo i proclamatori: il sorvegliante è uno di
|
||||
// loro. Poi i collegamenti che li aspettavano (membri, utenze).
|
||||
$gruppoMap = [];
|
||||
foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) {
|
||||
$legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? '');
|
||||
// Nei file del 19-20 ottobre 2026 un gruppo sciolto c'era ancora
|
||||
// (<sciolto_at>): ora un gruppo sciolto non esiste più, quindi
|
||||
// non si importa.
|
||||
if (trim((string) ($gruppoNode->sciolto_at ?? '')) !== '') {
|
||||
continue;
|
||||
}
|
||||
$g = GruppoServizio::create([
|
||||
'nome' => (string) $gruppoNode->nome,
|
||||
'sorvegliante_id' => $proclamatoreMap[$legacySorvegliante] ?? null,
|
||||
]);
|
||||
$this->applyRowMeta('gruppi_servizio', $g->id, $gruppoNode);
|
||||
$legacyId = (string) ($gruppoNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
$gruppoMap[$legacyId] = $g->id;
|
||||
}
|
||||
}
|
||||
foreach ($gruppiDeiProclamatori as $idProclamatore => $legacyGruppo) {
|
||||
if (isset($gruppoMap[$legacyGruppo])) {
|
||||
DB::table('proclamatori')->where('id', $idProclamatore)->update(['gruppo_servizio_id' => $gruppoMap[$legacyGruppo]]);
|
||||
}
|
||||
}
|
||||
foreach ($utentiDaCollegare as $idUtente => $legacyProclamatore) {
|
||||
DB::table('users')->where('id', $idUtente)->update([
|
||||
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$annoMap = []; foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
|
||||
$label = (string) $annoNode->label;
|
||||
$anno = AnnoTeocratico::create([
|
||||
'label' => $label,
|
||||
@@ -707,6 +754,7 @@ class XmlExchange extends Component
|
||||
'note' => (string) ($territorioNode->note ?? ''),
|
||||
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
||||
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
|
||||
'gruppo_servizio_id' => $gruppoMap[(string) ($territorioNode->legacy_gruppo_id ?? '')] ?? null,
|
||||
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
|
||||
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
|
||||
]);
|
||||
@@ -768,6 +816,11 @@ class XmlExchange extends Component
|
||||
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
|
||||
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
||||
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
|
||||
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
|
||||
'cartaceo' => ((int) ($assegnazioneNode->cartaceo ?? 0)) === 1,
|
||||
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null,
|
||||
'gruppo_nome' => trim((string) ($assegnazioneNode->gruppo_nome ?? '')) ?: null,
|
||||
'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null,
|
||||
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
|
||||
'note' => (string) ($assegnazioneNode->note ?? ''),
|
||||
'created_by' => $createdBy,
|
||||
@@ -823,16 +876,19 @@ class XmlExchange extends Component
|
||||
// remember_token no: all'import si azzera comunque, e nel file
|
||||
// sarebbe solo una credenziale in più per chi lo tiene.
|
||||
'roles' => $u->roles->pluck('name')->all(),
|
||||
// Sorvegliante di gruppo: il proclamatore collegato.
|
||||
'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '',
|
||||
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
||||
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
||||
])->all(),
|
||||
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'email', 'telefono', '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']),
|
||||
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']),
|
||||
'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'created_at', 'updated_at']),
|
||||
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'gruppo_servizio_id', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
|
||||
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', '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', 'cartaceo', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1231,6 +1287,7 @@ class XmlExchange extends Component
|
||||
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
|
||||
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
||||
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
||||
$node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
|
||||
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
|
||||
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
||||
$rolesNode = $node->addChild('roles');
|
||||
@@ -1263,9 +1320,8 @@ class XmlExchange extends Component
|
||||
$this->addLegacyIdAttribute($node, $proclamatore);
|
||||
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
|
||||
$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('legacy_gruppo_id', (string) ($proclamatore['gruppo_servizio_id'] ?? ''));
|
||||
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
|
||||
$this->addTimestampNodes($node, $proclamatore);
|
||||
}
|
||||
@@ -1281,6 +1337,7 @@ class XmlExchange extends Component
|
||||
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
|
||||
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
|
||||
$node->addChild('legacy_gruppo_id', (string) ($territorio['gruppo_servizio_id'] ?? ''));
|
||||
$this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
|
||||
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
|
||||
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
|
||||
@@ -1297,6 +1354,16 @@ class XmlExchange extends Component
|
||||
$this->addTimestampNodes($node, $anno);
|
||||
}
|
||||
|
||||
// Gruppi di servizio: il sorvegliante è un proclamatore (legacy id).
|
||||
$gruppiNode = $xml->addChild('gruppi_servizio');
|
||||
foreach (($dataset['gruppi_servizio'] ?? []) as $gruppo) {
|
||||
$node = $gruppiNode->addChild('gruppo');
|
||||
$this->addLegacyIdAttribute($node, $gruppo);
|
||||
$this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? ''));
|
||||
$node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? ''));
|
||||
$this->addTimestampNodes($node, $gruppo);
|
||||
}
|
||||
|
||||
$campagneNode = $xml->addChild('campagne');
|
||||
foreach ($dataset['campagne'] as $campagna) {
|
||||
$node = $campagneNode->addChild('campagna');
|
||||
@@ -1321,6 +1388,11 @@ class XmlExchange extends Component
|
||||
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
|
||||
$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_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
|
||||
$node->addChild('cartaceo', (string) ((int) ($assegnazione['cartaceo'] ?? 0)));
|
||||
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? ''));
|
||||
$this->addXmlText($node, 'gruppo_nome', (string) ($assegnazione['gruppo_nome'] ?? ''));
|
||||
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
|
||||
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
|
||||
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
||||
$this->addTimestampNodes($node, $assegnazione);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use App\Models\Territorio;
|
||||
@@ -11,6 +12,8 @@ use App\Services\TerritorioThumbnailService;
|
||||
|
||||
class TerritorioCreate extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
use WithFileUploads;
|
||||
|
||||
public string $numero = '';
|
||||
@@ -64,7 +67,7 @@ class TerritorioCreate extends Component
|
||||
->log('created');
|
||||
|
||||
session()->flash('success', "Territorio {$territorio->numero} creato.");
|
||||
return redirect()->route('territori.index');
|
||||
return redirect()->to($this->destinazione(route('territori.index')));
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use App\Models\Territorio;
|
||||
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TerritorioEdit extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
use WithFileUploads;
|
||||
|
||||
public Territorio $territorio;
|
||||
@@ -82,7 +85,7 @@ class TerritorioEdit extends Component
|
||||
$this->territorio->update($data);
|
||||
|
||||
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()
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Territorio;
|
||||
@@ -15,14 +16,25 @@ class TerritorioIndex extends Component
|
||||
use Avvisa;
|
||||
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 = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterZona = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterTipologia = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterStato = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterPriorita = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterPdf = '';
|
||||
#[Url(except: '')]
|
||||
public string $filterContenuti = '';
|
||||
#[Url(except: 'numero')]
|
||||
public string $sortField = 'numero';
|
||||
#[Url(except: 'asc')]
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
protected $queryString = [
|
||||
@@ -143,6 +155,7 @@ class TerritorioIndex extends Component
|
||||
'tipologia',
|
||||
'assegnazioneCorrente.proclamatore',
|
||||
'ultimaAssegnazione',
|
||||
'gruppo',
|
||||
]);
|
||||
|
||||
if ($this->search) {
|
||||
@@ -167,6 +180,7 @@ class TerritorioIndex extends Component
|
||||
if ($this->filterStato) {
|
||||
match ($this->filterStato) {
|
||||
'in_reparto' => $query->inReparto(),
|
||||
'in_gruppo' => $query->inGruppo(),
|
||||
'assegnato' => $query->assegnato(),
|
||||
'da_rientrare' => $query->daRientrare(),
|
||||
'inattivo' => $query->where('attivo', false),
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\RicordaRitorno;
|
||||
use Livewire\Component;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Assegnazione;
|
||||
|
||||
class TerritorioShow extends Component
|
||||
{
|
||||
use RicordaRitorno;
|
||||
|
||||
public Territorio $territorio;
|
||||
|
||||
public function mount(Territorio $territorio)
|
||||
@@ -20,8 +23,11 @@ class TerritorioShow extends Component
|
||||
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
|
||||
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa'])
|
||||
->orderByDesc('assigned_at')
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label);
|
||||
->groupBy(fn($a) => $a->annoTeocratico->label)
|
||||
// Dal più recente: gli anni ("2026-2027") si ordinano come testo.
|
||||
->sortKeysDesc();
|
||||
|
||||
return view('livewire.territori.territorio-show', [
|
||||
'activeAssignment' => $this->territorio->assegnazioneCorrente,
|
||||
|
||||
50
app/Mail/ModuloS13Mail.php
Normal file
50
app/Mail/ModuloS13Mail.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\User;
|
||||
use Illuminate\Mail\Attachment;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Il modulo S-13 in PDF per un amministratore (registro:invia-s13). Non va in
|
||||
* coda (niente ShouldQueue): il PDF contiene i nomi dei proclamatori, che in
|
||||
* coda starebbero in chiaro.
|
||||
*/
|
||||
class ModuloS13Mail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public User $utente,
|
||||
public AnnoTeocratico $anno,
|
||||
private string $pdf,
|
||||
) {}
|
||||
|
||||
public function nomeFile(): string
|
||||
{
|
||||
return "S-13-registro-assegnazioni-{$this->anno->label}.pdf";
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: "Modulo S-13 — anno di servizio {$this->anno->label}");
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'mail.modulo-s13',
|
||||
with: ['data' => now()->format('d/m/Y')],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<int, Attachment> */
|
||||
public function attachments(): array
|
||||
{
|
||||
return [
|
||||
Attachment::fromData(fn () => $this->pdf, $this->nomeFile())->withMime('application/pdf'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,22 @@ class Assegnazione extends Model
|
||||
'campaign_id',
|
||||
'pdf_access_code',
|
||||
'link_sent',
|
||||
'link_da_inviare',
|
||||
'link_canale',
|
||||
'cartaceo',
|
||||
'note',
|
||||
'created_by',
|
||||
'returned_by',
|
||||
'gruppo_servizio_id',
|
||||
'gruppo_nome',
|
||||
];
|
||||
|
||||
/** Il gruppo di servizio da cui è nata (data dal sorvegliante a un membro). */
|
||||
public function gruppo()
|
||||
{
|
||||
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -32,6 +43,10 @@ class Assegnazione extends Model
|
||||
'returned_at' => 'date',
|
||||
'counted_in_campaign' => 'boolean',
|
||||
'link_sent' => 'boolean',
|
||||
'link_da_inviare' => 'boolean',
|
||||
// Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei).
|
||||
'cartaceo' => 'boolean',
|
||||
'gruppo_servizio_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -53,6 +68,20 @@ class Assegnazione extends Model
|
||||
$territorio->update(['prioritario' => false]);
|
||||
}
|
||||
});
|
||||
|
||||
// Al rientro un territorio del pacchetto di un gruppo torna in reparto
|
||||
// ed esce dal pacchetto. Qui e non nella pagina Rientra: vale per ogni
|
||||
// via (rientro del sorvegliante, dell'amministratore, Registro).
|
||||
static::updated(function (self $assegnazione): void {
|
||||
if (! $assegnazione->wasChanged('returned_at') || $assegnazione->returned_at === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Territorio::withTrashed()
|
||||
->whereKey($assegnazione->territorio_id)
|
||||
->whereNotNull('gruppo_servizio_id')
|
||||
->update(['gruppo_servizio_id' => null]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,7 +162,8 @@ class Assegnazione extends Model
|
||||
public function getGiorniAttribute(): int
|
||||
{
|
||||
$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
|
||||
@@ -211,9 +241,56 @@ class Assegnazione extends Model
|
||||
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 ─────────────────────────────────────────────────
|
||||
@@ -235,16 +312,69 @@ class Assegnazione extends Model
|
||||
|
||||
// ─── 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.
|
||||
* Returns the matching campaign or null.
|
||||
*/
|
||||
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)
|
||||
->where('end_date', '>=', $this->assigned_at)
|
||||
->where('end_date', '>=', $assignedAt)
|
||||
->orderByDesc('start_date')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,16 @@ class Campagna extends Model
|
||||
->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.
|
||||
*/
|
||||
|
||||
85
app/Models/GruppoServizio.php
Normal file
85
app/Models/GruppoServizio.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
/**
|
||||
* Gruppo di servizio: un sorvegliante (proclamatore con un'utenza limitata
|
||||
* al gruppo), i membri (proclamatori, un solo gruppo a testa) e un pacchetto
|
||||
* di territori che il sorvegliante distribuisce ai membri.
|
||||
*
|
||||
* Il pacchetto non è un'assegnazione: un territorio del pacchetto non ancora
|
||||
* dato a nessuno è "in carico al gruppo" e non entra nelle statistiche. Quando
|
||||
* il sorvegliante lo dà a un membro nasce un'assegnazione normale, con
|
||||
* gruppo_servizio_id per dire da dove viene. Al rientro il territorio torna in
|
||||
* reparto ed esce dal pacchetto.
|
||||
*
|
||||
* Sciolto, il gruppo si elimina: non resta nello storico (decisione del 20
|
||||
* ottobre 2026). Le assegnazioni già date dal sorvegliante ne conservano solo
|
||||
* il nome (assegnazioni.gruppo_nome). Logica in App\Services\GruppiServizio.
|
||||
*/
|
||||
class GruppoServizio extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
protected $table = 'gruppi_servizio';
|
||||
|
||||
protected $fillable = ['nome', 'sorvegliante_id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['sorvegliante_id' => 'integer'];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['nome', 'sorvegliante_id'])
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function sorvegliante()
|
||||
{
|
||||
return $this->belongsTo(Proclamatore::class, 'sorvegliante_id')->withTrashed();
|
||||
}
|
||||
|
||||
public function membri()
|
||||
{
|
||||
return $this->hasMany(Proclamatore::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
/** Il pacchetto: i territori in carico al gruppo, dati o non ancora dati. */
|
||||
public function territori()
|
||||
{
|
||||
return $this->hasMany(Territorio::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
public function assegnazioni()
|
||||
{
|
||||
return $this->hasMany(Assegnazione::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* I numeri del gruppo per la scheda del proclamatore e il profilo del
|
||||
* sorvegliante: solo conteggi in SQL, nessun nome da decifrare.
|
||||
*
|
||||
* @return array{membri: int, pacchetto: int, da_dare: int, dati: int, assegnazioni: int}
|
||||
*/
|
||||
public function riepilogo(): array
|
||||
{
|
||||
$pacchetto = $this->territori()->count();
|
||||
$dati = $this->territori()->whereHas('assegnazioni', fn ($q) => $q->whereNull('returned_at'))->count();
|
||||
|
||||
return [
|
||||
'membri' => $this->membri()->count(),
|
||||
'pacchetto' => $pacchetto,
|
||||
'da_dare' => $pacchetto - $dati,
|
||||
'dati' => $dati,
|
||||
'assegnazioni' => $this->assegnazioni()->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,24 +13,77 @@ class Proclamatore extends Model
|
||||
|
||||
protected $table = 'proclamatori';
|
||||
|
||||
protected $fillable = ['nome', 'cognome', 'email', 'telefono', 'attivo'];
|
||||
// Niente email né telefono (tolti il 19 ottobre 2026): il link della
|
||||
// piantina lo manda chi assegna, copiandolo o con "Condividi", e il
|
||||
// destinatario lo sceglie dai propri contatti.
|
||||
protected $fillable = ['nome', 'cognome', 'attivo', 'gruppo_servizio_id'];
|
||||
|
||||
/** Il gruppo di servizio di cui è membro (al massimo uno). */
|
||||
public function gruppo()
|
||||
{
|
||||
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
/** L'utenza, se è sorvegliante di un gruppo. */
|
||||
public function utente()
|
||||
{
|
||||
return $this->hasOne(User::class, 'proclamatore_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Un sorvegliante che non è più attivo — reso non attivo, spostato nel
|
||||
* cestino o anonimizzato — non guida più il gruppo: il gruppo si scioglie
|
||||
* (GruppiServizio::sciogli), da qualunque pagina arrivi la modifica. Un
|
||||
* membro nel cestino lascia il gruppo.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updated(function (self $proclamatore) {
|
||||
if ($proclamatore->wasChanged('attivo') && ! $proclamatore->attivo) {
|
||||
$proclamatore->lasciaIlGruppo('sorvegliante non più attivo');
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function (self $proclamatore) {
|
||||
$proclamatore->lasciaIlGruppo('sorvegliante eliminato');
|
||||
});
|
||||
}
|
||||
|
||||
/** Il gruppo attivo di cui è sorvegliante, se c'è. */
|
||||
public function gruppoSorvegliato(): ?GruppoServizio
|
||||
{
|
||||
return GruppoServizio::where('sorvegliante_id', $this->id)->first();
|
||||
}
|
||||
|
||||
private function lasciaIlGruppo(string $motivo): void
|
||||
{
|
||||
$gruppo = $this->gruppoSorvegliato();
|
||||
if ($gruppo) {
|
||||
app(\App\Services\GruppiServizio::class)->sciogli($gruppo, $motivo);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->trashed() && $this->gruppo_servizio_id) {
|
||||
static::withTrashed()->whereKey($this->id)->update(['gruppo_servizio_id' => null]);
|
||||
$this->gruppo_servizio_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'nome' => 'encrypted',
|
||||
'cognome' => 'encrypted',
|
||||
// Facoltativi. Cifrati come nome e cognome: dati personali.
|
||||
'email' => 'encrypted',
|
||||
'telefono' => 'encrypted',
|
||||
'attivo' => 'boolean',
|
||||
'gruppo_servizio_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
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()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
@@ -44,43 +97,57 @@ class Proclamatore extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Il numero come lo vuole WhatsApp: solo cifre, con il prefisso
|
||||
* internazionale. Un numero senza prefisso si considera italiano: un
|
||||
* cellulare scritto "333 1234567" diventa 393331234567. Null se non c'è un
|
||||
* numero utilizzabile.
|
||||
* L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per
|
||||
* cognome e nome. Nome e cognome sono cifrati nel database: la ricerca
|
||||
* avviene sui valori già decifrati, nella pagina, mai in SQL.
|
||||
*/
|
||||
public function telefonoInternazionale(): ?string
|
||||
public function opzioneScelta(?string $motivo = null): array
|
||||
{
|
||||
$grezzo = trim((string) $this->telefono);
|
||||
if ($grezzo === '') {
|
||||
return null;
|
||||
}
|
||||
// Il gruppo di servizio come nota, solo se già caricato (opzioniScelta).
|
||||
$gruppo = $this->relationLoaded('gruppo') ? $this->gruppo_etichetta : null;
|
||||
|
||||
$cifre = preg_replace('/\D+/', '', $grezzo);
|
||||
|
||||
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;
|
||||
return array_filter([
|
||||
'valore' => (string) $this->id,
|
||||
'etichetta' => $this->nome_completo,
|
||||
'dettaglio' => $gruppo !== null ? lcfirst($gruppo) : null,
|
||||
'chiavi' => [(string) $this->cognome, (string) $this->nome],
|
||||
'motivo' => $motivo,
|
||||
], fn ($v) => $v !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Il link che apre una chat WhatsApp con il proclamatore, con un messaggio
|
||||
* già scritto se c'è. Null se non ha un numero utilizzabile.
|
||||
* Le opzioni di un elenco di proclamatori, in ordine di cognome e nome.
|
||||
* L'ordine si fa qui: in SQL ordinerebbe i testi cifrati. Con $conGruppo
|
||||
* chi fa parte di un gruppo di servizio lo dice accanto al nome (non serve
|
||||
* dove sono tutti dello stesso gruppo, come in "Il mio gruppo").
|
||||
*/
|
||||
public function whatsappUrl(?string $messaggio = null): ?string
|
||||
public static function opzioniScelta(iterable $proclamatori, bool $conGruppo = true): array
|
||||
{
|
||||
$numero = $this->telefonoInternazionale();
|
||||
if ($numero === null) {
|
||||
if ($conGruppo && $proclamatori instanceof \Illuminate\Database\Eloquent\Collection) {
|
||||
$proclamatori->loadMissing('gruppo');
|
||||
}
|
||||
|
||||
return collect($proclamatori)
|
||||
->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome))
|
||||
->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo'))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* "Sorvegliante del gruppo Zisa" o "Gruppo Zisa"; null fuori dai gruppi.
|
||||
* Senza gruppo_servizio_id non fa query.
|
||||
*/
|
||||
public function getGruppoEtichettaAttribute(): ?string
|
||||
{
|
||||
$gruppo = $this->gruppo;
|
||||
if (! $gruppo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'https://wa.me/' . $numero . ($messaggio !== null && $messaggio !== '' ? '?text=' . rawurlencode($messaggio) : '');
|
||||
return $gruppo->sorvegliante_id === $this->id
|
||||
? "Sorvegliante del gruppo {$gruppo->nome}"
|
||||
: "Gruppo {$gruppo->nome}";
|
||||
}
|
||||
|
||||
public function assegnazioni()
|
||||
@@ -106,9 +173,10 @@ class Proclamatore extends Model
|
||||
{
|
||||
$this->nome = 'Anonimo';
|
||||
$this->cognome = 'Proclamatore #' . $this->id;
|
||||
$this->email = null;
|
||||
$this->telefono = null;
|
||||
$this->attivo = false;
|
||||
$this->gruppo_servizio_id = null;
|
||||
// Se era sorvegliante, il salvataggio (non più attivo) scioglie il
|
||||
// gruppo ed elimina la sua utenza: booted().
|
||||
$this->save();
|
||||
$this->delete(); // soft delete
|
||||
}
|
||||
|
||||
112
app/Models/Richiesta.php
Normal file
112
app/Models/Richiesta.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
/**
|
||||
* Una richiesta dal modulo pubblico (/richiesta): un proclamatore chiede un
|
||||
* territorio nuovo o ne annuncia la riconsegna. Non tocca niente da sola:
|
||||
* compare nella Home a chi ha `richieste.gestisci` (amministratore e
|
||||
* assistenti), che la porta sul modulo di assegnazione o di rientro già
|
||||
* esistenti; fatta l'operazione la richiesta risulta evasa. Si può anche
|
||||
* scartare. Logica in App\Services\RichiesteServizio.
|
||||
*
|
||||
* Nome e note li scrive chi compila, senza account: cifrati come i dati dei
|
||||
* proclamatori, e fuori dal registro delle attività (che logga solo lo stato).
|
||||
*/
|
||||
class Richiesta extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public const NUOVO = 'nuovo';
|
||||
public const RICONSEGNA = 'riconsegna';
|
||||
|
||||
public const APERTA = 'aperta';
|
||||
public const EVASA = 'evasa';
|
||||
public const SCARTATA = 'scartata';
|
||||
|
||||
protected $table = 'richieste';
|
||||
|
||||
// Appena creata è aperta anche nell'oggetto, non solo nel database.
|
||||
protected $attributes = ['stato' => self::APERTA];
|
||||
|
||||
// L'arrivo lo registra RichiesteServizio (con il numero del territorio):
|
||||
// qui solo i cambi di stato, con chi li ha fatti.
|
||||
protected static $recordEvents = ['updated'];
|
||||
|
||||
protected $fillable = ['tipo', 'nome', 'numero_territorio', 'note', 'stato', 'assegnazione_id', 'gestita_da', 'gestita_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'nome' => 'encrypted',
|
||||
'note' => 'encrypted',
|
||||
'gestita_at' => 'datetime',
|
||||
'assegnazione_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['tipo', 'stato', 'assegnazione_id'])
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function assegnazione()
|
||||
{
|
||||
return $this->belongsTo(Assegnazione::class);
|
||||
}
|
||||
|
||||
public function gestitaDa()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'gestita_da');
|
||||
}
|
||||
|
||||
public function scopeAperte($query)
|
||||
{
|
||||
return $query->where('stato', self::APERTA);
|
||||
}
|
||||
|
||||
public function isAperta(): bool
|
||||
{
|
||||
return $this->stato === self::APERTA;
|
||||
}
|
||||
|
||||
public function getTipoEtichettaAttribute(): string
|
||||
{
|
||||
return $this->tipo === self::RICONSEGNA ? 'Riconsegna' : 'Nuovo territorio';
|
||||
}
|
||||
|
||||
/** Chiusa da un'assegnazione o da un rientro fatti a partire da lei. */
|
||||
public function evadi(?Assegnazione $assegnazione, ?User $chi): void
|
||||
{
|
||||
if (! $this->isAperta()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'stato' => self::EVASA,
|
||||
'assegnazione_id' => $assegnazione?->id,
|
||||
'gestita_da' => $chi?->id,
|
||||
'gestita_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function scarta(?User $chi): void
|
||||
{
|
||||
if (! $this->isAperta()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'stato' => self::SCARTATA,
|
||||
'gestita_da' => $chi?->id,
|
||||
'gestita_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,20 @@ class Territorio extends Model
|
||||
'thumbnail_path',
|
||||
'attivo',
|
||||
'prioritario',
|
||||
'gruppo_servizio_id',
|
||||
];
|
||||
|
||||
/** Il gruppo di servizio nel cui pacchetto si trova, se c'è. */
|
||||
public function gruppo()
|
||||
{
|
||||
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attivo' => 'boolean',
|
||||
'gruppo_servizio_id' => 'integer',
|
||||
'prioritario' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -95,9 +103,25 @@ class Territorio extends Model
|
||||
return 'assegnato';
|
||||
}
|
||||
|
||||
// Nel pacchetto di un gruppo e non ancora dato a nessuno: non è in
|
||||
// reparto (non si assegna dalla pagina normale), è del gruppo.
|
||||
if ($this->gruppo_servizio_id) {
|
||||
return 'in_gruppo';
|
||||
}
|
||||
|
||||
return 'in_reparto';
|
||||
}
|
||||
|
||||
/** Lo stato da mostrare: "In gruppo Zisa" invece di "In gruppo". */
|
||||
public function getStatoEtichettaAttribute(): string
|
||||
{
|
||||
$stato = $this->stato;
|
||||
|
||||
return $stato === 'in_gruppo'
|
||||
? 'In gruppo ' . ($this->gruppo?->nome ?? '')
|
||||
: str_replace('_', ' ', ucfirst($stato));
|
||||
}
|
||||
|
||||
public function getAssegnatarioAttribute(): ?Proclamatore
|
||||
{
|
||||
return $this->assegnazioneCorrente?->proclamatore;
|
||||
@@ -145,6 +169,59 @@ class Territorio extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* La logica di priorità dei territori da distribuire, la stessa ovunque
|
||||
* (Home "Da assegnare", pacchetto di un gruppo, "Il mio gruppo"): prima i
|
||||
* prioritari — spunta manuale o fermi da più giorni della soglia
|
||||
* "giorni_giacenza_prioritari" — poi chi è fermo da più tempo, poi il
|
||||
* numero. Su ogni territorio imposta giacenza_giorni (dall'ultimo rientro,
|
||||
* o dalla creazione se mai assegnato; 0 se è fuori) e in_priorita, da
|
||||
* mostrare. Vuole 'ultimaAssegnazione' già caricata, per non fare una
|
||||
* query a territorio.
|
||||
*/
|
||||
public static function ordinaPerPriorita(iterable $territori): \Illuminate\Support\Collection
|
||||
{
|
||||
$soglia = (int) (Setting::instance()->giorni_giacenza_prioritari ?? 180);
|
||||
|
||||
return collect($territori)
|
||||
->each(function (self $territorio) use ($soglia) {
|
||||
$ultima = $territorio->ultimaAssegnazione;
|
||||
if ($ultima && $ultima->returned_at) {
|
||||
$giorni = (int) $ultima->returned_at->startOfDay()->diffInDays(today());
|
||||
} elseif (! $ultima) {
|
||||
$giorni = (int) $territorio->created_at->startOfDay()->diffInDays(today());
|
||||
} else {
|
||||
$giorni = 0;
|
||||
}
|
||||
|
||||
$territorio->setAttribute('giacenza_giorni', $giorni);
|
||||
$territorio->setAttribute('in_priorita', (bool) $territorio->prioritario || $giorni > $soglia);
|
||||
})
|
||||
->sort(function (self $a, self $b) {
|
||||
return [(int) $b->in_priorita, $b->giacenza_giorni] <=> [(int) $a->in_priorita, $a->giacenza_giorni]
|
||||
?: strnatcasecmp((string) $a->numero, (string) $b->numero);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* L'opzione per il campo di scelta con ricerca (<x-scelta>): si cerca per
|
||||
* numero, zona e tipologia. Con $motivo compare cercando ma non si può
|
||||
* 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 ─────────────────────────────────────────────────
|
||||
|
||||
public function scopeAttivi($query)
|
||||
@@ -154,12 +231,23 @@ class Territorio extends Model
|
||||
|
||||
public function scopeInReparto($query)
|
||||
{
|
||||
// Quelli nel pacchetto di un gruppo non sono in reparto: stanno al
|
||||
// gruppo finché il sorvegliante non li dà o non li rimanda indietro.
|
||||
return $query->attivi()
|
||||
->whereNull('gruppo_servizio_id')
|
||||
->whereDoesntHave('assegnazioni', function ($q) {
|
||||
$q->whereNull('returned_at');
|
||||
});
|
||||
}
|
||||
|
||||
/** Nel pacchetto di un gruppo e non ancora dati a nessuno. */
|
||||
public function scopeInGruppo($query)
|
||||
{
|
||||
return $query->attivi()
|
||||
->whereNotNull('gruppo_servizio_id')
|
||||
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
|
||||
}
|
||||
|
||||
public function scopeAssegnato($query)
|
||||
{
|
||||
return $query->attivi()
|
||||
@@ -240,4 +328,12 @@ class Territorio extends Model
|
||||
? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
|
||||
: null;
|
||||
}
|
||||
|
||||
/** La miniatura per il sorvegliante di gruppo ("Il mio gruppo"): solo del suo pacchetto. */
|
||||
public function miniaturaGruppoUrl(): ?string
|
||||
{
|
||||
return $this->thumbnail_path
|
||||
? route('mio-gruppo.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
|
||||
@@ -24,6 +26,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'email',
|
||||
'password',
|
||||
'password_change_required',
|
||||
'password_temporanea',
|
||||
'proclamatore_id',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -37,9 +41,80 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'password_change_required' => 'boolean',
|
||||
// Il cambio obbligatorio viene da una password temporanea data
|
||||
// dall'amministratore, non da un ripristino (pagina del cambio).
|
||||
'password_temporanea' => 'boolean',
|
||||
'proclamatore_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/** Il proclamatore di cui è l'utenza (sorvegliante di gruppo). */
|
||||
public function proclamatore()
|
||||
{
|
||||
return $this->belongsTo(Proclamatore::class, 'proclamatore_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorvegliante di gruppo: vede solo il proprio gruppo e il proprio
|
||||
* profilo (middleware LimitaSorvegliante).
|
||||
*/
|
||||
public function isSorvegliante(): bool
|
||||
{
|
||||
return $this->hasRole('sorvegliante');
|
||||
}
|
||||
|
||||
/** Il gruppo di cui è sorvegliante (sciolto il gruppo, l'utenza non esiste più). */
|
||||
public function gruppoSorvegliato(): ?GruppoServizio
|
||||
{
|
||||
return $this->proclamatore_id
|
||||
? GruppoServizio::where('sorvegliante_id', $this->proclamatore_id)->first()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina l'utenza senza perdere lo storico: le assegnazioni che ha fatto
|
||||
* o fatto rientrare passano a $subentra (la colonna non può restare
|
||||
* vuota), e nel registro delle attività le sue voci si tengono, con
|
||||
* scritto chi era ($nome, $email) al posto del collegamento all'utenza.
|
||||
*/
|
||||
public function eliminaConservandoStorico(self $subentra, string $nome, ?string $email): void
|
||||
{
|
||||
DB::transaction(function () use ($subentra, $nome, $email) {
|
||||
$eliminatoIl = now()->toDateTimeString();
|
||||
|
||||
Activity::query()
|
||||
->where('causer_type', self::class)
|
||||
->where('causer_id', $this->id)
|
||||
->chunkById(200, function ($attivita) use ($nome, $email, $eliminatoIl) {
|
||||
foreach ($attivita as $voce) {
|
||||
$proprieta = $voce->properties?->toArray() ?? [];
|
||||
$proprieta['causer_name'] = $nome;
|
||||
$proprieta['causer_email'] = $email;
|
||||
$proprieta['causer_deleted_at'] = $eliminatoIl;
|
||||
|
||||
$voce->properties = $proprieta;
|
||||
$voce->save();
|
||||
}
|
||||
});
|
||||
|
||||
Assegnazione::query()->where('created_by', $this->id)->update(['created_by' => $subentra->id]);
|
||||
Assegnazione::query()->where('returned_by', $this->id)->update(['returned_by' => $subentra->id]);
|
||||
|
||||
$this->syncRoles([]);
|
||||
$this->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Il sorvegliante non conferma l'email (scelta dell'utente, 19 ottobre
|
||||
* 2026): l'utenza la crea l'amministratore con una password temporanea.
|
||||
* Per tutti gli altri resta obbligatoria.
|
||||
*/
|
||||
public function hasVerifiedEmail(): bool
|
||||
{
|
||||
return ! is_null($this->email_verified_at) || $this->isSorvegliante();
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
@@ -48,6 +123,32 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* L'utenza di un sorvegliante porta nome (ed email, spesso) di un
|
||||
* proclamatore: nel registro delle attività i proclamatori compaiono solo
|
||||
* con l'identificativo, quindi anche qui.
|
||||
*/
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
|
||||
{
|
||||
$proclamatoreId = $this->proclamatore_id ?? $this->getOriginal('proclamatore_id');
|
||||
if (! $proclamatoreId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity->properties = collect($activity->properties)->map(function ($valori) use ($proclamatoreId) {
|
||||
if (! is_array($valori)) {
|
||||
return $valori;
|
||||
}
|
||||
foreach (['name', 'email'] as $campo) {
|
||||
if (array_key_exists($campo, $valori)) {
|
||||
$valori[$campo] = "proclamatore #{$proclamatoreId}";
|
||||
}
|
||||
}
|
||||
|
||||
return $valori;
|
||||
});
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
|
||||
@@ -65,6 +166,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
*/
|
||||
public function inviaConfermaEmail(): bool
|
||||
{
|
||||
if ($this->isSorvegliante()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->sendEmailVerificationNotification();
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
42
app/Notifications/RichiestaRicevuta.php
Normal file
42
app/Notifications/RichiestaRicevuta.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Richiesta;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Avviso ad amministratori e assistenti: è arrivata una richiesta dal modulo
|
||||
* pubblico. Volutamente non ShouldQueue: il nome scritto nella richiesta
|
||||
* finirebbe in chiaro nella coda (Redis, failed_jobs).
|
||||
*/
|
||||
class RichiestaRicevuta extends Notification
|
||||
{
|
||||
public function __construct(private Richiesta $richiesta) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$r = $this->richiesta;
|
||||
$messaggio = (new MailMessage)
|
||||
->subject("Nuova richiesta: {$r->tipo_etichetta} — TerManager2")
|
||||
->greeting('Ciao ' . $notifiable->name . ',')
|
||||
->line($r->tipo === Richiesta::RICONSEGNA
|
||||
? "{$r->nome} annuncia la riconsegna del territorio N° {$r->numero_territorio}."
|
||||
: "{$r->nome} chiede un territorio nuovo.");
|
||||
|
||||
if ($r->note) {
|
||||
$messaggio->line('Note: ' . $r->note);
|
||||
}
|
||||
|
||||
return $messaggio
|
||||
->action('Apri la Home di TerManager2', route('dashboard'))
|
||||
->line('La richiesta si conferma dalla Home, nel riquadro "Richieste".')
|
||||
->salutation('TerManager2');
|
||||
}
|
||||
}
|
||||
347
app/Services/GruppiServizio.php
Normal file
347
app/Services/GruppiServizio.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\GruppoServizio;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Le regole dei gruppi di servizio, in un posto solo: le usano le pagine
|
||||
* dell'amministratore (Gruppi di servizio) e quella del sorvegliante (Il mio
|
||||
* gruppo). Ogni metodo lancia InvalidArgumentException con un messaggio da
|
||||
* mostrare così com'è quando una regola non è rispettata.
|
||||
*/
|
||||
class GruppiServizio
|
||||
{
|
||||
/** Il dominio delle email fittizie dei sorveglianti (iniziale+cognome@…). */
|
||||
public const DOMINIO_EMAIL = 'termanager.it';
|
||||
|
||||
/**
|
||||
* Crea il gruppo e l'utenza del sorvegliante: email fittizia ricavata dal
|
||||
* suo nome (emailFittizia), ruolo "sorvegliante", password temporanea da
|
||||
* cambiare al primo accesso, nessuna conferma dell'email. Restituisce il
|
||||
* gruppo e la password temporanea, da mostrare una volta sola
|
||||
* all'amministratore insieme all'email.
|
||||
*
|
||||
* @param int[] $membri id dei proclamatori, oltre al sorvegliante
|
||||
* @return array{0: GruppoServizio, 1: string}
|
||||
*/
|
||||
public function crea(string $nome, Proclamatore $sorvegliante, array $membri = []): array
|
||||
{
|
||||
$nome = trim($nome);
|
||||
if ($nome === '') {
|
||||
throw new InvalidArgumentException('Il nome del gruppo è obbligatorio.');
|
||||
}
|
||||
if (GruppoServizio::where('nome', $nome)->exists()) {
|
||||
throw new InvalidArgumentException("Esiste già un gruppo «{$nome}».");
|
||||
}
|
||||
if (! $sorvegliante->attivo || $sorvegliante->trashed()) {
|
||||
throw new InvalidArgumentException('Il sorvegliante deve essere un proclamatore attivo.');
|
||||
}
|
||||
if ($sorvegliante->gruppo_servizio_id) {
|
||||
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
|
||||
}
|
||||
if ($sorvegliante->utente()->exists()) {
|
||||
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
|
||||
}
|
||||
|
||||
$password = Str::password(12, symbols: false);
|
||||
|
||||
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $membri, $password) {
|
||||
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
|
||||
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||
$this->aggiungiMembri($gruppo, $membri);
|
||||
|
||||
$utente = User::create([
|
||||
'name' => $sorvegliante->nome_completo,
|
||||
'email' => self::emailFittizia($sorvegliante),
|
||||
'password' => $password,
|
||||
'password_change_required' => true,
|
||||
'password_temporanea' => true,
|
||||
'proclamatore_id' => $sorvegliante->id,
|
||||
]);
|
||||
$utente->assignRole('sorvegliante');
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
|
||||
->log('gruppo_creato');
|
||||
|
||||
return $gruppo;
|
||||
});
|
||||
|
||||
return [$gruppo, $password];
|
||||
}
|
||||
|
||||
/**
|
||||
* Una password temporanea nuova per il sorvegliante che l'ha persa: da
|
||||
* cambiare al primo accesso ("Password temporanea"). Le sessioni aperte
|
||||
* con quella vecchia si chiudono alla richiesta successiva
|
||||
* (AuthenticateSession: è cambiato l'hash), il "ricordami" si azzera.
|
||||
* Restituisce la password, da mostrare una volta sola.
|
||||
*/
|
||||
public function nuovaPasswordTemporanea(User $utente): string
|
||||
{
|
||||
if (! $utente->proclamatore_id || ! $utente->isSorvegliante()) {
|
||||
throw new InvalidArgumentException("{$utente->name} non è il sorvegliante di un gruppo.");
|
||||
}
|
||||
|
||||
$password = Str::password(12, symbols: false);
|
||||
$utente->forceFill([
|
||||
'password' => $password,
|
||||
'password_change_required' => true,
|
||||
'password_temporanea' => true,
|
||||
'remember_token' => null,
|
||||
])->save();
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* L'email fittizia del sorvegliante, con cui entra: iniziale del nome e
|
||||
* cognome, minuscoli, senza accenti, spazi né apostrofi, sul dominio
|
||||
* dell'app (Francesco Fontana → ffontana@termanager.it, Francesco
|
||||
* D'Angelo → fdangelo@…). Se è già presa, un numero in coda
|
||||
* (ffontana2@…). Non riceve posta: il sorvegliante non conferma l'email.
|
||||
*/
|
||||
public static function emailFittizia(Proclamatore $proclamatore): string
|
||||
{
|
||||
$pulisci = fn (string $testo) => preg_replace('/[^a-z0-9]/', '', Str::lower(Str::ascii($testo)));
|
||||
|
||||
$base = Str::limit(mb_substr($pulisci((string) $proclamatore->nome), 0, 1) . $pulisci((string) $proclamatore->cognome), 60, '');
|
||||
if ($base === '') {
|
||||
$base = "sorvegliante{$proclamatore->id}";
|
||||
}
|
||||
|
||||
$email = "{$base}@" . self::DOMINIO_EMAIL;
|
||||
for ($n = 2; User::where('email', $email)->exists(); $n++) {
|
||||
$email = "{$base}{$n}@" . self::DOMINIO_EMAIL;
|
||||
}
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
/** @param int[] $ids */
|
||||
public function aggiungiMembri(GruppoServizio $gruppo, array $ids): void
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
foreach (Proclamatore::whereIn('id', $ids)->get() as $p) {
|
||||
if ($p->gruppo_servizio_id && $p->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("{$p->nome_completo} fa già parte di un altro gruppo.");
|
||||
}
|
||||
if (! $p->attivo) {
|
||||
throw new InvalidArgumentException("{$p->nome_completo} non è attivo.");
|
||||
}
|
||||
$p->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||
}
|
||||
}
|
||||
|
||||
public function togliMembro(GruppoServizio $gruppo, Proclamatore $membro): void
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
if ($membro->id === $gruppo->sorvegliante_id) {
|
||||
throw new InvalidArgumentException('Il sorvegliante non si toglie: per cambiarlo si scioglie il gruppo.');
|
||||
}
|
||||
if ($membro->gruppo_servizio_id === $gruppo->id) {
|
||||
$membro->update(['gruppo_servizio_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mette nel pacchetto del gruppo dei territori in reparto (attivi, non
|
||||
* assegnati, non già in un altro pacchetto). Restituisce quanti.
|
||||
*
|
||||
* @param int[] $ids
|
||||
*/
|
||||
public function aggiungiAlPacchetto(GruppoServizio $gruppo, array $ids): int
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
$territori = Territorio::whereIn('id', $ids)->get();
|
||||
foreach ($territori as $t) {
|
||||
if (! $t->attivo) {
|
||||
throw new InvalidArgumentException("Il territorio {$t->numero} non è attivo.");
|
||||
}
|
||||
if ($t->gruppo_servizio_id && $t->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("Il territorio {$t->numero} è già nel pacchetto di un altro gruppo.");
|
||||
}
|
||||
if ($t->assegnazioneCorrente) {
|
||||
throw new InvalidArgumentException("Il territorio {$t->numero} è assegnato: va prima fatto rientrare.");
|
||||
}
|
||||
}
|
||||
|
||||
Territorio::whereKey($territori->pluck('id'))->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['territori' => $territori->pluck('numero')->all()])
|
||||
->log('pacchetto_aggiunti');
|
||||
|
||||
return $territori->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rimanda in reparto un territorio del pacchetto non ancora dato a
|
||||
* nessuno (lo fa l'amministratore o il sorvegliante).
|
||||
*/
|
||||
public function rimandaInReparto(GruppoServizio $gruppo, Territorio $territorio): void
|
||||
{
|
||||
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
|
||||
}
|
||||
if ($territorio->assegnazioneCorrente) {
|
||||
throw new InvalidArgumentException("Il territorio {$territorio->numero} è assegnato: prima va registrato il rientro.");
|
||||
}
|
||||
|
||||
$territorio->update(['gruppo_servizio_id' => null]);
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(['territorio' => $territorio->numero])
|
||||
->log('pacchetto_rimandato_in_reparto');
|
||||
}
|
||||
|
||||
/**
|
||||
* Il sorvegliante dà un territorio del pacchetto a un membro (o a sé):
|
||||
* nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il
|
||||
* territorio resta nel pacchetto finché non rientra.
|
||||
*/
|
||||
public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data, bool $cartaceo = false): Assegnazione
|
||||
{
|
||||
$this->attivo($gruppo);
|
||||
|
||||
// Si decide sui dati di adesso, non su quelli con cui è partita la
|
||||
// pagina: nel frattempo l'amministratore può aver cambiato pacchetto
|
||||
// o membri.
|
||||
$territorio->refresh();
|
||||
$membro->refresh();
|
||||
|
||||
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
|
||||
}
|
||||
if ($membro->gruppo_servizio_id !== $gruppo->id) {
|
||||
throw new InvalidArgumentException("{$membro->nome_completo} non fa parte del gruppo.");
|
||||
}
|
||||
if (! $membro->attivo) {
|
||||
throw new InvalidArgumentException("{$membro->nome_completo} non è attivo.");
|
||||
}
|
||||
if ($data->isFuture()) {
|
||||
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
|
||||
}
|
||||
|
||||
$esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data, $cartaceo) {
|
||||
$bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first();
|
||||
if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) {
|
||||
return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo.";
|
||||
}
|
||||
|
||||
$conflitto = Assegnazione::sovrappostaA($territorio->id, $data);
|
||||
if ($conflitto) {
|
||||
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}.";
|
||||
}
|
||||
|
||||
return Assegnazione::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'proclamatore_id' => $membro->id,
|
||||
'anno_teocratico_id' => AnnoTeocratico::perData($data)->id,
|
||||
'assigned_at' => $data,
|
||||
'created_by' => auth()->id(),
|
||||
'gruppo_servizio_id' => $gruppo->id,
|
||||
// Il nome resta anche quando il gruppo, sciolto, non c'è più.
|
||||
'gruppo_nome' => $gruppo->nome,
|
||||
'link_da_inviare' => (bool) $territorio->pdf_path,
|
||||
'cartaceo' => $cartaceo,
|
||||
]);
|
||||
});
|
||||
|
||||
if (is_string($esito)) {
|
||||
throw new InvalidArgumentException($esito);
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($esito)
|
||||
->withProperties([
|
||||
'territorio' => $territorio->numero,
|
||||
'proclamatore_id' => $membro->id,
|
||||
'gruppo_servizio_id' => $gruppo->id,
|
||||
])
|
||||
->log('assigned');
|
||||
|
||||
return $esito;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scioglie il gruppo (si toglie il ruolo di sorvegliante, o il
|
||||
* sorvegliante non è più attivo) e lo elimina: non resta nello storico.
|
||||
* I territori del pacchetto non ancora dati tornano in reparto; quelli
|
||||
* già dati restano ai membri come assegnazioni qualsiasi, che conservano
|
||||
* solo il nome del gruppo (gruppo_nome). I membri restano senza gruppo.
|
||||
* L'utenza del sorvegliante si elimina: torna un proclamatore come gli
|
||||
* altri. Le assegnazioni che aveva fatto o fatto rientrare passano a chi
|
||||
* scioglie il gruppo; nel registro le sue voci restano, a nome
|
||||
* "Sorvegliante del gruppo …", senza dati personali.
|
||||
*/
|
||||
public function sciogli(GruppoServizio $gruppo, ?string $motivo = null): void
|
||||
{
|
||||
if (! GruppoServizio::whereKey($gruppo->id)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($gruppo, $motivo) {
|
||||
$liberati = Territorio::withTrashed()
|
||||
->where('gruppo_servizio_id', $gruppo->id)
|
||||
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'))
|
||||
->pluck('numero');
|
||||
Territorio::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
Proclamatore::withTrashed()->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
Assegnazione::where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
|
||||
$utente = User::where('proclamatore_id', $gruppo->sorvegliante_id)->first();
|
||||
|
||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||
->withProperties(array_filter([
|
||||
'gruppo' => $gruppo->nome,
|
||||
'territori_liberati' => $liberati->all(),
|
||||
'utente_eliminato' => $utente?->id,
|
||||
'motivo' => $motivo,
|
||||
], fn ($v) => $v !== null))
|
||||
->log('gruppo_sciolto');
|
||||
|
||||
if ($utente) {
|
||||
$utente->eliminaConservandoStorico($this->subentrante($utente), "Sorvegliante del gruppo {$gruppo->nome}", null);
|
||||
}
|
||||
|
||||
$gruppo->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi prende le assegnazioni fatte dal sorvegliante quando la sua utenza
|
||||
* si elimina: chi sta sciogliendo il gruppo, altrimenti il primo
|
||||
* amministratore.
|
||||
*/
|
||||
private function subentrante(User $sorvegliante): User
|
||||
{
|
||||
$chi = auth()->user();
|
||||
if ($chi instanceof User && $chi->id !== $sorvegliante->id) {
|
||||
return $chi;
|
||||
}
|
||||
|
||||
return User::role('amministratore')->orderBy('id')->firstOrFail();
|
||||
}
|
||||
|
||||
private function attivo(GruppoServizio $gruppo): void
|
||||
{
|
||||
if (! $gruppo->exists || ! GruppoServizio::whereKey($gruppo->id)->exists()) {
|
||||
throw new InvalidArgumentException('Il gruppo non esiste più: è stato sciolto.');
|
||||
}
|
||||
}
|
||||
}
|
||||
155
app/Services/RichiesteServizio.php
Normal file
155
app/Services/RichiesteServizio.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Richiesta;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Notifications\RichiestaRicevuta;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Le richieste dal modulo pubblico: registrarle, avvisare chi le gestisce e
|
||||
* aiutarlo a ricondurle a un proclamatore o a un'assegnazione. La conferma
|
||||
* passa dai moduli già esistenti (Assegna, Rientra), che chiudono la
|
||||
* richiesta da cui sono partiti.
|
||||
*/
|
||||
class RichiesteServizio
|
||||
{
|
||||
public function __construct(private ConfigurazionePosta $posta) {}
|
||||
|
||||
/** Registra la richiesta e avvisa per email amministratori e assistenti. */
|
||||
public function registra(string $tipo, string $nome, ?string $numero, ?string $note): Richiesta
|
||||
{
|
||||
$richiesta = Richiesta::create([
|
||||
'tipo' => $tipo === Richiesta::RICONSEGNA ? Richiesta::RICONSEGNA : Richiesta::NUOVO,
|
||||
'nome' => trim($nome),
|
||||
'numero_territorio' => $tipo === Richiesta::RICONSEGNA ? trim((string) $numero) : null,
|
||||
'note' => trim((string) $note) !== '' ? trim((string) $note) : null,
|
||||
]);
|
||||
|
||||
// Nel registro tipo e numero, mai il nome.
|
||||
activity()->performedOn($richiesta)
|
||||
->withProperties(array_filter(['tipo' => $richiesta->tipo, 'territorio' => $richiesta->numero_territorio]))
|
||||
->log('richiesta_ricevuta');
|
||||
|
||||
$this->avvisa($richiesta);
|
||||
|
||||
return $richiesta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi gestisce le richieste e ha un'email vera: amministratori e
|
||||
* assistenti (permesso richieste.gestisci) con l'indirizzo confermato.
|
||||
*/
|
||||
public function destinatari(): Collection
|
||||
{
|
||||
return User::permission('richieste.gestisci')->get()
|
||||
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* L'email parte subito e non in coda: contiene il nome scritto nella
|
||||
* richiesta, che nel database è cifrato e in coda starebbe in chiaro.
|
||||
* Senza un server di posta funzionante non si manda niente: la richiesta
|
||||
* resta comunque nella Home.
|
||||
*/
|
||||
private function avvisa(Richiesta $richiesta): void
|
||||
{
|
||||
if (! $this->posta->funzionante()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$destinatari = $this->destinatari();
|
||||
if ($destinatari->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Notification::send($destinatari, new RichiestaRicevuta($richiesta));
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* I proclamatori attivi con un nome riconducibile a quello scritto nella
|
||||
* richiesta, dal più simile: uguale, iniziale, una lettera sbagliata.
|
||||
* I nomi sono cifrati, quindi il confronto si fa qui, non in SQL.
|
||||
*
|
||||
* @return Collection<int, Proclamatore>
|
||||
*/
|
||||
public function nomiSimili(string $nome, ?Collection $proclamatori = null, int $quanti = 5): Collection
|
||||
{
|
||||
$cercate = self::parole($nome);
|
||||
if ($cercate === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return ($proclamatori ?? Proclamatore::attivi()->get())
|
||||
->map(function (Proclamatore $p) use ($cercate) {
|
||||
$sue = self::parole($p->cognome . ' ' . $p->nome);
|
||||
$punti = 0;
|
||||
foreach ($cercate as $parola) {
|
||||
$migliore = 0;
|
||||
foreach ($sue as $sua) {
|
||||
$migliore = max($migliore, self::somiglianza($parola, $sua));
|
||||
}
|
||||
$punti += $migliore;
|
||||
}
|
||||
|
||||
return [$p, $punti];
|
||||
})
|
||||
->filter(fn ($coppia) => $coppia[1] >= 2)
|
||||
// Prima il punteggio più alto, a pari punti in ordine alfabetico.
|
||||
->sort(fn ($a, $b) => [$b[1], mb_strtolower($a[0]->nome_completo)] <=> [$a[1], mb_strtolower($b[0]->nome_completo)])
|
||||
->take($quanti)
|
||||
->map(fn ($coppia) => $coppia[0])
|
||||
->values();
|
||||
}
|
||||
|
||||
/** L'assegnazione aperta del territorio indicato in una riconsegna, se c'è. */
|
||||
public function assegnazioneAperta(?string $numero): ?Assegnazione
|
||||
{
|
||||
$numero = trim((string) $numero);
|
||||
if ($numero === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$territorio = Territorio::query()
|
||||
->whereRaw('LOWER(numero) = ?', [Str::lower($numero)])
|
||||
->with('assegnazioneCorrente.proclamatore')
|
||||
->first();
|
||||
|
||||
return $territorio?->assegnazioneCorrente;
|
||||
}
|
||||
|
||||
/** @return string[] parole minuscole, senza accenti né segni */
|
||||
private static function parole(string $testo): array
|
||||
{
|
||||
$pulito = preg_replace('/[^a-z0-9 ]+/', ' ', Str::lower(Str::ascii($testo)));
|
||||
|
||||
return array_values(array_filter(explode(' ', (string) $pulito), fn ($p) => strlen($p) >= 2));
|
||||
}
|
||||
|
||||
/** 3 uguale, 2 una inizia con l'altra, 1 una lettera di differenza. */
|
||||
private static function somiglianza(string $a, string $b): int
|
||||
{
|
||||
if ($a === $b) {
|
||||
return 3;
|
||||
}
|
||||
if (min(strlen($a), strlen($b)) >= 3 && (str_starts_with($a, $b) || str_starts_with($b, $a))) {
|
||||
return 2;
|
||||
}
|
||||
if (min(strlen($a), strlen($b)) >= 4 && levenshtein($a, $b) <= 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->web(append: [
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
\App\Http\Middleware\RequirePasswordChange::class,
|
||||
// Utenze disattivate fuori; il sorvegliante di gruppo solo nel suo gruppo.
|
||||
\App\Http\Middleware\LimitaSorvegliante::class,
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
78
database/migrations/2026_10_20_000010_gruppi_di_servizio.php
Normal file
78
database/migrations/2026_10_20_000010_gruppi_di_servizio.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* Gruppi di servizio (richiesta del 19 ottobre 2026).
|
||||
*
|
||||
* - gruppi_servizio: il gruppo, con il suo sorvegliante (un proclamatore) e
|
||||
* sciolto_at quando viene sciolto (resta nello storico: le assegnazioni
|
||||
* nate dal gruppo continuano a dirlo).
|
||||
* - proclamatori.gruppo_servizio_id: i membri, un solo gruppo a testa.
|
||||
* - territori.gruppo_servizio_id: il pacchetto del gruppo. Un territorio nel
|
||||
* pacchetto non ancora dato a nessuno è "in carico al gruppo"; il pacchetto
|
||||
* non è un'assegnazione e non entra nelle statistiche.
|
||||
* - assegnazioni.gruppo_servizio_id: l'assegnazione è nata dal gruppo (data
|
||||
* dal sorvegliante a un membro). È un'assegnazione normale a tutti gli
|
||||
* effetti, segnata così.
|
||||
* - users.proclamatore_id: l'utenza del sorvegliante, collegata al suo
|
||||
* proclamatore. users.disattivato_at: utenza che non può più entrare (un
|
||||
* gruppo sciolto disattiva quella del suo sorvegliante).
|
||||
* - Ruolo "sorvegliante" con il solo permesso "gruppo.gestisci". Si crea qui
|
||||
* e non solo nel seeder, che sulle installazioni esistenti non gira più.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('gruppi_servizio', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('nome', 100);
|
||||
$table->foreignId('sorvegliante_id')->nullable()->constrained('proclamatori')->nullOnDelete();
|
||||
$table->timestamp('sciolto_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('proclamatori', function (Blueprint $table) {
|
||||
$table->foreignId('gruppo_servizio_id')->nullable()->after('attivo')->constrained('gruppi_servizio')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('territori', function (Blueprint $table) {
|
||||
$table->foreignId('gruppo_servizio_id')->nullable()->after('prioritario')->constrained('gruppi_servizio')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->foreignId('gruppo_servizio_id')->nullable()->after('campaign_id')->constrained('gruppi_servizio')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->foreignId('proclamatore_id')->nullable()->unique()->after('email')->constrained('proclamatori')->nullOnDelete();
|
||||
$table->timestamp('disattivato_at')->nullable()->after('password_change_required');
|
||||
});
|
||||
|
||||
$permesso = Permission::firstOrCreate(['name' => 'gruppo.gestisci', 'guard_name' => 'web']);
|
||||
Role::firstOrCreate(['name' => 'sorvegliante', 'guard_name' => 'web'])->givePermissionTo($permesso);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('proclamatore_id');
|
||||
$table->dropColumn('disattivato_at');
|
||||
});
|
||||
Schema::table('assegnazioni', fn (Blueprint $table) => $table->dropConstrainedForeignId('gruppo_servizio_id'));
|
||||
Schema::table('territori', fn (Blueprint $table) => $table->dropConstrainedForeignId('gruppo_servizio_id'));
|
||||
Schema::table('proclamatori', fn (Blueprint $table) => $table->dropConstrainedForeignId('gruppo_servizio_id'));
|
||||
Schema::dropIfExists('gruppi_servizio');
|
||||
|
||||
Role::where('name', 'sorvegliante')->delete();
|
||||
Permission::where('name', 'gruppo.gestisci')->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Gruppi di servizio, 20 ottobre 2026 (decisioni dell'utente):
|
||||
*
|
||||
* - un gruppo sciolto non resta nello storico: si elimina. Le assegnazioni
|
||||
* già date dal sorvegliante conservano il nome del gruppo
|
||||
* (assegnazioni.gruppo_nome), così "via gruppo …" resta leggibile;
|
||||
* - il sorvegliante entra con un nome utente ricavato dal nome del gruppo,
|
||||
* non con l'email: users.username, e users.email diventa facoltativa.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('username', 60)->nullable()->unique()->after('email');
|
||||
});
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('email')->nullable()->change();
|
||||
});
|
||||
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->string('gruppo_nome', 100)->nullable()->after('gruppo_servizio_id');
|
||||
});
|
||||
|
||||
foreach (DB::table('gruppi_servizio')->get() as $gruppo) {
|
||||
DB::table('assegnazioni')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_nome' => $gruppo->nome]);
|
||||
|
||||
if ($gruppo->sciolto_at !== null) {
|
||||
// Già sciolto: pacchetto e membri erano già stati liberati.
|
||||
DB::table('territori')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
DB::table('proclamatori')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
DB::table('assegnazioni')->where('gruppo_servizio_id', $gruppo->id)->update(['gruppo_servizio_id' => null]);
|
||||
DB::table('gruppi_servizio')->where('id', $gruppo->id)->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sorvegliante di un gruppo attivo: il nome utente dal nome del gruppo.
|
||||
$utente = DB::table('users')->where('proclamatore_id', $gruppo->sorvegliante_id)->first();
|
||||
if ($utente && $utente->username === null) {
|
||||
$base = Str::slug($gruppo->nome, '.') ?: 'gruppo';
|
||||
$base = Str::limit($base, 50, '');
|
||||
$username = $base;
|
||||
for ($n = 2; DB::table('users')->where('username', $username)->exists(); $n++) {
|
||||
$username = $base . $n;
|
||||
}
|
||||
DB::table('users')->where('id', $utente->id)->update(['username' => $username]);
|
||||
}
|
||||
}
|
||||
|
||||
Schema::table('gruppi_servizio', function (Blueprint $table) {
|
||||
$table->dropColumn('sciolto_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('gruppi_servizio', function (Blueprint $table) {
|
||||
$table->timestamp('sciolto_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->dropColumn('gruppo_nome');
|
||||
});
|
||||
|
||||
// Senza email l'utenza non si può tenere com'era: chi non ce l'ha
|
||||
// riceve un indirizzo segnaposto, da correggere a mano.
|
||||
DB::table('users')->whereNull('email')->orderBy('id')->each(function ($u) {
|
||||
DB::table('users')->where('id', $u->id)->update(['email' => ($u->username ?? "utente-{$u->id}") . '@senza-email.invalid']);
|
||||
});
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropUnique(['username']);
|
||||
$table->dropColumn('username');
|
||||
});
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('email')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Proclamatore;
|
||||
use App\Services\GruppiServizio;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Gruppi di servizio, 20 ottobre 2026 (decisioni dell'utente, che
|
||||
* sostituiscono il nome utente introdotto poche ore prima):
|
||||
*
|
||||
* - il sorvegliante entra con un'email fittizia, iniziale del nome e cognome
|
||||
* (ffontana@termanager.it), come gli altri utenti: via users.username, e
|
||||
* users.email torna obbligatoria;
|
||||
* - sciolto il gruppo l'utenza del sorvegliante si elimina, non si disattiva:
|
||||
* via users.disattivato_at e le utenze già disattivate. Le loro
|
||||
* assegnazioni passano al primo amministratore; nel registro le loro voci
|
||||
* restano, a nome "Ex sorvegliante di gruppo".
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$amministratore = DB::table('model_has_roles')
|
||||
->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
|
||||
->where('roles.name', 'amministratore')
|
||||
->where('model_has_roles.model_type', 'App\\Models\\User')
|
||||
->min('model_has_roles.model_id');
|
||||
|
||||
if (Schema::hasColumn('users', 'disattivato_at')) {
|
||||
foreach (DB::table('users')->whereNotNull('disattivato_at')->get() as $utente) {
|
||||
if (! $amministratore) {
|
||||
continue; // nessuno a cui passare le assegnazioni: resta
|
||||
}
|
||||
|
||||
DB::table('assegnazioni')->where('created_by', $utente->id)->update(['created_by' => $amministratore]);
|
||||
DB::table('assegnazioni')->where('returned_by', $utente->id)->update(['returned_by' => $amministratore]);
|
||||
|
||||
foreach (DB::table('activity_log')->where('causer_type', 'App\\Models\\User')->where('causer_id', $utente->id)->get() as $voce) {
|
||||
$proprieta = json_decode((string) $voce->properties, true) ?: [];
|
||||
$proprieta['causer_name'] = 'Ex sorvegliante di gruppo';
|
||||
$proprieta['causer_email'] = null;
|
||||
$proprieta['causer_deleted_at'] = now()->toDateTimeString();
|
||||
DB::table('activity_log')->where('id', $voce->id)->update(['properties' => json_encode($proprieta)]);
|
||||
}
|
||||
|
||||
DB::table('model_has_roles')->where('model_type', 'App\\Models\\User')->where('model_id', $utente->id)->delete();
|
||||
DB::table('model_has_permissions')->where('model_type', 'App\\Models\\User')->where('model_id', $utente->id)->delete();
|
||||
DB::table('users')->where('id', $utente->id)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Chi è rimasto senza email (sorveglianti col nome utente): la fittizia.
|
||||
foreach (DB::table('users')->whereNull('email')->get() as $utente) {
|
||||
$proclamatore = $utente->proclamatore_id ? Proclamatore::withTrashed()->find($utente->proclamatore_id) : null;
|
||||
$email = $proclamatore
|
||||
? GruppiServizio::emailFittizia($proclamatore)
|
||||
: "utente{$utente->id}@" . GruppiServizio::DOMINIO_EMAIL;
|
||||
DB::table('users')->where('id', $utente->id)->update(['email' => $email]);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('users', 'username')) {
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropUnique(['username']);
|
||||
});
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('username');
|
||||
});
|
||||
}
|
||||
if (Schema::hasColumn('users', 'disattivato_at')) {
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('disattivato_at');
|
||||
});
|
||||
}
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('email')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('email')->nullable()->change();
|
||||
});
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('username', 60)->nullable()->unique()->after('email');
|
||||
$table->timestamp('disattivato_at')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Perché un utente deve cambiare la password: dopo un ripristino da backup
|
||||
* (com'era finora l'unico caso) o perché l'amministratore gli ha dato una
|
||||
* password temporanea (sorvegliante di gruppo, alla creazione del gruppo o
|
||||
* con "Nuova password temporanea"). La pagina del cambio password spiega il
|
||||
* motivo giusto.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('password_temporanea')->default(false)->after('password_change_required');
|
||||
});
|
||||
|
||||
// I sorveglianti che non hanno ancora cambiato la password di
|
||||
// creazione del gruppo: la loro è temporanea, non da ripristino.
|
||||
DB::table('users')
|
||||
->whereNotNull('proclamatore_id')
|
||||
->where('password_change_required', true)
|
||||
->update(['password_temporanea' => true]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('password_temporanea');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* Dal documento delle attività per la versione 2, decise dall'utente il
|
||||
* 21/10/2026:
|
||||
*
|
||||
* - richieste dal modulo pubblico (nuovo territorio o riconsegna), che
|
||||
* responsabile e assistenti confermano dalla Home: tabella `richieste` e
|
||||
* permesso `richieste.gestisci` (amministratore e assistente);
|
||||
* - territorio chiesto in cartaceo: segno sull'assegnazione
|
||||
* (`assegnazioni.cartaceo`).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('richieste', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// 'nuovo' (richiesta di un territorio) | 'riconsegna'
|
||||
$table->string('tipo', 20);
|
||||
// Nome e cognome come li ha scritti chi compila: cifrati, come i
|
||||
// proclamatori (cast `encrypted` nel modello).
|
||||
$table->text('nome');
|
||||
$table->string('numero_territorio', 20)->nullable();
|
||||
$table->text('note')->nullable();
|
||||
// 'aperta' | 'evasa' | 'scartata'
|
||||
$table->string('stato', 20)->default('aperta')->index();
|
||||
$table->foreignId('assegnazione_id')->nullable()->constrained('assegnazioni')->nullOnDelete();
|
||||
$table->foreignId('gestita_da')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('gestita_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->boolean('cartaceo')->default(false)->after('link_canale');
|
||||
});
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
$permesso = Permission::firstOrCreate(['name' => 'richieste.gestisci', 'guard_name' => 'web']);
|
||||
foreach (['amministratore', 'assistente'] as $ruolo) {
|
||||
Role::where('name', $ruolo)->where('guard_name', 'web')->first()?->givePermissionTo($permesso);
|
||||
}
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
Permission::where('name', 'richieste.gestisci')->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
Schema::table('assegnazioni', function (Blueprint $table) {
|
||||
$table->dropColumn('cartaceo');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('richieste');
|
||||
}
|
||||
};
|
||||
@@ -25,6 +25,8 @@ class RolesAndPermissionsSeeder extends Seeder
|
||||
'territori.return',
|
||||
'audit.view',
|
||||
'audit.export',
|
||||
'gruppo.gestisci',
|
||||
'richieste.gestisci',
|
||||
];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
@@ -43,7 +45,9 @@ class RolesAndPermissionsSeeder extends Seeder
|
||||
|
||||
// Amministratore: all permissions
|
||||
$admin = Role::firstOrCreate(['name' => 'amministratore']);
|
||||
$admin->givePermissionTo($permissions);
|
||||
// Tutti tranne gruppo.gestisci, che è del solo sorvegliante: l'admin
|
||||
// gestisce i gruppi da settings.manage, non ne ha uno suo.
|
||||
$admin->givePermissionTo(array_values(array_diff($permissions, ['gruppo.gestisci'])));
|
||||
|
||||
// Assistente: proclamatori, campagne, assign, return
|
||||
$assistente = Role::firstOrCreate(['name' => 'assistente']);
|
||||
@@ -53,6 +57,8 @@ class RolesAndPermissionsSeeder extends Seeder
|
||||
'territori.assign',
|
||||
'territori.return',
|
||||
'registro.view',
|
||||
// Le richieste dal modulo pubblico, confermate dalla Home.
|
||||
'richieste.gestisci',
|
||||
]);
|
||||
|
||||
// Operatore: assign and return only
|
||||
@@ -61,5 +67,10 @@ class RolesAndPermissionsSeeder extends Seeder
|
||||
'territori.assign',
|
||||
'territori.return',
|
||||
]);
|
||||
|
||||
// Sorvegliante di gruppo: solo il proprio gruppo di servizio. Il ruolo
|
||||
// si dà creando un gruppo (Gruppi di servizio), mai dalla pagina Utenti.
|
||||
$sorvegliante = Role::firstOrCreate(['name' => 'sorvegliante']);
|
||||
$sorvegliante->givePermissionTo(['gruppo.gestisci']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
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
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -46,6 +50,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
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
|
||||
user: www-data
|
||||
env_file: .env
|
||||
@@ -89,6 +97,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
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
|
||||
user: www-data
|
||||
env_file: .env
|
||||
@@ -207,6 +219,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
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
|
||||
# installata senza dipendenze di sviluppo non contiene phpunit.
|
||||
target: test
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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)
|
||||
# =============================================================================
|
||||
@@ -65,7 +81,7 @@ RUN npm run build
|
||||
# estensioni e sullo stesso php.ini della produzione: un test che passa qui
|
||||
# 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
|
||||
# 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
|
||||
# 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.
|
||||
#
|
||||
# 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 \
|
||||
curl \
|
||||
netcat-openbsd \
|
||||
@@ -101,8 +122,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
intl \
|
||||
zip \
|
||||
opcache \
|
||||
&& pecl install redis \
|
||||
&& docker-php-ext-enable redis \
|
||||
&& mkdir -p /usr/src/php/ext/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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -8,6 +8,16 @@ memory_limit = 256M
|
||||
max_execution_time = 120
|
||||
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.enable = 1
|
||||
opcache.memory_consumption = 128
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
"build": "vite build",
|
||||
"test:js": "node --test tests/js/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5",
|
||||
|
||||
@@ -557,33 +557,6 @@ html { scroll-behavior: smooth; }
|
||||
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 ──────────────────────── */
|
||||
|
||||
.tm-da-inviare {
|
||||
@@ -598,6 +571,14 @@ html { scroll-behavior: smooth; }
|
||||
background: var(--tm-accento-tenue);
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
@@ -1671,6 +1652,18 @@ html { scroll-behavior: smooth; }
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--tm-bordo) !important;
|
||||
}
|
||||
/* Riga di separazione di un gruppo (l'anno teocratico negli storici):
|
||||
resta un titolo, non diventa una scheda. */
|
||||
table.table-cards tr.tm-riga-gruppo {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 16px 0 6px;
|
||||
}
|
||||
table.table-cards tr.tm-riga-gruppo th {
|
||||
display: block;
|
||||
padding: 0 2px;
|
||||
}
|
||||
/* Cells spanning all columns (empty state, inline edit form) stay full
|
||||
width and block, so their inner layout isn't squeezed by the flex row. */
|
||||
table.table-cards td[colspan] {
|
||||
@@ -1678,3 +1671,184 @@ html { scroll-behavior: smooth; }
|
||||
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; }
|
||||
/* Campo trappola del modulo pubblico delle richieste: fuori dallo schermo,
|
||||
non `display:none` (alcuni programmi saltano i campi nascosti così). */
|
||||
.tm-trappola {
|
||||
position: absolute; left: -10000px; top: auto;
|
||||
width: 1px; height: 1px; overflow: hidden;
|
||||
}
|
||||
/* Il numero come pulsante ("Il mio gruppo": apre l'anteprima): stesso aspetto. */
|
||||
button.tm-assegnato-numero {
|
||||
background: none; border: 0; padding: 0; cursor: pointer;
|
||||
font-family: inherit; line-height: inherit;
|
||||
}
|
||||
button.tm-assegnato-numero:focus-visible {
|
||||
outline: 2px solid var(--tm-accento); outline-offset: 2px; border-radius: 2px;
|
||||
}
|
||||
.tm-assegnato-zona {
|
||||
margin-left: 6px; padding: 2px 6px; border-radius: 4px;
|
||||
background: #f3f4f6; color: #9ca3af; font-size: 12px;
|
||||
}
|
||||
/* Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). */
|
||||
.tm-cartaceo {
|
||||
margin-left: 4px; padding: 2px 6px; border-radius: 4px;
|
||||
background: #fffbeb; color: #b45309; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.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 { 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 {
|
||||
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);
|
||||
|
||||
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 }))];
|
||||
}
|
||||
@@ -21,7 +21,15 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($forced)
|
||||
@if($forced && $temporanea)
|
||||
{{-- Primo accesso del sorvegliante, o password nuova data dall'amministratore --}}
|
||||
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<strong>Password temporanea.</strong>
|
||||
Stai usando la password provvisoria che ti ha dato l’amministratore:
|
||||
scegline una tua per continuare. Fino ad allora il resto
|
||||
dell’applicazione resta bloccato.
|
||||
</div>
|
||||
@elseif($forced)
|
||||
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<strong>Ripristino da backup eseguito.</strong>
|
||||
Il pacchetto di backup contiene le password di tutti gli account, quindi
|
||||
|
||||
@@ -79,11 +79,24 @@
|
||||
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 lg:z-auto lg:shadow-none lg:border-r lg:border-gray-200 pt-16 lg:pt-0">
|
||||
<nav class="mt-4 px-3 space-y-0.5">
|
||||
{{-- Il sorvegliante di gruppo vede solo il suo gruppo (LimitaSorvegliante).
|
||||
Il ruolo, non @can: l'amministratore passa ogni @can
|
||||
(Gate::before) ma un gruppo suo non ce l'ha. --}}
|
||||
@if(auth()->user()->isSorvegliante())
|
||||
<a href="{{ route('mio-gruppo') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('mio-gruppo*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Il mio gruppo
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@unless(auth()->user()->isSorvegliante() && auth()->user()->roles->count() === 1)
|
||||
<a href="{{ route('dashboard') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('dashboard') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/></svg>
|
||||
Home
|
||||
</a>
|
||||
@endunless
|
||||
|
||||
@can('territori.assign')
|
||||
<a href="{{ route('territori.index') }}"
|
||||
@@ -107,6 +120,11 @@
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Assegnazioni
|
||||
</a>
|
||||
<a href="{{ route('assegnazioni.cartacei') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('assegnazioni.cartacei') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 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>
|
||||
Cartacei
|
||||
</a>
|
||||
@endcan
|
||||
|
||||
@can('campagne.manage')
|
||||
@@ -146,6 +164,11 @@
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Utenti
|
||||
</a>
|
||||
<a href="{{ route('gruppi.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('gruppi.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
Gruppi di servizio
|
||||
</a>
|
||||
<a href="{{ route('zone.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('zone.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
|
||||
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
|
||||
72
resources/views/livewire/assegnazioni/_invio-link.blade.php
Normal file
72
resources/views/livewire/assegnazioni/_invio-link.blade.php
Normal file
@@ -0,0 +1,72 @@
|
||||
{{-- Finestra di invio del link dopo un'assegnazione. Logica nel trait
|
||||
App\Livewire\Concerns\InviaLink; la includono Assegna e Il mio gruppo. --}}
|
||||
{{-- Invio del link (InviaLink::apriInvio): blocca la pagina finché non si
|
||||
risponde "inviato / non ancora". Se si chiude la scheda prima,
|
||||
l'assegnazione resta segnalata con "link ancora da inviare". --}}
|
||||
@if($invio)
|
||||
@php
|
||||
$link = $invio->shortPdfUrl();
|
||||
$messaggio = $invio->messaggioLink($link);
|
||||
@endphp
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-gray-900/50 p-4 sm:items-center"
|
||||
role="dialog" aria-modal="true" aria-labelledby="invio-titolo" wire:key="invio-{{ $invio->id }}">
|
||||
<section class="tm-da-inviare tm-da-inviare-finestra">
|
||||
<h2 id="invio-titolo" class="tm-da-inviare-titolo">
|
||||
Territorio N° {{ $invio->territorio?->numero }} assegnato a {{ $invio->proclamatore?->nome_completo }}
|
||||
</h2>
|
||||
|
||||
@if($link)
|
||||
<p class="text-sm text-gray-700 mb-2">Come vuoi mandare il link della piantina? Il destinatario lo scegli tu, tra i tuoi contatti.</p>
|
||||
|
||||
<div class="tm-da-inviare-link">
|
||||
<code id="invio-link" x-data x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
|
||||
</div>
|
||||
|
||||
{{-- Il messaggio sta in data-messaggio: dentro <x-btn> le direttive
|
||||
Blade (@js) non si compilano, quindi i pulsanti lo leggono da qui. --}}
|
||||
<div class="tm-da-inviare-azioni" x-data="{ copiato: false, condivisibile: !! navigator.share }"
|
||||
data-messaggio="{{ $messaggio }}" data-titolo="Territorio N° {{ $invio->territorio?->numero }}">
|
||||
<x-btn :variante="$canaleUsato === 'copia' ? 'primary' : 'secondary'"
|
||||
x-on:click="navigator.clipboard?.writeText(document.getElementById('invio-link').textContent.trim()).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) }).catch(() => {}); $wire.canale('copia')">
|
||||
<span x-show="! copiato">Copia link</span>
|
||||
<span x-show="copiato" x-cloak>Copiato</span>
|
||||
</x-btn>
|
||||
|
||||
{{-- Condividi: il menu di condivisione del telefono (WhatsApp,
|
||||
Telegram, SMS, posta…), da cui si sceglie app e contatto. Solo
|
||||
dove il browser lo offre. Il canale si annota a condivisione
|
||||
fatta, non se la si annulla. --}}
|
||||
<x-btn :variante="$canaleUsato === 'condividi' ? 'primary' : 'secondary'" x-show="condivisibile" x-cloak
|
||||
x-on:click="const d = $el.closest('[data-messaggio]').dataset; navigator.share({ title: d.titolo, text: d.messaggio }).then(() => $wire.canale('condividi')).catch(() => {})">
|
||||
Condividi…
|
||||
</x-btn>
|
||||
</div>
|
||||
|
||||
@if($canaleUsato)
|
||||
<p class="mt-2 text-sm text-gray-700">Canale scelto: {{ \App\Models\Assegnazione::CANALI_LINK[$canaleUsato] }}.</p>
|
||||
@endif
|
||||
@if($erroreInvio)
|
||||
<p class="mt-2 text-sm text-red-600" role="alert">{{ $erroreInvio }}</p>
|
||||
@endif
|
||||
|
||||
<div class="tm-da-inviare-scelta">
|
||||
<p>Hai inviato il link?</p>
|
||||
<div>
|
||||
<x-btn variante="primary" wire:click="concludiInvio(true)" :disabled="$canaleUsato === null"
|
||||
:title="$canaleUsato === null ? 'Scegli prima come mandare il link' : null">Sì, inviato</x-btn>
|
||||
<x-btn variante="secondary" wire:click="concludiInvio(false)">No, non ancora</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-600">
|
||||
Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio.
|
||||
</p>
|
||||
<div class="tm-da-inviare-scelta">
|
||||
<div>
|
||||
<x-btn variante="secondary" wire:click="concludiInvio(false)">Ho capito</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
</div>
|
||||
@endif
|
||||
@@ -1,75 +1,15 @@
|
||||
<div>
|
||||
<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>
|
||||
Torna ai territori
|
||||
{{ $ritorno ? 'Torna indietro' : 'Torna ai territori' }}
|
||||
</a>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{{-- Appena assegnato: il link da mandare al proclamatore, in evidenza, e
|
||||
la scelta "inviato / non ancora". Resta finché non si sceglie
|
||||
(Assegna::DA_INVIARE in sessione). --}}
|
||||
@foreach($daInviare as $nuova)
|
||||
@php
|
||||
$link = $nuova->shortPdfUrl();
|
||||
$messaggio = $link
|
||||
? "Ciao {$nuova->proclamatore->nome}, ti è stato assegnato il territorio N° {$nuova->territorio->numero}. Ecco la piantina: {$link}"
|
||||
: null;
|
||||
$whatsapp = $messaggio ? $nuova->proclamatore->whatsappUrl($messaggio) : null;
|
||||
@endphp
|
||||
<section class="tm-da-inviare" wire:key="da-inviare-{{ $nuova->id }}" aria-labelledby="da-inviare-{{ $nuova->id }}">
|
||||
<h2 id="da-inviare-{{ $nuova->id }}" class="tm-da-inviare-titolo">
|
||||
Territorio N° {{ $nuova->territorio->numero }} assegnato a {{ $nuova->proclamatore->nome_completo }}
|
||||
</h2>
|
||||
@include('livewire.assegnazioni._invio-link')
|
||||
|
||||
@if($link)
|
||||
<div x-data="{ copiato: false }" class="tm-da-inviare-link">
|
||||
<code x-ref="link" x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm"
|
||||
x-on:click="navigator.clipboard.writeText($refs.link.textContent).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) })">
|
||||
<span x-show="! copiato">Copia link</span>
|
||||
<span x-show="copiato" x-cloak>Copiato</span>
|
||||
</x-btn>
|
||||
</div>
|
||||
|
||||
<div class="tm-da-inviare-azioni">
|
||||
@if($whatsapp)
|
||||
<a href="{{ $whatsapp }}" target="_blank" rel="noopener" class="tm-whatsapp tm-whatsapp-grande">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||
<span>Invia su WhatsApp</span>
|
||||
<span class="tm-whatsapp-numero">{{ $nuova->proclamatore->telefono }}</span>
|
||||
</a>
|
||||
@else
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ $nuova->proclamatore->nome_completo }} non ha un numero di telefono: copia il link e mandalo come preferisci.
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('proclamatori.edit', $nuova->proclamatore) }}" class="text-indigo-700 underline">Aggiungi il numero</a>
|
||||
@endcan
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="tm-da-inviare-scelta">
|
||||
<p>Il link è stato inviato?</p>
|
||||
<div>
|
||||
<x-btn variante="primary" wire:click="esitoInvio({{ $nuova->id }}, true)">Sì, inviato</x-btn>
|
||||
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Non ancora</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-600">
|
||||
Il territorio non ha un PDF: non c'è un link da mandare. Caricalo dalla scheda del territorio.
|
||||
</p>
|
||||
<div class="tm-da-inviare-scelta">
|
||||
<div>
|
||||
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Ho capito</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endforeach
|
||||
|
||||
<div x-data="{ formOpen: true }">
|
||||
|
||||
@@ -86,55 +26,43 @@
|
||||
|
||||
{{-- 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">
|
||||
@if($richiesta)
|
||||
{{-- Partita da una richiesta del modulo pubblico (Home): assegnando, risulta evasa. --}}
|
||||
<div class="mb-4 rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900" role="status">
|
||||
Richiesta di <strong>{{ $richiesta->nome }}</strong> del {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif.
|
||||
Assegnando il territorio, la richiesta risulta evasa.
|
||||
</div>
|
||||
@endif
|
||||
<form wire:submit="save" class="space-y-4">
|
||||
<div>
|
||||
@if(!$preselectedTerritorioId)
|
||||
<label for="territorio_search" class="block text-sm font-medium text-gray-700">Filtra territori</label>
|
||||
<input wire:model.live.debounce.300ms="territorioSearch"
|
||||
type="text"
|
||||
id="territorio_search"
|
||||
placeholder="Cerca per numero, zona o tipologia"
|
||||
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">
|
||||
@endif
|
||||
|
||||
<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
|
||||
{{-- L'anteprima segue la scelta nel browser (evento "scelta" di
|
||||
<x-scelta>, miniatura nelle opzioni): scegliere un
|
||||
territorio non fa più una richiesta al server. --}}
|
||||
<div x-data="{ scelto: @js((bool) $territorio_id), anteprima: @js($anteprimaIniziale) }"
|
||||
x-on:scelta="scelto = !! $event.detail.valore; anteprima = $event.detail.opzione?.miniatura ?? null">
|
||||
<label for="territorio_id" class="block text-sm font-medium text-gray-700 mb-1">Territorio *</label>
|
||||
<x-scelta id="territorio_id" wire:model="territorio_id"
|
||||
:opzioni="$opzioniTerritori"
|
||||
segnaposto="Numero, zona o tipologia" :vuoto="null"
|
||||
:disabilitato="(bool) $preselectedTerritorioId" />
|
||||
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
|
||||
@if($territorio_id)
|
||||
<div class="mt-3">
|
||||
<div class="mt-3" x-show="scelto" x-cloak>
|
||||
<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">
|
||||
<img src="{{ $this->selectedThumbnailUrl }}"
|
||||
alt="Thumbnail territorio selezionato"
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm" x-show="anteprima">
|
||||
<img x-bind:src="anteprima" alt="Miniatura del territorio scelto" loading="lazy"
|
||||
class="block w-full h-auto max-h-[70vh] object-contain bg-white">
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500">
|
||||
Nessuna thumbnail disponibile per questo territorio.
|
||||
<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">
|
||||
Nessuna miniatura disponibile per questo territorio.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700">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">
|
||||
<option value="">Seleziona un proclamatore</option>
|
||||
@foreach($proclamatoriAttivi as $p)
|
||||
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700 mb-1">Proclamatore *</label>
|
||||
<x-scelta id="proclamatore_id" wire:model="proclamatore_id"
|
||||
:opzioni="$opzioniProclamatori"
|
||||
segnaposto="Cognome o nome" :vuoto="null" />
|
||||
@error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
@@ -144,115 +72,30 @@
|
||||
@error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input wire:model="cartaceo" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
Ha chiesto la cartolina cartacea
|
||||
<a href="{{ route('assegnazioni.cartacei') }}" class="text-xs text-indigo-600 hover:underline">(vedi i cartacei fuori)</a>
|
||||
</label>
|
||||
|
||||
<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>
|
||||
@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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Elenco territori attualmente assegnati --}}
|
||||
@if($assegnazioniAperte->count())
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-2">
|
||||
<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"><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
|
||||
{{-- Elenco dei territori assegnati: componente a sé, caricato subito
|
||||
dopo la pagina (ElencoAssegnati, #[Lazy]). Le azioni del form non
|
||||
lo ridisegnano. Non c'è quando si arriva da "Assegna →" della Home
|
||||
(Assegna::$elencoNascosto). --}}
|
||||
@unless($elencoNascosto)
|
||||
<livewire:assegnazioni.elenco-assegnati />
|
||||
@endunless
|
||||
</div>
|
||||
|
||||
47
resources/views/livewire/assegnazioni/cartacei.blade.php
Normal file
47
resources/views/livewire/assegnazioni/cartacei.blade.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Territori cartacei</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Le cartoline cartacee fuori e a chi: territori assegnati per cui il proclamatore ha chiesto il cartaceo.
|
||||
Al rientro escono dall'elenco.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
@if($assegnazioni->isEmpty())
|
||||
<p class="px-5 py-8 text-center text-sm text-gray-500">Nessun territorio cartaceo fuori.</p>
|
||||
@else
|
||||
<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-3 text-left text-xs font-semibold text-gray-600 uppercase">Territorio</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Proclamatore</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Dal</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Giorni</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($assegnazioni as $a)
|
||||
<tr wire:key="cartaceo-{{ $a->id }}">
|
||||
<td data-label="Territorio" class="px-4 py-3">
|
||||
<a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="font-semibold text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a>
|
||||
<span class="text-gray-500">{{ $a->territorio?->zona?->nome }}</span>
|
||||
</td>
|
||||
<td data-label="Proclamatore" class="px-4 py-3">{{ $a->proclamatore?->nome_completo }}</td>
|
||||
<td data-label="Dal" class="px-4 py-3 tabular-nums">{{ $a->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Giorni" class="px-4 py-3 text-right tabular-nums">{{ $a->giorni }}</td>
|
||||
<td class="px-4 py-3 text-right cell-actions">
|
||||
@can('territori.return')
|
||||
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', $a)">Rientro</x-btn>
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</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,78 @@
|
||||
<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).
|
||||
"Rientro" porta alla registrazione del rientro e torna qui
|
||||
(data-ritorno); indirizzo relativo e niente a capo: pesa meno.
|
||||
L'etichetta "cartaceo" è un'espressione e non un blocco if:
|
||||
Livewire circonda ogni if con due commenti segnaposto (58 byte
|
||||
a riga, anche quando è falso). Il testo è fisso, niente dati. --}}
|
||||
<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{!! $a->cartaceo ? ' <span class="tm-cartaceo">cartaceo</span>' : '' !!}</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 }}">@can('territori.return')<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a, false)">Rientro</x-btn>@endcan
|
||||
<code x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="tm-copia" x-on:click="copia($el)" title="Copia link" aria-label="Copia il link del PDF"><svg aria-hidden="true"><use href="#tm-i-copia"/></svg><svg aria-hidden="true"><use href="#tm-i-spunta"/></svg></x-btn>
|
||||
{{-- Condividi: menu di condivisione del telefono, solo dove il browser lo offre (elencoCercabile.condividi). --}}
|
||||
<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
|
||||
<div class="tm-assegnato-link">
|
||||
@can('territori.return')<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a, false)">Rientro</x-btn>@endcan
|
||||
<p class="tm-assegnato-senza">Nessun PDF disponibile</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
<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,12 +1,19 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('territori.show', $assegnazione->territorio) }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<a href="{{ $ritorno ?? $this->paginaPredefinita() }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Torna al territorio
|
||||
{{ $ritorno ? 'Torna indietro' : (auth()->user()->can('territori.assign') ? 'Torna al territorio' : 'Torna al mio gruppo') }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1>
|
||||
</div>
|
||||
|
||||
@if($richiesta)
|
||||
{{-- Partito da una richiesta di riconsegna (Home): registrando il rientro, risulta evasa. --}}
|
||||
<div class="mb-4 rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900 max-w-lg" role="status">
|
||||
Riconsegna annunciata da <strong>{{ $richiesta->nome }}</strong> il {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Assignment summary --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
@@ -40,13 +47,19 @@
|
||||
@if($showCampaignPrompt && $campagna)
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<p class="text-sm font-medium text-amber-800 mb-2">
|
||||
⚠️ Campagna Attiva: <strong>{{ $campagna->descrizione }}</strong>
|
||||
⚠️ Campagna: <strong>{{ $campagna->descrizione }}</strong>
|
||||
</p>
|
||||
<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') }}).
|
||||
Vuoi conteggiarlo nella percentuale di percorrenza?
|
||||
È stato percorso per la campagna? Se sì, conteggialo nella percentuale di percorrenza.
|
||||
</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">
|
||||
<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>
|
||||
@@ -56,7 +69,7 @@
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 pt-4">
|
||||
<x-btn variante="primary" type="submit">Registra rientro</x-btn>
|
||||
<x-btn variante="secondary" :href="route('territori.show', $assegnazione->territorio)">Annulla</x-btn>
|
||||
<x-btn variante="secondary" :href="$ritorno ?? $this->paginaPredefinita()">Annulla</x-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
'deleted' => 'bg-red-100 text-red-800',
|
||||
'assigned' => 'bg-indigo-100 text-indigo-800',
|
||||
'returned' => 'bg-amber-100 text-amber-800',
|
||||
'campagna_percorso' => 'bg-orange-100 text-orange-800',
|
||||
'restored' => 'bg-purple-100 text-purple-800',
|
||||
'login' => 'bg-cyan-100 text-cyan-800',
|
||||
default => 'bg-gray-100 text-gray-700',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div>
|
||||
<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>
|
||||
Torna alla lista
|
||||
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1>
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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>
|
||||
@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>
|
||||
Nuova campagna
|
||||
</x-btn>
|
||||
@@ -47,13 +47,13 @@
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right cell-actions">
|
||||
<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)"
|
||||
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>
|
||||
</x-btn>
|
||||
@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)"
|
||||
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>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<div>
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<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>
|
||||
Torna alla lista
|
||||
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $campagna->descrizione }}</h1>
|
||||
</div>
|
||||
@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>
|
||||
Modifica
|
||||
</x-btn>
|
||||
@@ -63,7 +63,7 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($conteggiate as $a)
|
||||
<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="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>
|
||||
@@ -78,4 +78,40 @@
|
||||
</table>
|
||||
</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>
|
||||
|
||||
63
resources/views/livewire/gruppi/gruppi-index.blade.php
Normal file
63
resources/views/livewire/gruppi/gruppi-index.blade.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Gruppi di servizio</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Ogni gruppo ha un sorvegliante, che con la sua utenza vede solo il proprio gruppo e assegna ai membri i territori del pacchetto.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if($creato && $passwordTemporanea)
|
||||
<div class="mb-6 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
|
||||
<p class="font-semibold">Gruppo «{{ $creato->nome }}» creato.</p>
|
||||
<p class="mt-1">Consegna queste credenziali a {{ $creato->sorvegliante?->nome_completo }}: la password si vede <strong>solo ora</strong> e al primo accesso va cambiata.</p>
|
||||
<dl class="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
<dt class="text-green-700">Indirizzo</dt><dd><code>{{ route('login') }}</code></dd>
|
||||
<dt class="text-green-700">Email</dt><dd><code class="select-all">{{ $creato->sorvegliante?->utente?->email }}</code></dd>
|
||||
<dt class="text-green-700">Password</dt><dd><code class="select-all">{{ $passwordTemporanea }}</code></dd>
|
||||
</dl>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<x-btn variante="primary" dimensione="sm" :href="route('gruppi.show', $creato)">Aggiungi membri e territori</x-btn>
|
||||
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiPassword">Ho annotato la password</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl" aria-labelledby="titolo-nuovo">
|
||||
<h2 id="titolo-nuovo" class="text-sm font-semibold text-gray-800 mb-3">Nuovo gruppo</h2>
|
||||
<form wire:submit="crea" class="space-y-4">
|
||||
<div>
|
||||
<label for="nome-gruppo" class="block text-sm font-medium text-gray-700 mb-1">Nome del gruppo *</label>
|
||||
<input wire:model="nome" id="nome-gruppo" type="text" placeholder="es. Zisa"
|
||||
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="sorvegliante" class="block text-sm font-medium text-gray-700 mb-1">Sorvegliante *</label>
|
||||
<x-scelta id="sorvegliante" wire:model="sorveglianteId" :opzioni="$opzioniSorveglianti" segnaposto="Cognome o nome" :vuoto="null" />
|
||||
<p class="text-xs text-gray-500 mt-1">Un proclamatore attivo che non fa già parte di un gruppo. Diventa anche membro del gruppo, e riceve un'utenza con un'email fittizia: iniziale del nome e cognome (es. Francesco Fontana → <code>ffontana@{{ \App\Services\GruppiServizio::DOMINIO_EMAIL }}</code>).</p>
|
||||
@error('sorveglianteId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<x-btn variante="primary" type="submit">Crea il gruppo</x-btn>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" aria-labelledby="titolo-elenco">
|
||||
<h2 id="titolo-elenco" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">Gruppi</h2>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($gruppi as $g)
|
||||
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<a data-ritorno href="{{ route('gruppi.show', $g) }}" class="font-semibold text-indigo-600 hover:underline">{{ $g->nome }}</a>
|
||||
<p class="text-xs text-gray-500">
|
||||
Sorvegliante: {{ $g->sorvegliante?->nome_completo ?? '—' }}
|
||||
· {{ $g->membri_count }} membri · {{ $g->territori_count }} territori nel pacchetto
|
||||
</p>
|
||||
</div>
|
||||
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" :href="route('gruppi.show', $g)">Gestisci</x-btn>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-8 text-center text-sm text-gray-500">Nessun gruppo di servizio.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
165
resources/views/livewire/gruppi/gruppo-show.blade.php
Normal file
165
resources/views/livewire/gruppi/gruppo-show.blade.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<a href="{{ $ritorno ?? route('gruppi.index') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
{{ $ritorno ? 'Torna indietro' : 'Gruppi di servizio' }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Sorvegliante: <strong>{{ $gruppo->sorvegliante?->nome_completo ?? '—' }}</strong>
|
||||
@if($gruppo->sorvegliante?->utente)
|
||||
· accede con <code class="select-all">{{ $gruppo->sorvegliante->utente->email }}</code>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if($passwordTemporanea)
|
||||
<div class="mb-6 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
|
||||
<p>Nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}, da cambiare al primo accesso. Si vede <strong>solo ora</strong>:</p>
|
||||
<p class="mt-2"><code class="select-all text-base">{{ $passwordTemporanea }}</code></p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ─── Pacchetto ─────────────────────────────────────────── --}}
|
||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-pacchetto">
|
||||
<h2 id="titolo-pacchetto" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">
|
||||
Pacchetto ({{ $territori->count() }} territori)
|
||||
</h2>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($territori as $t)
|
||||
@php($a = $t->assegnazioneCorrente)
|
||||
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-2" wire:key="pacchetto-{{ $t->id }}">
|
||||
<span class="text-sm">
|
||||
<a data-ritorno href="{{ route('territori.show', $t) }}" class="font-semibold text-indigo-600 hover:underline">N° {{ $t->numero }}</a>
|
||||
<span class="text-gray-500">{{ $t->zona?->nome }}</span>
|
||||
—
|
||||
@if($a)
|
||||
<span class="text-gray-700">a {{ $a->proclamatore?->nome_completo }} dal {{ $a->assigned_at->format('d/m/Y') }}</span>
|
||||
@else
|
||||
<span class="text-gray-700">da dare</span>
|
||||
@endif
|
||||
</span>
|
||||
@unless($a)
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="rimanda({{ $t->id }})"
|
||||
wire:confirm="Rimandare in reparto il territorio {{ $t->numero }}?">Rimanda in reparto</x-btn>
|
||||
@endunless
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-4 py-6 text-center text-sm text-gray-500">Il pacchetto è vuoto.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
|
||||
{{-- Assegnazione in blocco: si spuntano i territori in reparto, in
|
||||
ordine di priorità (come "Da assegnare" nella Home), filtrabili. --}}
|
||||
<div class="border-t border-gray-100 bg-gray-50 p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi territori al pacchetto</h3>
|
||||
@if($totaleCandidati === 0)
|
||||
<p class="text-sm text-gray-500">Non ci sono territori in reparto da aggiungere.</p>
|
||||
@else
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 mb-2">
|
||||
<input wire:model.live.debounce.300ms="cercaTerritorio" type="search" inputmode="search" autocomplete="off" aria-label="Cerca tra i territori in reparto"
|
||||
placeholder="Numero, zona o tipologia…"
|
||||
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<select wire:model.live="filtroZona" aria-label="Zona"
|
||||
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">Tutte le zone</option>
|
||||
@foreach($zoneCandidati as $id => $nome)
|
||||
<option value="{{ $id }}">{{ $nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select wire:model.live="filtroTipologia" aria-label="Tipologia"
|
||||
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">Tutte le tipologie</option>
|
||||
@foreach($tipologieCandidati as $id => $nome)
|
||||
<option value="{{ $id }}">{{ $nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 px-1">
|
||||
<input type="checkbox" wire:model.live="soloPrioritari" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
|
||||
Solo prioritari
|
||||
</label>
|
||||
</div>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<span>{{ $candidatiTerritori->count() }} di {{ $totaleCandidati }} in reparto, prima i prioritari e quelli fermi da più tempo</span>
|
||||
@if($candidatiTerritori->isNotEmpty())
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="selezionaMostrati">Seleziona tutti quelli mostrati</x-btn>
|
||||
@endif
|
||||
<x-btn variante="ghost" dimensione="sm" wire:click="deselezionaTutti">Deseleziona</x-btn>
|
||||
</div>
|
||||
<div class="max-h-96 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
|
||||
@forelse($candidatiTerritori as $c)
|
||||
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40" wire:key="candidato-t-{{ $c->id }}">
|
||||
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviTerritori" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
|
||||
<span class="min-w-0">
|
||||
<strong>N° {{ $c->numero }}</strong>
|
||||
@if($c->in_priorita)
|
||||
<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
|
||||
@endif
|
||||
<span class="block text-xs text-gray-500">
|
||||
{{ collect([$c->zona?->nome, $c->tipologia?->nome])->filter()->join(' — ') }}
|
||||
@if($c->giacenza_giorni > 0) · fermo da {{ $c->giacenza_giorni }} gg @endif
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@empty
|
||||
<p class="px-3 py-6 text-center text-sm text-gray-500">Nessun territorio con questi filtri.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<x-btn variante="primary" wire:click="aggiungiTerritori">Aggiungi i territori selezionati</x-btn>
|
||||
<span class="text-xs text-gray-500" x-data>Selezionati: <strong x-text="$wire.nuoviTerritori.length">{{ count($nuoviTerritori) }}</strong></span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ─── Membri ───────────────────────────────────────────── --}}
|
||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-membri">
|
||||
<h2 id="titolo-membri" class="px-4 py-3 border-b border-gray-100 text-sm font-semibold text-gray-800">Membri ({{ $membri->count() }})</h2>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@foreach($membri as $m)
|
||||
<li class="px-4 py-2.5 flex items-center justify-between gap-2 text-sm" wire:key="membro-{{ $m->id }}">
|
||||
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
|
||||
@if($m->id !== $gruppo->sorvegliante_id)
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="togliMembro({{ $m->id }})">Togli</x-btn>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<div class="border-t border-gray-100 bg-gray-50 p-4" x-data="elencoCercabile">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Aggiungi membri</h3>
|
||||
@if($candidatiMembri->isEmpty())
|
||||
<p class="text-sm text-gray-500">Tutti i proclamatori attivi fanno già parte di un gruppo.</p>
|
||||
@else
|
||||
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Filtra i proclamatori"
|
||||
placeholder="Filtra per nome…"
|
||||
class="mb-2 block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<div class="max-h-72 overflow-y-auto rounded-lg border border-gray-200 bg-white divide-y divide-gray-100">
|
||||
@foreach($candidatiMembri as $c)
|
||||
<label class="flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-indigo-50/40"
|
||||
data-cerca="{{ \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii($c->nome_completo)) }}"
|
||||
x-show="corrisponde($el.dataset.cerca)" wire:key="candidato-p-{{ $c->id }}">
|
||||
<input type="checkbox" value="{{ $c->id }}" wire:model="nuoviMembri" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
|
||||
<span>{{ $c->nome_completo }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<x-btn variante="primary" wire:click="aggiungiMembri">Aggiungi i membri selezionati</x-btn>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ─── Sorvegliante ─────────────────────────────────────── --}}
|
||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-4" aria-labelledby="titolo-sorvegliante">
|
||||
<h2 id="titolo-sorvegliante" class="text-sm font-semibold text-gray-800 mb-2">Sorvegliante</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-btn variante="secondary" wire:click="nuovaPassword"
|
||||
wire:confirm="Generare una nuova password temporanea per {{ $gruppo->sorvegliante?->nome_completo }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
|
||||
<x-btn variante="danger" wire:click="sciogli"
|
||||
wire:confirm="Togliere il ruolo di sorvegliante a {{ $gruppo->sorvegliante?->nome_completo }}? Il gruppo si scioglie e viene eliminato: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e l'utenza del sorvegliante viene eliminata. Le assegnazioni già fatte restano ai membri.">Togli il ruolo e sciogli il gruppo</x-btn>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
193
resources/views/livewire/gruppi/mio-gruppo.blade.php
Normal file
193
resources/views/livewire/gruppi/mio-gruppo.blade.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<div x-data="{ anteprima: null }">
|
||||
{{-- Il mio gruppo (MioGruppo): la sola pagina del sorvegliante di gruppo.
|
||||
Pensata prima per il telefono: schede una sotto l'altra, pulsanti
|
||||
grandi. Copia, Condividi e ricerca vengono da elencoCercabile
|
||||
(resources/js/app.js), l'invio del link dal partial _invio-link.
|
||||
Il numero del territorio apre l'anteprima (stato Alpine `anteprima`,
|
||||
finestra in fondo alla vista): miniatura e PDF dalle rotte
|
||||
mio-gruppo.*, che rispondono solo per il pacchetto del gruppo. --}}
|
||||
@if(! $gruppo)
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-sm text-gray-600">
|
||||
Non sei il sorvegliante di un gruppo di servizio attivo.
|
||||
</div>
|
||||
@else
|
||||
<div class="mb-5">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
|
||||
@if($gruppo->sorvegliante)
|
||||
<p class="text-sm text-gray-700 mt-0.5">Sorvegliante: <strong>{{ trim($gruppo->sorvegliante->nome . ' ' . $gruppo->sorvegliante->cognome) }}</strong></p>
|
||||
@endif
|
||||
{{-- In questa vista solo blocchi php…endphp: la forma in linea, se
|
||||
viene prima di un blocco, confonde il compilatore di Blade. --}}
|
||||
@php $daDare = $pacchetto->filter(fn ($t) => ! $t->assegnazioneCorrente)->count(); @endphp
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{ $pacchetto->count() }} {{ $pacchetto->count() === 1 ? 'territorio' : 'territori' }} nel pacchetto ·
|
||||
{{ $daDare }} da dare · {{ $membri->count() }} {{ $membri->count() === 1 ? 'membro' : 'membri' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@include('livewire.assegnazioni._invio-link')
|
||||
|
||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" x-data="elencoCercabile" aria-labelledby="titolo-pacchetto">
|
||||
<svg aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||
<symbol id="tm-i-copia" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></symbol>
|
||||
<symbol id="tm-i-spunta" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></symbol>
|
||||
<symbol id="tm-i-condividi" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/></symbol>
|
||||
<symbol id="tm-i-avviso" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></symbol>
|
||||
</svg>
|
||||
|
||||
<div class="px-4 py-3 border-b border-gray-100 space-y-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 id="titolo-pacchetto" class="text-sm font-semibold text-gray-800">Territori del pacchetto</h2>
|
||||
@if($pacchetto->count() > 5)
|
||||
<input x-model="q" type="search" inputmode="search" autocomplete="off" aria-label="Cerca nel pacchetto"
|
||||
placeholder="Cerca numero, zona o nome…"
|
||||
class="w-full sm:w-64 rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
@endif
|
||||
</div>
|
||||
{{-- Filtri; l'ordine è di priorità: prima i prioritari e quelli fermi da più tempo --}}
|
||||
@if($pacchetto->isNotEmpty())
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<select wire:model.live="filtroStato" aria-label="Stato"
|
||||
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">Tutti</option>
|
||||
<option value="da_dare">Da dare</option>
|
||||
<option value="dati">Già dati</option>
|
||||
</select>
|
||||
<select wire:model.live="filtroZona" aria-label="Zona"
|
||||
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">Tutte le zone</option>
|
||||
@foreach($zone as $id => $nome)
|
||||
<option value="{{ $id }}">{{ $nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select wire:model.live="filtroTipologia" aria-label="Tipologia"
|
||||
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="">Tutte le tipologie</option>
|
||||
@foreach($tipologie as $id => $nome)
|
||||
<option value="{{ $id }}">{{ $nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 px-1">
|
||||
<input type="checkbox" wire:model.live="soloPrioritari" class="h-5 w-5 rounded border-gray-300 text-indigo-600">
|
||||
Solo prioritari
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
@forelse($territori as $t)
|
||||
@php
|
||||
$a = $t->assegnazioneCorrente;
|
||||
$pdfUrl = $a?->shortPdfUrl();
|
||||
$cerca = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::ascii(implode(' ', array_filter([$t->numero, $t->zona?->nome, $a?->proclamatore?->nome_completo]))));
|
||||
@endphp
|
||||
<div wire:key="pacchetto-{{ $t->id }}" data-cerca="{{ $cerca }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
|
||||
<div class="tm-assegnato-testa">
|
||||
<span>@if($t->thumbnail_path || $t->pdf_path)<button type="button" class="tm-assegnato-numero" title="Anteprima del territorio"
|
||||
data-titolo="Territorio N° {{ $t->numero }}{{ $t->zona?->nome ? ' — ' . $t->zona->nome : '' }}"
|
||||
data-miniatura="{{ $t->miniaturaGruppoUrl() }}"
|
||||
data-pdf="{{ $t->pdf_path ? route('mio-gruppo.pdf', $t) : '' }}"
|
||||
x-on:click="anteprima = { titolo: $el.dataset.titolo, miniatura: $el.dataset.miniatura, pdf: $el.dataset.pdf }">N° {{ $t->numero }}</button>@else<span class="tm-assegnato-numero">N° {{ $t->numero }}</span>@endif @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif
|
||||
@if($t->in_priorita)<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>@endif @if($a?->cartaceo)<span class="tm-cartaceo">cartaceo</span>@endif</span>
|
||||
@if($a)
|
||||
<span class="tm-assegnato-info"><b>{{ $a->proclamatore?->nome_completo }}</b> · dal {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
|
||||
@else
|
||||
<span class="tm-assegnato-info"><b>Da dare</b>@if($t->tipologia?->nome) · {{ $t->tipologia->nome }}@endif @if($t->giacenza_giorni > 0) · fermo da {{ $t->giacenza_giorni }} gg @endif</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($a?->link_in_sospeso)
|
||||
<div class="tm-assegnato-sospeso" role="status"><svg aria-hidden="true"><use href="#tm-i-avviso"/></svg> Link ancora da inviare <x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="apriInvio({{ $a->id }})">Invia link</x-btn></div>
|
||||
@endif
|
||||
|
||||
@if($a)
|
||||
{{-- Assegnato: rientro, link (copia / condividi), stato dell'invio --}}
|
||||
<div class="tm-assegnato-link" @if($pdfUrl) data-link data-id="{{ $a->id }}" data-messaggio="{{ $a->messaggioLink($pdfUrl) }}" data-titolo="Territorio N° {{ $t->numero }}" @endif>
|
||||
<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a)">Rientro</x-btn>
|
||||
@if($pdfUrl)
|
||||
<code x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="tm-copia" x-on:click="copia($el)" title="Copia link" aria-label="Copia il link della piantina"><svg aria-hidden="true"><use href="#tm-i-copia"/></svg><svg aria-hidden="true"><use href="#tm-i-spunta"/></svg></x-btn>
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona x-show="condivisibile" x-cloak x-on:click="condividi($el)" title="Condividi il link" aria-label="Condividi il link della piantina"><svg aria-hidden="true"><use href="#tm-i-condividi"/></svg></x-btn>
|
||||
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" wire:click="toggleLinkSent({{ $a->id }})" aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}">{{ $a->link_sent ? 'Link inviato' : 'Non inviato' }}</x-btn>
|
||||
@endif
|
||||
</div>
|
||||
@elseif($territorioDaAssegnare === $t->id)
|
||||
{{-- Da dare: a chi e da quando --}}
|
||||
<div class="mt-3 rounded-lg border border-indigo-100 bg-indigo-50/50 p-3 space-y-3">
|
||||
<div>
|
||||
<label for="membro-{{ $t->id }}" class="block text-sm font-medium text-gray-700 mb-1">A chi lo dai?</label>
|
||||
<x-scelta id="membro-{{ $t->id }}" wire:model="membroId" :opzioni="$opzioniMembri" segnaposto="Cognome o nome" :vuoto="null" />
|
||||
@error('membroId') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="data-{{ $t->id }}" class="block text-sm font-medium text-gray-700 mb-1">Data</label>
|
||||
<input wire:model="dataAssegnazione" type="date" id="data-{{ $t->id }}" max="{{ now()->format('Y-m-d') }}"
|
||||
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
@error('dataAssegnazione') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input wire:model="cartaceo" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
Ha chiesto la cartolina cartacea
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-btn variante="primary" wire:click="assegna">Assegna</x-btn>
|
||||
<x-btn variante="ghost" wire:click="annullaAssegnazione">Annulla</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="tm-assegnato-link">
|
||||
<x-btn variante="primary" dimensione="sm" wire:click="apriAssegnazione({{ $t->id }})">Assegna</x-btn>
|
||||
@if($t->pdf_path)
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('mio-gruppo.pdf', $t)" target="_blank" rel="noopener">Piantina</x-btn>
|
||||
@endif
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="rimanda({{ $t->id }})"
|
||||
wire:confirm="Rimandare in reparto il territorio {{ $t->numero }}? Uscirà dal pacchetto del gruppo.">Rimanda in reparto</x-btn>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-4 py-8 text-center text-sm text-gray-500">
|
||||
{{ $pacchetto->isEmpty() ? "Il pacchetto è vuoto: l'amministratore non ha ancora assegnato territori al gruppo." : 'Nessun territorio con questi filtri.' }}
|
||||
</p>
|
||||
@endforelse
|
||||
<p x-show="nessuno" x-cloak class="px-4 py-8 text-center text-sm text-gray-400">Nessun risultato per «<span x-text="q"></span>».</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-5 bg-white rounded-xl shadow-sm border border-gray-200 p-4" aria-labelledby="titolo-membri">
|
||||
<h2 id="titolo-membri" class="text-sm font-semibold text-gray-800 mb-2">Membri del gruppo</h2>
|
||||
<ul class="text-sm text-gray-700 space-y-1">
|
||||
@foreach($membri as $m)
|
||||
@php $quanti = $pacchetto->filter(fn ($t) => $t->assegnazioneCorrente?->proclamatore_id === $m->id)->count(); @endphp
|
||||
<li class="flex justify-between gap-3">
|
||||
<span>{{ $m->nome_completo }} @if($m->id === $gruppo->sorvegliante_id)<span class="text-xs text-gray-500">(sorvegliante)</span>@endif</span>
|
||||
<span class="text-gray-500">{{ $quanti }} {{ $quanti === 1 ? 'territorio' : 'territori' }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{-- Anteprima del territorio: si apre dal numero, si chiude con la X,
|
||||
con Esc o toccando fuori. --}}
|
||||
<div class="tm-modale-sfondo" x-show="anteprima" x-cloak x-transition.opacity
|
||||
x-on:click.self="anteprima = null" x-on:keydown.escape.window="anteprima = null">
|
||||
<div class="tm-modale" role="dialog" aria-modal="true" aria-labelledby="anteprima-titolo">
|
||||
<header class="tm-modale-testata">
|
||||
<h2 id="anteprima-titolo" x-text="anteprima?.titolo"></h2>
|
||||
<button type="button" class="tm-editor-chiudi" x-on:click="anteprima = null" aria-label="Chiudi l'anteprima">×</button>
|
||||
</header>
|
||||
<div class="tm-modale-corpo">
|
||||
<template x-if="anteprima?.miniatura">
|
||||
<img x-bind:src="anteprima.miniatura" x-bind:alt="'Anteprima: ' + anteprima.titolo"
|
||||
class="mx-auto max-w-full rounded-lg border border-gray-200 bg-gray-50">
|
||||
</template>
|
||||
<p x-show="! anteprima?.miniatura" class="text-sm text-gray-500">Per questo territorio non c'è ancora un'anteprima.</p>
|
||||
</div>
|
||||
<footer class="tm-modale-piede" x-show="anteprima?.pdf">
|
||||
<a x-bind:href="anteprima?.pdf" target="_blank" rel="noopener" class="tm-collegamento">Apri la piantina (PDF)</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -78,11 +78,66 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Richieste dal modulo pubblico: si confermano portandole sul modulo
|
||||
di assegnazione o di rientro, che le chiude da solo. --}}
|
||||
@can('richieste.gestisci')
|
||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-richieste">
|
||||
<div class="px-5 py-4 border-b flex flex-wrap items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe">
|
||||
<div>
|
||||
<h3 id="titolo-richieste" class="text-sm font-semibold" style="color:#3730a3">Richieste ({{ $richieste->count() }})</h3>
|
||||
<p class="text-xs" style="color:#4f46e5">Dal modulo pubblico: <code class="select-all">{{ route('richiesta') }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
@forelse($richieste as $voce)
|
||||
@php
|
||||
$r = $voce['richiesta'];
|
||||
$a = $voce['assegnazione'];
|
||||
@endphp
|
||||
<div class="px-5 py-4 border-b border-gray-100 last:border-b-0" wire:key="richiesta-{{ $r->id }}">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="text-sm text-gray-900">
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $r->tipo === \App\Models\Richiesta::RICONSEGNA ? 'bg-amber-50 text-amber-800' : 'bg-indigo-50 text-indigo-700' }}">{{ $r->tipo_etichetta }}</span>
|
||||
<strong class="ml-1">{{ $r->nome }}</strong>
|
||||
@if($r->numero_territorio) · territorio N° {{ $r->numero_territorio }} @endif
|
||||
</p>
|
||||
<span class="text-xs text-gray-500">{{ $r->created_at->format('d/m/Y H:i') }}</span>
|
||||
</div>
|
||||
@if($r->note)
|
||||
<p class="mt-1 text-sm text-gray-600 italic">«{{ $r->note }}»</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
@if($r->tipo === \App\Models\Richiesta::NUOVO)
|
||||
<span class="text-xs text-gray-500">{{ $voce['simili']->isNotEmpty() ? 'Chi è? Assegna a:' : 'Nessun proclamatore con un nome simile.' }}</span>
|
||||
@foreach($voce['simili'] as $p)
|
||||
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm"
|
||||
:href="route('assegnazioni.assegna', ['proclamatore' => $p->id, 'richiesta' => $r->id, 'dalla_home' => 1])">{{ $p->nome_completo }} →</x-btn>
|
||||
@endforeach
|
||||
<x-btn data-ritorno variante="ghost" dimensione="sm"
|
||||
:href="route('assegnazioni.assegna', ['richiesta' => $r->id, 'dalla_home' => 1])">{{ $voce['simili']->isNotEmpty() ? 'Nessuno di questi: apri il modulo' : 'Apri il modulo' }}</x-btn>
|
||||
@elseif($a)
|
||||
<span class="text-xs text-gray-500">Assegnato a <strong>{{ $a->proclamatore?->nome_completo }}</strong> dal {{ $a->assigned_at->format('d/m/Y') }}</span>
|
||||
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm"
|
||||
:href="route('assegnazioni.rientra', ['assegnazione' => $a->id, 'richiesta' => $r->id])">Registra rientro →</x-btn>
|
||||
@else
|
||||
<span class="text-xs text-amber-800">Il territorio N° {{ $r->numero_territorio }} non risulta assegnato.</span>
|
||||
@endif
|
||||
<x-btn variante="danger" tono="tenue" dimensione="sm" class="ml-auto"
|
||||
wire:click="scartaRichiesta({{ $r->id }})"
|
||||
wire:confirm="Scartare la richiesta di {{ $r->nome }}?">Scarta</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-4 text-sm text-gray-500">Nessuna richiesta aperta.</p>
|
||||
@endforelse
|
||||
</section>
|
||||
@endcan
|
||||
|
||||
{{-- Quick lists --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Da assegnare --}}
|
||||
<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">
|
||||
<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>
|
||||
@@ -90,33 +145,39 @@
|
||||
<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>
|
||||
</div>
|
||||
{{-- "Vedi tutti" in testata, non in fondo all'elenco. --}}
|
||||
<div class="ml-auto flex flex-none items-center gap-2">
|
||||
@if($territoriDaAssegnare->count() >= $homeLimit)
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('territori.index', ['filterStato' => 'in_reparto'])">Vedi tutti</x-btn>
|
||||
@endif
|
||||
@hasanyrole('amministratore|assistente')
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto"
|
||||
<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>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($territoriDaAssegnare as $t)
|
||||
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||
<div>
|
||||
<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>
|
||||
@if($t->home_is_prioritario)
|
||||
<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->in_priorita)
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}
|
||||
@if($t->home_giorni_giacenza > 0)
|
||||
— <span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto
|
||||
@if($t->giacenza_giorni > 0)
|
||||
— <span class="font-medium">{{ $t->giacenza_giorni }} gg</span> in reparto
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@can('territori.assign')
|
||||
<x-btn variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $t->id])">Assegna →</x-btn>
|
||||
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $t->id, 'dalla_home' => 1])">Assegna →</x-btn>
|
||||
@endcan
|
||||
</li>
|
||||
@empty
|
||||
@@ -126,16 +187,11 @@
|
||||
</li>
|
||||
@endforelse
|
||||
</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>
|
||||
|
||||
{{-- Da rientrare --}}
|
||||
<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">
|
||||
<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>
|
||||
@@ -143,22 +199,27 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="ml-auto flex flex-none items-center gap-2">
|
||||
@if($daRientrare->count() >= $homeLimit)
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" :href="route('territori.index', ['filterStato' => 'da_rientrare'])">Vedi tutti</x-btn>
|
||||
@endif
|
||||
@hasanyrole('amministratore|assistente')
|
||||
{{-- Scaricare un elenco non è distruttivo: era rosso solo perché
|
||||
lo è la scheda che lo contiene. --}}
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona class="ml-auto"
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
|
||||
wire:click="downloadPdfDaRientrare"
|
||||
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>
|
||||
<ul class="divide-y divide-gray-100">
|
||||
@forelse($daRientrare as $t)
|
||||
@php($assegnazioneCorrente = $t->assegnazioneCorrente)
|
||||
<li class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||
<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">
|
||||
{{ $assegnazioneCorrente?->proclamatore?->nome_completo }}
|
||||
— <span class="font-medium" style="color:#dc2626">{{ $assegnazioneCorrente?->giorni }} gg</span>
|
||||
@@ -166,7 +227,7 @@
|
||||
</div>
|
||||
@can('territori.return')
|
||||
@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
|
||||
@endcan
|
||||
</li>
|
||||
@@ -177,11 +238,6 @@
|
||||
</li>
|
||||
@endforelse
|
||||
</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>
|
||||
|
||||
@@ -148,15 +148,8 @@
|
||||
|
||||
<div class="tm-campo">
|
||||
<label for="territorioScelto">Collegata a</label>
|
||||
<select id="territorioScelto" wire:model.live="territorioScelto">
|
||||
<option value="">— nessun territorio —</option>
|
||||
<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>
|
||||
<x-scelta id="territorioScelto" wire:model.live="territorioScelto"
|
||||
:opzioni="$opzioniTerritori" segnaposto="Numero, zona o tipologia" />
|
||||
@error('territorioScelto') <p class="tm-campo-errore">{{ $message }}</p> @enderror
|
||||
@if($mappa->territorioNelCestino())
|
||||
<p class="tm-campo-aiuto tm-campo-aiuto-attesa">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -13,10 +16,23 @@
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Dati trattati</h2>
|
||||
<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>Territori</strong>: numero, località, zona, tipologia, mappe PDF — nessun dato personale.</li>
|
||||
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio con date di assegnazione e rientro.</li>
|
||||
<li><strong>Utenti</strong>: nome, email e ruolo per l'accesso all'applicazione.</li>
|
||||
<li><strong>Proclamatori</strong>: solo nome e cognome, cifrati nel database ({{ $cifratura }}), se sono attivi e l'eventuale gruppo di servizio di cui fanno parte. Nessun numero di telefono né indirizzo email.</li>
|
||||
<li><strong>Territori e piantine</strong>: numero, zona, tipologia, confini, mappe e PDF — nessun dato personale.</li>
|
||||
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio, con date di assegnazione e rientro, conteggio nelle campagne, codice del link alla piantina, stato del suo invio (inviato o no, e con quale canale) se il proclamatore ha chiesto la cartolina cartacea e, se l'ha data il sorvegliante di un gruppo di servizio, il nome del gruppo.</li>
|
||||
<li><strong>Richieste dal modulo pubblico</strong>: chi chiede un territorio o ne annuncia la riconsegna scrive il proprio nome, l'eventuale numero del territorio e una nota. Nome e nota sono cifrati nel database ({{ $cifratura }}) e non finiscono nel registro delle attività. Dell'indirizzo IP resta solo un'impronta temporanea, per limitare i tentativi.</li>
|
||||
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo. Il sorvegliante di un gruppo di servizio entra con un'email fittizia creata dall'applicazione (iniziale del nome e cognome), che non riceve posta e non va confermata. La sua utenza è collegata al suo proclamatore e viene eliminata quando il gruppo si scioglie; anche il gruppo, sciolto, viene eliminato.</li>
|
||||
<li><strong>Registro delle attività</strong>: chi ha fatto cosa e quando. I proclamatori vi compaiono solo con un identificativo numerico, mai con il nome.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<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['richieste'] }} richieste dal modulo pubblico, di cui {{ $conteggi['richiesteAperte'] }} da gestire.</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>
|
||||
</section>
|
||||
|
||||
@@ -25,6 +41,7 @@
|
||||
<p>I dati personali sono trattati esclusivamente per:</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<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>Gestire le campagne speciali di predicazione.</li>
|
||||
</ul>
|
||||
@@ -35,24 +52,64 @@
|
||||
<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>
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Chi accede ai dati</h2>
|
||||
<p>L'accesso è riservato agli utenti dell'applicazione, ciascuno secondo il proprio ruolo:</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
@foreach($ruoli as $ruolo)
|
||||
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email fittizia e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
|
||||
<p class="mt-2">Il modulo pubblico di richiesta non mostra dati personali: chi annuncia una riconsegna sceglie il territorio da un elenco con solo numero e zona, senza sapere a chi è assegnato né se lo è. Le richieste le vedono e le gestiscono, dalla Home, solo i ruoli con il permesso sulle richieste.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Misure di sicurezza</h2>
|
||||
<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>Controllo accessi</strong>: sistema RBAC con tre ruoli (Admin, Servitore di Territorio, Proclamatore Semplificato).</li>
|
||||
<li><strong>Audit trail</strong>: tutte le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</li>
|
||||
<li><strong>Comunicazioni sicure</strong>: l'accesso avviene tramite HTTPS.</li>
|
||||
<li><strong>Hosting locale</strong>: i dati risiedono sull'infrastruttura della congregazione, non su servizi cloud di terze parti.</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>Accesso</strong>: password personale, conferma dell'email, blocco temporaneo dopo troppi tentativi; la sessione scade dopo {{ $durataSessione }} minuti di inattività.</li>
|
||||
<li><strong>Permessi</strong>: ogni ruolo vede e modifica solo ciò che gli serve (vedi sopra).</li>
|
||||
<li><strong>Modulo pubblico</strong>: protetto dallo spam con un campo trappola, un tempo minimo di compilazione e un limite di richieste per indirizzo.</li>
|
||||
<li><strong>Registro delle attività</strong>: le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</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 partono dal server di posta <code>{{ $hostPosta ?? 'configurato' }}</code>: conferma dell'indirizzo, avviso di una nuova richiesta dal modulo pubblico a chi le gestisce, e il modulo S-13 (registro delle assegnazioni, in PDF) agli amministratori l'ultima domenica del mese. 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>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>I dati dei proclamatori inattivi possono essere anonimizzati su richiesta.</li>
|
||||
<li>I territori eliminati sono conservati nel cestino (soft-delete) prima dell'eliminazione definitiva.</li>
|
||||
<li>I log di audit sono conservati per <strong>{{ $auditRetention }} giorni</strong>, configurabili nelle impostazioni.</li>
|
||||
<li>Lo storico assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
|
||||
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile; se era sorvegliante di un gruppo, il gruppo si scioglie e la sua utenza viene eliminata.</li>
|
||||
<li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li>
|
||||
<li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li>
|
||||
<li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
|
||||
<li>Le richieste dal modulo pubblico sono cancellate automaticamente <strong>{{ \App\Console\Commands\PuliziaRichieste::GIORNI }} giorni</strong> dopo essere state gestite.</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>
|
||||
</section>
|
||||
|
||||
@@ -65,7 +122,7 @@
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div>
|
||||
<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>
|
||||
Torna alla lista
|
||||
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1>
|
||||
</div>
|
||||
@@ -21,30 +21,20 @@
|
||||
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</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">
|
||||
<input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="attivo" class="text-sm text-gray-700">Attivo</label>
|
||||
</div>
|
||||
@if($gruppoSorvegliato ?? null)
|
||||
<p class="-mt-2 text-xs text-amber-800">
|
||||
È il sorvegliante del gruppo <strong>{{ $gruppoSorvegliato->nome }}</strong>: se non è più attivo il gruppo si scioglie,
|
||||
i territori non ancora dati tornano in reparto e la sua utenza viene eliminata.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>
|
||||
<x-btn variante="secondary" :href="route('proclamatori.index')">Annulla</x-btn>
|
||||
<x-btn variante="secondary" :href="$ritorno ?? route('proclamatori.index')">Annulla</x-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
link rosso perso in mezzo ai filtri, in un posto diverso rispetto
|
||||
all'altra lista che fa esattamente la stessa cosa. --}}
|
||||
<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>
|
||||
Nuovo proclamatore
|
||||
</x-btn>
|
||||
@@ -26,6 +26,7 @@
|
||||
<option value="">Tutti</option>
|
||||
<option value="attivo">Attivi</option>
|
||||
<option value="inattivo">Inattivi</option>
|
||||
<option value="gruppo">In un gruppo di servizio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,27 +54,40 @@
|
||||
<td data-label="Cognome" class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td>
|
||||
<td data-label="Nome" class="px-4 py-3 text-sm text-gray-700">{{ $proclamatore->nome }}</td>
|
||||
<td data-label="Stato" class="px-4 py-3 text-sm">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@if($proclamatore->attivo)
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attivo</span>
|
||||
@else
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
|
||||
@endif
|
||||
@if($proclamatore->gruppo_etichetta)
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-800">{{ $proclamatore->gruppo_etichetta }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right cell-actions">
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<x-whatsapp :proclamatore="$proclamatore" compatto />
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
|
||||
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm" icona
|
||||
:href="route('proclamatori.show', $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>
|
||||
</x-btn>
|
||||
@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)"
|
||||
title="Modifica" aria-label="Modifica il proclamatore">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</x-btn>
|
||||
@if($proclamatore->attivo && isset($gruppiSorvegliati[$proclamatore->id]))
|
||||
{{-- Sorvegliante: disattivarlo scioglie il gruppo, si chiede conferma --}}
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
|
||||
wire:click="toggleActive({{ $proclamatore->id }})"
|
||||
wire:confirm="{{ $proclamatore->nome_completo }} è il sorvegliante del gruppo {{ $gruppiSorvegliati[$proclamatore->id] }}. Se non è più attivo il gruppo si scioglie: i territori non ancora dati tornano in reparto, i membri restano senza gruppo e la sua utenza viene eliminata. Procedere?"
|
||||
title="Disattiva" aria-label="Disattiva il proclamatore">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
|
||||
</x-btn>
|
||||
@else
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
|
||||
wire:click="toggleActive({{ $proclamatore->id }})"
|
||||
title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}"
|
||||
@@ -84,9 +98,10 @@
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
@endif
|
||||
</x-btn>
|
||||
@endif
|
||||
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
|
||||
wire:click="deleteProclamatore({{ $proclamatore->id }})"
|
||||
wire:confirm="Spostare il proclamatore nel cestino?"
|
||||
wire:confirm="{{ isset($gruppiSorvegliati[$proclamatore->id]) ? 'Spostare nel cestino ' . $proclamatore->nome_completo . '? È il sorvegliante del gruppo ' . $gruppiSorvegliati[$proclamatore->id] . ': il gruppo si scioglie, i territori non ancora dati tornano in reparto e la sua utenza viene eliminata.' : 'Spostare il proclamatore nel cestino?' }}"
|
||||
title="Elimina" aria-label="Sposta nel cestino il proclamatore">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</x-btn>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
<div>
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<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>
|
||||
Torna alla lista
|
||||
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
|
||||
</a>
|
||||
<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>
|
||||
@if($proclamatore->gruppo_etichetta)
|
||||
<span class="mt-1 inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">{{ $proclamatore->gruppo_etichetta }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@can('settings.manage')
|
||||
<x-btn 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>
|
||||
Modifica
|
||||
</x-btn>
|
||||
@@ -44,56 +39,118 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Gruppo di servizio --}}
|
||||
@if($gruppoSorvegliato || $gruppoMembro)
|
||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" aria-labelledby="titolo-gruppo">
|
||||
<h3 id="titolo-gruppo" class="text-lg font-semibold text-gray-900 mb-3">Gruppo di servizio</h3>
|
||||
|
||||
@if($gruppoSorvegliato)
|
||||
<p class="text-sm text-gray-700">
|
||||
<span class="inline-flex px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">Sorvegliante</span>
|
||||
del gruppo
|
||||
@can('settings.manage')
|
||||
<a data-ritorno href="{{ route('gruppi.show', $gruppoSorvegliato) }}" class="font-semibold text-indigo-600 hover:underline">{{ $gruppoSorvegliato->nome }}</a>
|
||||
@else
|
||||
<strong>{{ $gruppoSorvegliato->nome }}</strong>
|
||||
@endcan
|
||||
dal {{ $gruppoSorvegliato->created_at->format('d/m/Y') }}
|
||||
</p>
|
||||
|
||||
<dl class="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<dt class="text-xs text-gray-500">Membri</dt>
|
||||
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['membri'] }}</dd>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<dt class="text-xs text-gray-500">Nel pacchetto</dt>
|
||||
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['pacchetto'] }}</dd>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<dt class="text-xs text-gray-500">Da dare</dt>
|
||||
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['da_dare'] }}</dd>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<dt class="text-xs text-gray-500">Assegnazioni dal gruppo</dt>
|
||||
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['assegnazioni'] }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if($utenza)
|
||||
<p class="mt-4 border-t border-gray-100 pt-3 text-sm text-gray-600">
|
||||
Utenza
|
||||
@can('settings.manage') <code>{{ $utenza->email }}</code> @endcan
|
||||
· {{ $utenza->password_change_required ? 'password temporanea, ancora da cambiare' : 'password personale impostata' }}
|
||||
· {{ $ultimoAccesso ? 'ultimo accesso il ' . \Illuminate\Support\Carbon::parse($ultimoAccesso)->format('d/m/Y H:i') : 'non è ancora entrato' }}
|
||||
</p>
|
||||
@endif
|
||||
@elseif($gruppoMembro)
|
||||
<p class="text-sm text-gray-700">
|
||||
Membro del gruppo
|
||||
@can('settings.manage')
|
||||
<a data-ritorno href="{{ route('gruppi.show', $gruppoMembro) }}" class="font-semibold text-indigo-600 hover:underline">{{ $gruppoMembro->nome }}</a>,
|
||||
@else
|
||||
<strong>{{ $gruppoMembro->nome }}</strong>,
|
||||
@endcan
|
||||
sorvegliante {{ $gruppoMembro->sorvegliante?->nome_completo ?? '—' }}.
|
||||
</p>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Assignment history --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Storico Assegnazioni</h3>
|
||||
|
||||
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni)
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4>
|
||||
{{-- Come nella scheda del territorio: una tabella sola, colonne fisse,
|
||||
l'anno come riga di separazione (tm-riga-gruppo), dal più recente. --}}
|
||||
@if($assegnazioniPerAnno->isEmpty())
|
||||
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
||||
<table class="min-w-full table-fixed text-sm table-cards">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
|
||||
<th class="w-[36%] px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
|
||||
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
|
||||
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
|
||||
<th class="w-[9%] px-3 py-2 text-right text-xs font-medium text-gray-500">Giorni</th>
|
||||
<th class="w-[25%] px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@foreach($assegnazioniPerAnno as $annoLabel => $assegnazioni)
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr class="tm-riga-gruppo">
|
||||
<th colspan="5" scope="colgroup" class="px-3 pt-5 pb-2 text-left text-sm font-semibold text-indigo-600 bg-white">Anno Teocratico {{ $annoLabel }}</th>
|
||||
</tr>
|
||||
@foreach($assegnazioni as $assegnazione)
|
||||
<tr>
|
||||
<td data-label="Territorio" class="px-3 py-2">
|
||||
<a href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">
|
||||
{{ $assegnazione->territorio?->numero ?? 'N/A' }}
|
||||
</a>
|
||||
<a data-ritorno href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $assegnazione->territorio?->numero ?? 'N/A' }}</a>@if($assegnazione->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo_nome }}</span>@endif
|
||||
</td>
|
||||
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Rientrato" class="px-3 py-2">
|
||||
<td data-label="Assegnato" class="px-3 py-2 tabular-nums">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Rientrato" class="px-3 py-2 tabular-nums">
|
||||
@if($assegnazione->returned_at)
|
||||
{{ $assegnazione->returned_at->format('d/m/Y') }}
|
||||
@else
|
||||
<span class="text-amber-600 font-medium">In corso</span>
|
||||
@endif
|
||||
</td>
|
||||
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</td>
|
||||
<td data-label="Giorni" class="px-3 py-2 text-right tabular-nums">{{ $assegnazione->giorni }}</td>
|
||||
<td data-label="Campagna" class="px-3 py-2">
|
||||
@if($assegnazione->counted_in_campaign)
|
||||
<span class="text-green-600">{{ $assegnazione->campagna?->descrizione }}</span>
|
||||
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
|
||||
@elseif($assegnazione->counted_in_campaign === false)
|
||||
<span class="text-gray-400">No</span>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
<span class="text-gray-300">-</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
@endforeach
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
|
||||
@endforelse
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('dashboard') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<a href="{{ $gruppo ? route('mio-gruppo') : route('dashboard') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
Torna alla home
|
||||
{{ $gruppo ? 'Torna al mio gruppo' : 'Torna alla home' }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Il mio profilo</h1>
|
||||
</div>
|
||||
@@ -25,10 +25,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Gruppo di servizio (solo per il sorvegliante) --}}
|
||||
@if($gruppo)
|
||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-6" aria-labelledby="titolo-mio-gruppo">
|
||||
<h3 id="titolo-mio-gruppo" class="text-lg font-semibold text-gray-900 mb-1">Il tuo gruppo di servizio</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Sei il sorvegliante del gruppo <strong class="text-gray-800">{{ $gruppo->nome }}</strong> dal {{ $gruppo->created_at->format('d/m/Y') }}.
|
||||
</p>
|
||||
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<dt class="text-xs text-gray-500">Membri</dt>
|
||||
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['membri'] }}</dd>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<dt class="text-xs text-gray-500">Nel pacchetto</dt>
|
||||
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['pacchetto'] }}</dd>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<dt class="text-xs text-gray-500">Da dare</dt>
|
||||
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['da_dare'] }}</dd>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 px-3 py-2">
|
||||
<dt class="text-xs text-gray-500">Dati ai membri</dt>
|
||||
<dd class="text-xl font-bold text-gray-900">{{ $riepilogoGruppo['dati'] }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-4">
|
||||
<x-btn variante="primary" :href="route('mio-gruppo')">Vai al mio gruppo</x-btn>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Dati profilo --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Dati profilo</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Aggiorna nome e cognome ed email.</p>
|
||||
<p class="text-sm text-gray-500 mb-4">{{ $user->isSorvegliante() ? 'Aggiorna nome e cognome.' : 'Aggiorna nome e cognome ed email.' }}</p>
|
||||
|
||||
@if(session('profile_success'))
|
||||
<div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
|
||||
@@ -42,11 +73,20 @@
|
||||
<input wire:model="name" type="text" id="name" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
@if($user->isSorvegliante())
|
||||
{{-- Il sorvegliante entra con l'email fittizia creata col gruppo --}}
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700">Email per entrare</p>
|
||||
<p class="mt-1 text-sm"><code class="select-all">{{ $user->email }}</code></p>
|
||||
<p class="mt-1 text-xs text-gray-500">L'ha creata l'applicazione insieme al gruppo: la usi per entrare, insieme alla password. Non riceve posta.</p>
|
||||
</div>
|
||||
@else
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input wire:model="email" type="email" id="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex justify-end">
|
||||
<x-btn variante="primary" type="submit">Salva modifiche</x-btn>
|
||||
</div>
|
||||
|
||||
@@ -82,12 +82,12 @@
|
||||
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none">
|
||||
@endif
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
||||
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}@if($a->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700" title="Assegnato dal sorvegliante del gruppo di servizio">via gruppo {{ $a->gruppo_nome }}</span>@endif</td>
|
||||
<td data-label="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Rientrato" class="px-3 py-2">
|
||||
@if($a->returned_at)
|
||||
@@ -188,25 +188,17 @@
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;">
|
||||
{{-- Territorio --}}
|
||||
<div style="grid-column:1/-1;">
|
||||
<label 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;">
|
||||
<option value="">-- seleziona --</option>
|
||||
@foreach($territori as $t)
|
||||
<option value="{{ $t->id }}">N° {{ $t->numero }} — {{ $t->zona?->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label for="form_territorio_id" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Territorio *</label>
|
||||
<x-scelta id="form_territorio_id" wire:model="form_territorio_id"
|
||||
:opzioni="$opzioniTerritori" segnaposto="Numero, zona o tipologia" />
|
||||
@error('form_territorio_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Proclamatore --}}
|
||||
<div style="grid-column:1/-1;">
|
||||
<label 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;">
|
||||
<option value="">-- seleziona --</option>
|
||||
@foreach($proclamatori as $p)
|
||||
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<label for="form_proclamatore_id" style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Proclamatore *</label>
|
||||
<x-scelta id="form_proclamatore_id" wire:model="form_proclamatore_id"
|
||||
:opzioni="$opzioniProclamatori" segnaposto="Cognome o nome" />
|
||||
@error('form_proclamatore_id') <span style="color:#dc2626;font-size:12px;">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<div class="bg-white shadow-xl rounded-2xl p-6 sm:p-8 my-8" style="border-top:4px solid #6366f1">
|
||||
{{-- Modulo pubblico (ModuloRichiesta): nessun dato già presente si vede
|
||||
qui, tranne l'elenco dei territori (numero e zona) per scegliere
|
||||
quello da riconsegnare. --}}
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto h-14 w-14 rounded-xl flex items-center justify-center text-white font-bold text-xl mb-4 shadow-lg" style="background:linear-gradient(135deg,#4338ca,#6366f1)">T2</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Richiesta territorio</h1>
|
||||
<p class="text-gray-500 text-sm mt-1">{{ $congregazione }}</p>
|
||||
</div>
|
||||
|
||||
@if($inviata)
|
||||
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-800" role="status">
|
||||
<p class="font-semibold">Richiesta inviata.</p>
|
||||
<p class="mt-1">Il responsabile dei territori la vedrà e ti farà sapere.</p>
|
||||
</div>
|
||||
<div class="mt-5 text-center">
|
||||
<x-btn variante="secondary" wire:click="nuova">Fai un'altra richiesta</x-btn>
|
||||
</div>
|
||||
@else
|
||||
<form wire:submit="invia" class="space-y-5">
|
||||
<fieldset>
|
||||
<legend class="block text-sm font-medium text-gray-700 mb-2">Cosa ti serve? *</legend>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-3 text-sm cursor-pointer has-[:checked]:border-indigo-400 has-[:checked]:bg-indigo-50">
|
||||
<input type="radio" wire:model.live="tipo" value="{{ \App\Models\Richiesta::NUOVO }}" class="h-4 w-4 text-indigo-600">
|
||||
Chiedo un territorio nuovo
|
||||
</label>
|
||||
<label class="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-3 text-sm cursor-pointer has-[:checked]:border-indigo-400 has-[:checked]:bg-indigo-50">
|
||||
<input type="radio" wire:model.live="tipo" value="{{ \App\Models\Richiesta::RICONSEGNA }}" class="h-4 w-4 text-indigo-600">
|
||||
Riconsegno un territorio
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label for="nome" class="block text-sm font-medium text-gray-700">Nome e cognome *</label>
|
||||
<input wire:model="nome" type="text" id="nome" autocomplete="name" maxlength="100"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5">
|
||||
@error('nome') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
@if($tipo === \App\Models\Richiesta::RICONSEGNA)
|
||||
<div>
|
||||
<label for="numero" class="block text-sm font-medium text-gray-700">Territorio che riconsegni *</label>
|
||||
<x-scelta id="numero" wire:model="numero" :opzioni="$opzioniTerritori" segnaposto="Scrivi il numero o la zona" class="mt-1" />
|
||||
@error('numero') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label for="note" class="block text-sm font-medium text-gray-700">
|
||||
{{ $tipo === \App\Models\Richiesta::RICONSEGNA ? 'Note' : 'Esigenze particolari / note' }}
|
||||
</label>
|
||||
<textarea wire:model="note" id="note" rows="3" maxlength="1000"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5"></textarea>
|
||||
@error('note') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Trappola per i programmi che riempiono ogni campo: nascosta a chi
|
||||
guarda e ai lettori di schermo. --}}
|
||||
<div class="tm-trappola" aria-hidden="true">
|
||||
<label for="sito">Lascia vuoto questo campo</label>
|
||||
<input wire:model="sito" type="text" id="sito" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<x-btn variante="primary" type="submit" class="w-full">Invia la richiesta</x-btn>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
Nome e note servono solo a gestire questa richiesta: li vedono il responsabile dei territori e i suoi
|
||||
assistenti, e vengono cancellati {{ \App\Console\Commands\PuliziaRichieste::GIORNI }} giorni dopo che la richiesta è stata gestita.
|
||||
</p>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@@ -57,6 +57,19 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Utenti esistenti</h2>
|
||||
|
||||
@if($passwordTemporanea && $utentePassword)
|
||||
<div class="mb-4 rounded-xl border-2 border-green-200 bg-green-50 p-4 text-sm text-green-900" role="status">
|
||||
<p>Nuova password temporanea per <strong>{{ $utentePassword->name }}</strong>, da cambiare al primo accesso. Si vede <strong>solo ora</strong>:</p>
|
||||
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
<dt class="text-green-700">Email</dt><dd><code class="select-all">{{ $utentePassword->email }}</code></dd>
|
||||
<dt class="text-green-700">Password</dt><dd><code class="select-all text-base">{{ $passwordTemporanea }}</code></dd>
|
||||
</dl>
|
||||
<div class="mt-3">
|
||||
<x-btn variante="ghost" dimensione="sm" wire:click="chiudiPassword">Ho annotato la password</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
||||
<thead class="bg-gray-50">
|
||||
@@ -129,6 +142,18 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2 cell-actions">
|
||||
@if($user->proclamatore_id)
|
||||
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo;
|
||||
da qui solo la password, se l'ha persa. --}}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm"
|
||||
wire:click="nuovaPasswordSorvegliante({{ $user->id }})"
|
||||
wire:confirm="Generare una nuova password temporanea per {{ $user->name }}? Quella attuale smette di funzionare.">Nuova password temporanea</x-btn>
|
||||
@if(isset($gruppiDeiSorveglianti[$user->proclamatore_id]))
|
||||
<x-btn data-ritorno variante="ghost" dimensione="sm" :href="route('gruppi.show', $gruppiDeiSorveglianti[$user->proclamatore_id])">Apri il gruppo</x-btn>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="flex gap-2">
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
|
||||
@unless($user->hasVerifiedEmail())
|
||||
@@ -145,6 +170,7 @@
|
||||
Elimina
|
||||
</x-btn>
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div>
|
||||
<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>
|
||||
Torna alla lista
|
||||
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $title }}</h1>
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@
|
||||
dell'applicazione. Prima cambiava da una pagina all'altra. --}}
|
||||
<div class="flex flex-wrap gap-3 pt-2">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
</div>
|
||||
@can('territori.manage')
|
||||
<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>
|
||||
Nuovo territorio
|
||||
</x-btn>
|
||||
@@ -42,6 +46,7 @@
|
||||
<select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">Tutti gli stati</option>
|
||||
<option value="in_reparto">In reparto</option>
|
||||
<option value="in_gruppo">In un gruppo di servizio</option>
|
||||
<option value="prioritari">Prioritari</option>
|
||||
<option value="assegnato">Assegnato</option>
|
||||
<option value="da_rientrare">Da rientrare</option>
|
||||
@@ -112,7 +117,7 @@
|
||||
alt="Thumbnail territorio {{ $territorio->numero }}"
|
||||
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none shadow-sm">
|
||||
@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>
|
||||
</td>
|
||||
<td data-label="Zona" class="px-4 py-3 text-sm text-gray-600">{{ $territorio->zona?->nome ?? '-' }}</td>
|
||||
@@ -122,12 +127,13 @@
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{{ match($stato) {
|
||||
'in_reparto' => 'bg-green-100 text-green-800',
|
||||
'in_gruppo' => 'bg-purple-100 text-purple-800',
|
||||
'assegnato' => 'bg-blue-100 text-blue-800',
|
||||
'da_rientrare' => 'bg-red-100 text-red-800',
|
||||
'inattivo' => 'bg-gray-100 text-gray-600',
|
||||
default => 'bg-gray-100 text-gray-600'
|
||||
} }}">
|
||||
{{ str_replace('_', ' ', ucfirst($stato)) }}
|
||||
{{ $territorio->stato_etichetta }}
|
||||
</span>
|
||||
@if($territorio->is_prioritario)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">
|
||||
@@ -143,7 +149,7 @@
|
||||
{{-- Primary actions: Assegna / Rientra --}}
|
||||
@can('territori.assign')
|
||||
@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>
|
||||
Assegna
|
||||
</x-btn>
|
||||
@@ -154,7 +160,7 @@
|
||||
{{-- Rientra non cancella niente: era rosso pieno
|
||||
quanto un Elimina. È l'azione principale
|
||||
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>
|
||||
Rientra
|
||||
</x-btn>
|
||||
@@ -164,13 +170,13 @@
|
||||
{{-- Solo icona: l'aria-label non è ridondante col
|
||||
title, è l'unico nome che un lettore di
|
||||
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)"
|
||||
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>
|
||||
</x-btn>
|
||||
@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)"
|
||||
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>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<div>
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<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>
|
||||
Torna alla lista
|
||||
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Territorio {{ $territorio->numero }}</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
@can('territori.assign')
|
||||
@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>
|
||||
Assegna
|
||||
</x-btn>
|
||||
@@ -21,14 +21,14 @@
|
||||
{{-- Rientra era rosso pieno quanto un Elimina. Non cancella
|
||||
niente: è l'azione principale di un territorio assegnato,
|
||||
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>
|
||||
Rientra
|
||||
</x-btn>
|
||||
@endif
|
||||
@endcan
|
||||
@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>
|
||||
Modifica
|
||||
</x-btn>
|
||||
@@ -36,6 +36,16 @@
|
||||
</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 --}}
|
||||
<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">
|
||||
@@ -45,12 +55,13 @@
|
||||
<span class="inline-flex items-center mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{{ match($stato) {
|
||||
'in_reparto' => 'bg-green-100 text-green-800',
|
||||
'in_gruppo' => 'bg-purple-100 text-purple-800',
|
||||
'assegnato' => 'bg-blue-100 text-blue-800',
|
||||
'da_rientrare' => 'bg-red-100 text-red-800',
|
||||
'inattivo' => 'bg-gray-100 text-gray-600',
|
||||
default => 'bg-gray-100 text-gray-600'
|
||||
} }}">
|
||||
{{ str_replace('_', ' ', ucfirst($stato)) }}
|
||||
{{ $territorio->stato_etichetta }}
|
||||
</span>
|
||||
@if($territorio->is_prioritario)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">★ Prioritario</span>
|
||||
@@ -99,7 +110,7 @@
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@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
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,33 +161,41 @@
|
||||
<h3 class="text-lg font-semibold text-gray-900">Storico Assegnazioni</h3>
|
||||
</div>
|
||||
|
||||
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni)
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4>
|
||||
{{-- Una tabella sola, colonne a larghezza fissa: con una tabella per
|
||||
anno ciascuna si dimensionava sul suo contenuto e le colonne non
|
||||
erano in linea. L'anno è una riga di separazione (tm-riga-gruppo).
|
||||
Ordine: dal più recente, per anno e per data di assegnazione. --}}
|
||||
@if($assegnazioniPerAnno->isEmpty())
|
||||
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
||||
<table class="min-w-full table-fixed text-sm table-cards">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
|
||||
<th class="w-[36%] px-3 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
|
||||
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
|
||||
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
|
||||
<th class="w-[9%] px-3 py-2 text-right text-xs font-medium text-gray-500">Giorni</th>
|
||||
<th class="w-[25%] px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@foreach($assegnazioniPerAnno as $annoLabel => $assegnazioni)
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr class="tm-riga-gruppo">
|
||||
<th colspan="5" scope="colgroup" class="px-3 pt-5 pb-2 text-left text-sm font-semibold text-indigo-600 bg-white">Anno Teocratico {{ $annoLabel }}</th>
|
||||
</tr>
|
||||
@foreach($assegnazioni as $assegnazione)
|
||||
<tr>
|
||||
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
||||
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Rientrato" class="px-3 py-2">
|
||||
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}@if($assegnazione->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo_nome }}</span>@endif</td>
|
||||
<td data-label="Assegnato" class="px-3 py-2 tabular-nums">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Rientrato" class="px-3 py-2 tabular-nums">
|
||||
@if($assegnazione->returned_at)
|
||||
{{ $assegnazione->returned_at->format('d/m/Y') }}
|
||||
@else
|
||||
<span class="text-amber-600 font-medium">In corso</span>
|
||||
@endif
|
||||
</td>
|
||||
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</td>
|
||||
<td data-label="Giorni" class="px-3 py-2 text-right tabular-nums">{{ $assegnazione->giorni }}</td>
|
||||
<td data-label="Campagna" class="px-3 py-2">
|
||||
@if($assegnazione->counted_in_campaign)
|
||||
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
|
||||
@@ -189,11 +208,9 @@
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
@endforeach
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
|
||||
@endforelse
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user