Compare commits
23 Commits
main
...
5fb42b05c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb42b05c7 | ||
|
|
7d479a2c34 | ||
|
|
f1f340803a | ||
|
|
6f88c65d73 | ||
|
|
ab7d26ae41 | ||
|
|
cd3569a327 | ||
|
|
eda3c3f989 | ||
|
|
699e2ef173 | ||
|
|
0ce5bd8c89 | ||
|
|
ce98165667 | ||
|
|
d2d6b1bdce | ||
|
|
ec2e7bd7e9 | ||
|
|
69250ecb8c | ||
|
|
aaffeb7de7 | ||
|
|
a93539242a | ||
|
|
7a54c15852 | ||
|
|
4d9e445cba | ||
|
|
9999a3b031 | ||
|
|
93bc9cf2fe | ||
|
|
6489fb627d | ||
|
|
f48d0e96c0 | ||
|
|
96084499f5 | ||
|
|
5d0ae06eca |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,7 +18,6 @@
|
|||||||
/.phpunit.cache/
|
/.phpunit.cache/
|
||||||
/.idea/
|
/.idea/
|
||||||
/.vscode/
|
/.vscode/
|
||||||
.claude
|
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*.sql
|
*.sql
|
||||||
|
|||||||
555
HANDOFF.md
555
HANDOFF.md
@@ -4,173 +4,214 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
|
|||||||
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
|
||||||
può dare: perché le cose sono come sono, e cosa resta aperto.
|
può dare: perché le cose sono come sono, e cosa resta aperto.
|
||||||
|
|
||||||
Ultimo aggiornamento: **21 ottobre 2026** (rilascio di sicurezza di PHP) — vedi [Sicurezza: PHP](#sicurezza-php-cve-2026-109618--cve-2026-109619-21-ottobre-2026).
|
Ultimo aggiornamento: **20 ottobre 2026** — si riprende da [Stato attuale](#stato-attuale).
|
||||||
|
|
||||||
> **Questo è l'HANDOFF di `main` (produzione).** Il 21/10/2026 in produzione
|
|
||||||
> è andata **solo la correzione di PHP**, scelta dell'utente: le funzioni nuove
|
|
||||||
> di `dev` (gruppi di servizio e lavori del 20 ottobre) restano sulla demo.
|
|
||||||
> Il quadro completo e aggiornato del progetto è nell'HANDOFF di `dev`, che
|
|
||||||
> sostituirà questo quando `dev` verrà portato in produzione.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026)
|
|
||||||
|
|
||||||
Due esecuzioni di codice da remoto nel core di PHP (CVSS v4 9.2), patch alle
|
|
||||||
14:00 UTC del 21/10/2026; versione corretta per il nostro ramo: **8.3.36**.
|
|
||||||
La produzione girava su 8.3.35. Rilasciati su `main` solo i due commit di
|
|
||||||
sicurezza di `dev` (cherry-pick):
|
|
||||||
|
|
||||||
- `docker/php/Dockerfile`: `ARG PHP_VERSION=8.3.36` e `FROM
|
|
||||||
php:${PHP_VERSION}-fpm`. Prima era `php:8.3-fpm`, un tag che si sposta ma
|
|
||||||
che Docker tiene in cache: una ricostruzione senza `--pull` (anche su
|
|
||||||
Dokploy) poteva riusare la base vulnerabile. Ora una build o prende la
|
|
||||||
8.3.36 o fallisce. Test `tests/Unit/VersionePhpTest.php` contro il ritorno a
|
|
||||||
un tag mobile o a una versione inferiore.
|
|
||||||
- phpredis da GitHub (`ARG PHPREDIS_VERSION=6.3.0`) invece di `pecl install`:
|
|
||||||
quel giorno pecl.php.net rispondeva 504 e la build falliva.
|
|
||||||
- `docker/php/php.ini`: `session.upload_progress.enabled = 0` e
|
|
||||||
`session.use_strict_mode = 1`, la mitigazione indicata da Sansec in attesa
|
|
||||||
della patch, da tenere anche dopo (l'applicazione usa le sessioni di
|
|
||||||
Laravel, in Redis).
|
|
||||||
- Solo per provare in locale prima che l'immagine sia su Docker Hub:
|
|
||||||
`PHP_VERSION=8.3.35 docker compose ...` (mai per un deploy).
|
|
||||||
|
|
||||||
**In produzione dal 21 ottobre 2026, ore 18:48** (16:48 UTC; `main` =
|
|
||||||
`1ddb973`). Su Docker Hub il tag `php:8.3.36-fpm` è comparso alle 15:38 UTC
|
|
||||||
ma vuoto: l'immagine per amd64 (il server) è arrivata alle 16:41 UTC, e prima
|
|
||||||
la build falliva con "no match for platform in manifest". Prima del deploy:
|
|
||||||
test di questa versione su PHP 8.3.36 (524 PHP + 13 JS verdi) e backup
|
|
||||||
`/etc/dokploy/termanager2-backups/prod-20261021-164333` (database, 78 MB di
|
|
||||||
`storage/app`, conteggi). Deploy lanciato a mano (l'autodeploy non è
|
|
||||||
partito), uno solo. Verifica: PHP 8.3.36, `session.upload_progress.enabled`
|
|
||||||
Off e `session.use_strict_mode` On dal `php.ini` dell'immagine, nessuna
|
|
||||||
migrazione in sospeso, conteggi identici al backup (274 territori, 1130
|
|
||||||
assegnazioni di cui 77 aperte, 111 proclamatori, 4 utenti, 1251 righe di log,
|
|
||||||
35 migrazioni), login 200, container sani, nessun errore nei log. La
|
|
||||||
mitigazione applicata a mano ai container la mattina (in `conf.d`) è
|
|
||||||
sparita con la ricostruzione, come previsto: vale il `php.ini`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stato attuale
|
## Stato attuale
|
||||||
|
|
||||||
L'applicazione è **pronta per il deploy in produzione su Dokploy**. Lo stack è
|
**Produzione** (`main` → compose `termanager2-core-production`) in esercizio
|
||||||
stato verificato end-to-end: build dell'immagine, primo deploy su volumi vuoti,
|
su Dokploy dal 19 ottobre 2026. Ultimo rilascio: `d3314a9` (proclamatori senza
|
||||||
redeploy, login, tutte le pagine, code e scheduler.
|
contatti, il link si copia o si condivide). Backup fatti prima dei rilasci in
|
||||||
|
`/etc/dokploy/termanager2-backups/` sul server.
|
||||||
|
|
||||||
|
**Demo** (`dev` → compose `termanager2-core-demo`,
|
||||||
|
`demo-termanager.pyconetwork.it`): avanti di una ventina di commit, tutti
|
||||||
|
provati con la suite sul server e pubblicati sulla demo. **Non ancora in
|
||||||
|
produzione** — l'utente ha chiesto di aspettare il suo ok:
|
||||||
|
|
||||||
|
- [gruppi di servizio](#gruppi-di-servizio-19-20-ottobre-2026-solo-su-dev):
|
||||||
|
sorvegliante con email fittizia, pacchetto di territori, "Il mio gruppo",
|
||||||
|
scioglimento; migrazioni `2026_10_20_000010` … `000040`;
|
||||||
|
- priorità e filtri nel pacchetto, con la regola di priorità della Home in un
|
||||||
|
metodo solo (`Territorio::ordinaPerPriorita()`);
|
||||||
|
- pulsante **Rientro** nell'elenco dei territori assegnati;
|
||||||
|
- storico assegnazioni in una tabella sola, con le colonne in linea;
|
||||||
|
- *Assegna* aperta da "Assegna →" della Home senza l'elenco dei territori
|
||||||
|
assegnati.
|
||||||
|
|
||||||
|
**Per portarli in produzione**: backup del database di produzione, `main`
|
||||||
|
avanti fino a `dev` (fast-forward), deploy (l'autodeploy non parte: vedi
|
||||||
|
[Dove gira davvero](#dove-gira-davvero)), poi controllo di migrazioni e
|
||||||
|
conteggi. In produzione non ci sono gruppi: le quattro migrazioni non hanno
|
||||||
|
dati da convertire.
|
||||||
|
|
||||||
| Aspetto | Stato |
|
| 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 |
|
| Primo avvio a zero-config | ✅ migrazioni + seed + admin automatici |
|
||||||
| Redeploy senza interventi | ✅ ~25 secondi |
|
| Redeploy senza interventi | ✅ ~25 secondi |
|
||||||
| Sviluppo locale | ✅ stesso compose, profilo `dev` |
|
| 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) |
|
| Ripristino da browser in linea | ⚠️ **limitato a 100 MB da Cloudflare** — vedi [Cosa resta aperto](#cosa-resta-aperto) |
|
||||||
| Test automatici | ⚠️ **impalcatura pronta, 294 test** — copertura ancora parziale |
|
| Test automatici | ✅ **577 test PHP + 13 JavaScript, verdi** (20/10/2026); copertura ancora parziale |
|
||||||
|
| Prove nel browser | ⚠️ le funzioni del 19-20 ottobre sono verificate dai test e sul server; nel browser le ha usate l'utente sulla demo |
|
||||||
|
|
||||||
**Punto in cui ci si è fermati (30/07/2026).** Su `develop` è pubblicato tutto:
|
La storia precedente (luglio-agosto: backup e ripristino, sotto-app Piantine,
|
||||||
backup completo `.zip`, ripristino, azzeramento, cambio password forzato, barre
|
sistema visivo, prima suite di test) è in [Da dove veniamo](#da-dove-veniamo).
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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):
|
Dal più recente. Le sezioni del 12-19 ottobre sono in produzione; quelle del
|
||||||
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
|
20 ottobre e i gruppi di servizio solo sulla demo.
|
||||||
`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.
|
|
||||||
|
|
||||||
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di
|
### Assegna aperta dalla Home: senza l'elenco dei territori assegnati (20 ottobre 2026)
|
||||||
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
|
|
||||||
|
|
||||||
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026)
|
Richiesta dell'utente: "quando provengo da un territorio dalla home, non
|
||||||
|
mostrare lo storico assegnazioni". Avevo capito la scheda del territorio e
|
||||||
|
ne avevo nascosto lo storico (commit `f1f3408`, poi annullato con un revert):
|
||||||
|
intendeva la pagina *Assegna* aperta da "Assegna →" della Home, e come
|
||||||
|
"storico" l'elenco "Territori Assegnati" sotto il modulo. Ora quel link porta
|
||||||
|
`?dalla_home=1` e `Assegna::$elencoNascosto` toglie `ElencoAssegnati` (che
|
||||||
|
così non si carica nemmeno). Dal menu o dalla scheda del territorio l'elenco
|
||||||
|
c'è come prima. Non si usa il `?da=` del ritorno per capirlo: si trasmette di
|
||||||
|
pagina in pagina.
|
||||||
|
|
||||||
Richieste dell'utente, con le sue scelte:
|
### Storico assegnazioni in linea (20 ottobre 2026)
|
||||||
|
|
||||||
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta
|
Richiesta dell'utente (screenshot della scheda territorio): le colonne dello
|
||||||
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura
|
storico cambiavano posizione da un anno all'altro, perché ogni anno era una
|
||||||
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante
|
tabella a sé dimensionata sul suo contenuto. Ora, nella scheda del territorio
|
||||||
"Invia email di prova". Se compilato prende il posto del `.env`
|
e in quella del proclamatore, è **una tabella sola** con un'intestazione,
|
||||||
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail
|
colonne a larghezza fissa (`table-fixed`, `w-[..%]`), e l'anno come riga di
|
||||||
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la
|
separazione `tr.tm-riga-gruppo` (in `app.css`, su telefono resta un titolo e
|
||||||
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era
|
non diventa una scheda). Ordine esplicito: anni dal più recente
|
||||||
un valore non valido per Laravel 11: ora vuoto.
|
(`sortKeysDesc`), dentro l'anno per data di assegnazione. Nella scheda del
|
||||||
- **Proclamatori: email e telefono facoltativi**, cifrati come nome e cognome,
|
proclamatore la colonna Campagna ora dice "Sì — …" / "No" come in quella del
|
||||||
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
|
territorio, e c'è il badge "via gruppo …". Test: `StoricoAssegnazioniTest`.
|
||||||
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
|
|
||||||
considera italiano).
|
|
||||||
- **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
|
|
||||||
dopo l'assegnazione" qui sotto*. Il riquadro in sessione
|
|
||||||
(`Assegna::DA_INVIARE`) non esiste più.
|
|
||||||
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
|
|
||||||
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`).
|
|
||||||
L'admin crea l'utente con la password come prima; parte l'email di conferma
|
|
||||||
(in italiano, `VerifyEmail::toMailUsing`); il link funziona anche senza
|
|
||||||
login. Cambiare email (profilo o admin) la rende da confermare e rimanda il
|
|
||||||
link. In Utenti: stato "da confermare" e "Reinvia conferma".
|
|
||||||
Raggiungibili senza conferma: profilo, cambio password, pagina di conferma
|
|
||||||
e — per l'amministratore — il server di posta.
|
|
||||||
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
|
|
||||||
creato da riga di comando nasce confermato.
|
|
||||||
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
|
|
||||||
`$confermaUtentiCreati`).
|
|
||||||
|
|
||||||
Provato nel browser in locale con mailpit come server configurato dall'app:
|
### Rientro dall'elenco dei territori assegnati (20 ottobre 2026)
|
||||||
email di prova e di conferma arrivate, link aperto senza login, account
|
|
||||||
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
|
Richiesta dell'utente ("un tasto che va alla registrazione", dallo screenshot
|
||||||
e dati di prova rimossi dopo.
|
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.
|
||||||
|
- **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.
|
||||||
|
- **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)
|
### Proclamatori senza contatti; il link si copia o si condivide (19 ottobre 2026)
|
||||||
|
|
||||||
@@ -271,15 +312,17 @@ Proclamatore Semplificato" che non esistono).
|
|||||||
non è HTTPS), i ruoli con i permessi veri (`Privacy::PERMESSI` li spiega) e
|
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
|
quanti dati sono conservati (contati in SQL: i campi cifrati vuoti restano
|
||||||
NULL, non serve decifrare). `Privacy::REVISIONE` è la data del testo.
|
NULL, non serve decifrare). `Privacy::REVISIONE` è la data del testo.
|
||||||
- Aggiunte: email/telefono cifrati, link alla piantina, invio via WhatsApp
|
- Aggiunte: link alla piantina, invio dal telefono di chi assegna, mappe
|
||||||
(parte dal telefono di chi assegna) ed email (server di posta configurato),
|
OpenStreetMap, conferma email, blocco dopo troppi tentativi. (Email e
|
||||||
mappe OpenStreetMap, conferma email, blocco dopo troppi tentativi.
|
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,
|
- Titolare e base giuridica sono rimasti com'erano: sono valutazioni legali,
|
||||||
da far rivedere a chi ne ha la competenza.
|
da far rivedere a chi ne ha la competenza.
|
||||||
- **Aperto — backup in chiaro**: il pacchetto di backup usa l'export XML, che
|
- **Aperto — backup in chiaro**: il pacchetto di backup usa l'export XML, che
|
||||||
legge i proclamatori dal modello e quindi esporta nome, cognome, email e
|
legge i proclamatori dal modello e quindi esporta nome e cognome
|
||||||
telefono **decifrati**. L'informativa ora lo dice; valutare se cifrare
|
**decifrati**. L'informativa lo dice; valutare se cifrare l'archivio (per
|
||||||
l'archivio (per esempio con una password scelta al download).
|
esempio con una password scelta al download).
|
||||||
- `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli,
|
- `PrivacyTest` verifica che i valori cambino con le impostazioni, i ruoli,
|
||||||
i conteggi senza nomi e l'accesso solo amministratore.
|
i conteggi senza nomi e l'accesso solo amministratore.
|
||||||
|
|
||||||
@@ -373,14 +416,14 @@ all'**elenco filtrato**, non alla scheda (scelta esplicita).
|
|||||||
Richieste dell'utente:
|
Richieste dell'utente:
|
||||||
|
|
||||||
- **Dopo l'assegnazione non si va avanti** finché non si è deciso del link: si
|
- **Dopo l'assegnazione non si va avanti** finché non si è deciso del link: si
|
||||||
apre una finestra che blocca la pagina (`Assegna::apriInvio`) con i canali
|
apre una finestra che blocca la pagina (oggi trait `Concerns\InviaLink`,
|
||||||
|
usato anche da "Il mio gruppo") con i canali. *Superato la sera stessa:
|
||||||
|
WhatsApp ed email sono stati tolti, restano Copia link e Condividi… — vedi
|
||||||
|
"Proclamatori senza contatti".* Com'era il primo giorno:
|
||||||
- *Copia link*: sempre;
|
- *Copia link*: sempre;
|
||||||
- *WhatsApp*: solo se il proclamatore ha un numero utilizzabile;
|
- *WhatsApp*: solo se il proclamatore ha un numero utilizzabile;
|
||||||
- *Email*: solo se ha un indirizzo **e** la posta funziona
|
- *Email*: solo se ha un indirizzo **e** la posta funziona
|
||||||
(`ConfigurazionePosta::funzionante`). La manda l'app con
|
(`ConfigurazionePosta::funzionante`), inviata subito e mai in coda.
|
||||||
`Notification::route('mail', …)->notifyNow(new LinkTerritorio)`: **mai in
|
|
||||||
coda**, perché il job salverebbe in chiaro su Redis/`failed_jobs`
|
|
||||||
l'indirizzo che nel database è cifrato.
|
|
||||||
|
|
||||||
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
|
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
|
||||||
possibile. Solo dopo la risposta si ricarica la pagina.
|
possibile. Solo dopo la risposta si ricarica la pagina.
|
||||||
@@ -392,17 +435,62 @@ Richieste dell'utente:
|
|||||||
"Non inviato / Link inviato" dell'elenco.
|
"Non inviato / Link inviato" dell'elenco.
|
||||||
- Colonne nuove (migrazione `2026_10_19_000010`): `link_da_inviare` (le
|
- Colonne nuove (migrazione `2026_10_19_000010`): `link_da_inviare` (le
|
||||||
assegnazioni già esistenti restano false: niente avvisi a posteriori su
|
assegnazioni già esistenti restano false: niente avvisi a posteriori su
|
||||||
link di cui non si sa niente) e `link_canale` (`copia`/`whatsapp`/`email`,
|
link di cui non si sa niente) e `link_canale` (oggi `copia`/`condividi`,
|
||||||
`Assegnazione::CANALI_LINK`). Entrambe nell'XML.
|
`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`:
|
- Nel log `link_inviato` / `link_non_inviato` con canale e `proclamatore_id`:
|
||||||
mai nome, numero o email. Email e telefono restano solo, cifrati, nel
|
mai nomi.
|
||||||
proclamatore.
|
|
||||||
- **"Nuovo territorio"** (elenco territori) apre il designer delle piantine
|
- **"Nuovo territorio"** (elenco territori) apre il designer delle piantine
|
||||||
con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in
|
con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in
|
||||||
disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto.
|
disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto.
|
||||||
L'editor è solo dell'amministratore: chi ha `territori.manage` ma non
|
L'editor è solo dell'amministratore: chi ha `territori.manage` ma non
|
||||||
`settings.manage` resta sul form `territori.create`, che esiste ancora.
|
`settings.manage` resta sul form `territori.create`, che esiste ancora.
|
||||||
|
|
||||||
|
### Posta, contatti dei proclamatori, conferma email (12-13 ottobre 2026)
|
||||||
|
|
||||||
|
Richieste dell'utente, con le sue scelte:
|
||||||
|
|
||||||
|
- **Server di posta dall'app** (Impostazioni → Server di posta, rotta
|
||||||
|
`posta.edit`, `Settings\PostaElettronica`): host, porta, cifratura
|
||||||
|
(automatica / SSL / nessuna), utente, password cifrata, mittente; pulsante
|
||||||
|
"Invia email di prova". Se compilato prende il posto del `.env`
|
||||||
|
(`ConfigurazionePosta::applica()`, agganciata alla creazione del mail
|
||||||
|
manager e a ogni job della coda). Non si esporta nell'XML e l'import non la
|
||||||
|
cancella (`Setting::CAMPI_POSTA`). Nel `.env.example` `MAIL_SCHEME=tls` era
|
||||||
|
un valore non valido per Laravel 11: ora vuoto.
|
||||||
|
- ~~Proclamatori: email e telefono facoltativi~~ — *tolti il 19 ottobre 2026,
|
||||||
|
vedi "Proclamatori senza contatti" qui sotto*.
|
||||||
|
- **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
|
||||||
|
dopo l'assegnazione" qui sotto*. Il riquadro in sessione
|
||||||
|
(`Assegna::DA_INVIARE`) non esiste più.
|
||||||
|
- **Conferma email obbligatoria** per tutti gli utenti (tranne il sorvegliante
|
||||||
|
di gruppo), anche quelli già esistenti (la migrazione `2026_10_13_000010`
|
||||||
|
azzera `email_verified_at`). L'admin crea l'utente con la password come
|
||||||
|
prima; parte l'email di conferma (in italiano, `VerifyEmail::toMailUsing`);
|
||||||
|
il link funziona anche senza login. Cambiare email (profilo o admin) la
|
||||||
|
rende da confermare e rimanda il link. In Utenti: stato "da confermare" e
|
||||||
|
"Reinvia conferma". Raggiungibili senza conferma: profilo, cambio password,
|
||||||
|
pagina di conferma e — per l'amministratore — il server di posta.
|
||||||
|
Emergenza: `php artisan utenti:conferma-email <email>`. Il primo admin
|
||||||
|
creato da riga di comando nasce confermato.
|
||||||
|
- Nei test gli utenti nascono confermati (`tests/TestCase.php`,
|
||||||
|
`$confermaUtentiCreati`).
|
||||||
|
|
||||||
|
Provato nel browser in locale con mailpit come server configurato dall'app:
|
||||||
|
email di prova e di conferma arrivate, link aperto senza login, account
|
||||||
|
sbloccato. Configurazione e dati di prova rimossi dopo.
|
||||||
|
|
||||||
|
### Modulo S-13, editor piantine, revisione (12 ottobre 2026)
|
||||||
|
|
||||||
|
In produzione dal 19 ottobre 2026 (erano su `dev` dal 12):
|
||||||
|
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
|
||||||
|
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione
|
||||||
|
(integrità dati).
|
||||||
|
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
|
||||||
|
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
|
||||||
|
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||||||
|
- `426a952` — blocchi 2 e 3 della revisione (sotto).
|
||||||
|
|
||||||
### Revisione profonda: blocchi 2 e 3 chiusi
|
### Revisione profonda: blocchi 2 e 3 chiusi
|
||||||
|
|
||||||
| Problema | Correzione |
|
| Problema | Correzione |
|
||||||
@@ -457,20 +545,92 @@ a ogni deploy: lento, dipendente dalla rete, e capace di fallire in produzione.
|
|||||||
|
|
||||||
Da qui la ristrutturazione descritta sotto.
|
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
|
## Dove gira davvero
|
||||||
|
|
||||||
Due ambienti su Dokploy, stesso progetto `TerManager2`, entrambi alimentati dal
|
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`) |
|
| Ambiente | Branch | Dominio | Compose (nome / `appName` / `composeId`) |
|
||||||
|--------------|--------|-------------------------------------|----------------------------|
|
|--------------|--------|-------------------------------------|------------------------------------------|
|
||||||
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-vizcxr` |
|
| `production` | `main` | `termanager2.pyconetwork.it` | `termanager2-core-production` / `termanager2-core-vizcxr` / `vh_NtgaPyfQintCbcB3p6` |
|
||||||
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-vbekis` |
|
| `develop` | `dev` | `demo-termanager.pyconetwork.it` | `termanager2-core-demo` / `termanager2-core-vbekis` / `20DoOKciTzmhlPWdcqcxz` |
|
||||||
|
|
||||||
Conseguenze pratiche, tutte imparate sul campo:
|
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,
|
- **Il branch decide l'ambiente.** Pubblicare su `dev` non tocca la produzione,
|
||||||
che segue `main`. È la separazione su cui contare quando si vuole provare
|
che segue `main`. È la separazione su cui contare quando si vuole provare
|
||||||
qualcosa senza rischi.
|
qualcosa senza rischi.
|
||||||
@@ -1445,7 +1605,9 @@ blocchi che impedivano di scrivere il primo test sono stati rimossi: stage
|
|||||||
docker compose --profile test run --build --rm test
|
docker compose --profile test run --build --rm test
|
||||||
```
|
```
|
||||||
|
|
||||||
**Coperto oggi — 294 test, tutti verdi:**
|
**Coperto oggi — 577 test PHP e 13 JavaScript, tutti verdi (20/10/2026).**
|
||||||
|
Senza Docker in locale si fanno girare sul server: vedi [Dove gira
|
||||||
|
davvero](#dove-gira-davvero). I file storici, con cosa proteggono:
|
||||||
|
|
||||||
| File | Cosa protegge |
|
| File | Cosa protegge |
|
||||||
|------|---------------|
|
|------|---------------|
|
||||||
@@ -1462,6 +1624,21 @@ docker compose --profile test run --build --rm test
|
|||||||
| `tests/Unit/PiantinaRendererTest.php` | Il client del renderer: formato, qualita' e lato massimo davvero spediti, JPEG salvato con l'estensione giusta, il file di un formato precedente rimosso invece che lasciato orfano, errori e risposte non-immagine trasformati in messaggi leggibili, geometria corrotta bloccata prima della chiamata |
|
| `tests/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 |
|
| `tests/Feature/HomeMediaDurataTest.php` | La "media percorrenza congregazione": unità di misura, esclusione delle assegnazioni aperte, dei rientri nello stesso giorno, delle date impossibili ereditate dalla vecchia applicazione e degli altri anni di servizio |
|
||||||
|
|
||||||
|
Arrivati dopo (ottobre 2026), uno per funzione: `GruppiServizioTest` (gruppi
|
||||||
|
di servizio, compresa la rotta vera delle azioni Livewire del sorvegliante,
|
||||||
|
priorità e filtri), `InvioLinkTest` (invio del link, pulsante Rientro, Assegna
|
||||||
|
dalla Home), `StoricoAssegnazioniTest`, `SceltaRicercaTest` e
|
||||||
|
`tests/js/scelta.test.mjs` (scelta con ricerca), `RitornoTest`,
|
||||||
|
`AssegnaPrestazioniTest` (peso della pagina Assegna e delle righe
|
||||||
|
dell'elenco), `AssegnazioneSovrapposizioneTest`, `CampagnaCalcoliTest`,
|
||||||
|
`CambioPasswordTest`, `ConfermaEmailTest`, `PostaElettronicaTest`,
|
||||||
|
`PrivacyTest`, `LogSenzaNomiTest`, `AvvisiTest`, `CodaTest`,
|
||||||
|
`UltimoAmministratoreTest`, i test di import (`XmlExchangeImportTest`,
|
||||||
|
`ImportXmlFileOrfaniTest`, `ImportPdfTerritoriTest`, `PostoUnicoImportTest`,
|
||||||
|
`RipristinoBackupTest`) e dei file (`TerritorioFileTest`,
|
||||||
|
`TerritoriFileOrfaniTest`, `PubblicazionePiantinaTest`,
|
||||||
|
`AzzeramentoPiantineTest`, `PiantineAreaTest`).
|
||||||
|
|
||||||
Il primo giro ha già trovato tre difetti reali, tutti corretti: il warning di
|
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()`
|
`ZipArchive::addFile()` che copriva il messaggio d'errore vero, il `GREATEST()`
|
||||||
in migrazione, e `@disabled` dentro un tag di componente (vedi *Bug corretti*).
|
in migrazione, e `@disabled` dentro un tag di componente (vedi *Bug corretti*).
|
||||||
@@ -1476,22 +1653,25 @@ nel browser, e nella suite non c'è un browser. Quella parte va provata a mano.
|
|||||||
entry, dimensione della singola entry, totale decompresso. Sono tre `if` che
|
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.
|
nessun test tocca, e il caso in cui contano non si presenta mai per sbaglio.
|
||||||
- `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**:
|
- `Jobs/RestoreBackupArchive` e `Jobs/ResetApplication` **nel loro insieme**:
|
||||||
l'ordine staging → database → file, il `failed()` che libera il posto, la
|
in parte coperti (`RipristinoBackupTest`, `ZonaPericolosaTest`,
|
||||||
sopravvivenza dell'amministratore che ha chiesto l'azzeramento, il
|
`AzzeramentoPiantineTest`, l'azzeramento in `GruppiServizioTest`); restano
|
||||||
`password_change_required` acceso su tutte le righe. È la logica il cui
|
da verificare l'ordine staging → database → file e il `failed()` che libera
|
||||||
errore si scopre solo quando è troppo tardi.
|
il posto. È la logica il cui errore si scopre solo quando è troppo tardi.
|
||||||
- `Livewire/Settings/XmlExchange`: il **round-trip** export → import senza
|
- `Livewire/Settings/XmlExchange`: il round-trip export → import è coperto per
|
||||||
perdite, che è il presidio contro il tipo di bug già visto con `legacy_id`.
|
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
|
- Le regole di business: anno teocratico (`AnnoTeocratico::perData` a cavallo
|
||||||
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`,
|
del 1º settembre), `Territorio::stato` e la soglia `giorni_per_smarrito`.
|
||||||
priorità automatica, percentuale di percorrenza delle campagne.
|
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
|
- Il permesso come contratto: che un `assistente` **non** possa modificare o
|
||||||
eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È
|
eliminare un'assegnazione, una campagna, un territorio o un proclamatore. È
|
||||||
scritto in tre posti (rotta, componente, vista) e un test di richiesta lo
|
scritto in tre posti (rotta, componente, vista) e un test di richiesta lo
|
||||||
verificherebbe tutto insieme.
|
verificherebbe tutto insieme.
|
||||||
|
|
||||||
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: la dashboard
|
Attenzione a non scrivere test su ciò che SQLite non sa eseguire: resta solo
|
||||||
(`Home`) e l'ordinamento di `Registro::render()` usano SQL specifico di MySQL.
|
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
|
### 2. Race su doppia assegnazione — chiusa il 09/10/2026
|
||||||
|
|
||||||
@@ -1558,6 +1738,18 @@ e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e
|
|||||||
la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina
|
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.
|
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
|
## Trappole da conoscere
|
||||||
@@ -1601,6 +1793,14 @@ stringa invece di proteggere l'apostrofo, perché fra il file e l'attributo non
|
|||||||
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
|
c'è alcun livello di escape. Regole: nessuna virgoletta doppia in quel blocco,
|
||||||
da nessuna parte; apostrofi italiani solo dentro backtick.
|
da nessuna parte; apostrofi italiani solo dentro backtick.
|
||||||
|
|
||||||
|
**In una vista Blade la forma in linea `@php(...)` non va messa prima di un
|
||||||
|
blocco `@php … @endphp`.** Blade estrae i blocchi `@php … @endphp` per primi,
|
||||||
|
con un'espressione che parte dalla prima `@php` che trova: se è quella in
|
||||||
|
linea, ingoia tutto fino al primo `@endphp` e la vista non compila più
|
||||||
|
("unexpected token class"). Successo in *Il mio gruppo* il 19/10/2026. E
|
||||||
|
vale anche dentro i commenti `{{-- --}}`, che Blade toglie *dopo*: lì la
|
||||||
|
parola `@php` non va scritta.
|
||||||
|
|
||||||
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
|
Un blocco `<script>` con `@script` eviterebbe la questione alla radice, ma
|
||||||
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
|
**rompe il rilevamento della radice singola di Livewire** e restituisce 500 —
|
||||||
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.
|
provato. Il file porta l'avvertenza in testa; leggerla prima di intervenire.
|
||||||
@@ -1644,7 +1844,8 @@ cp .env.example .env
|
|||||||
# SESSION_SECURE_COOKIE=false, e le tre password
|
# SESSION_SECURE_COOKIE=false, e le tre password
|
||||||
docker network create dokploy-network
|
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
|
docker compose --profile test run --build --rm test
|
||||||
|
|
||||||
# 1. L'immagine builda
|
# 1. L'immagine builda
|
||||||
|
|||||||
200
README.md
200
README.md
@@ -1,8 +1,9 @@
|
|||||||
# TerManager2
|
# TerManager2
|
||||||
|
|
||||||
Applicazione web per la gestione dell'assegnazione e del rientro dei territori
|
Applicazione web per la gestione dell'assegnazione e del rientro dei territori
|
||||||
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali
|
(cartoline) di congregazione, con statistiche di percorrenza, campagne speciali,
|
||||||
e registro storico completo.
|
gruppi di servizio (un sorvegliante distribuisce ai membri un pacchetto di
|
||||||
|
territori) e registro storico completo.
|
||||||
|
|
||||||
I dati personali dei proclamatori sono **cifrati nel database**, e ogni
|
I dati personali dei proclamatori sono **cifrati nel database**, e ogni
|
||||||
operazione è tracciata in un **audit log** consultabile.
|
operazione è tracciata in un **audit log** consultabile.
|
||||||
@@ -410,24 +411,27 @@ Redis, quindi puntarli su un driver diverso interrompe l'elaborazione dei job.
|
|||||||
Configurabili dall'interfaccia (**Impostazioni**, permesso `settings.manage`),
|
Configurabili dall'interfaccia (**Impostazioni**, permesso `settings.manage`),
|
||||||
non da variabili d'ambiente:
|
non da variabili d'ambiente:
|
||||||
|
|
||||||
| Impostazione | Descrizione |
|
| Impostazione | Descrizione |
|
||||||
|--------------------------------|------------------------------------------------------|
|
|------------------------------------|------------------------------------------------------|
|
||||||
| Nome congregazione | Mostrato nell'intestazione e nei PDF |
|
| Nome congregazione | Mostrato nell'intestazione, nei PDF e nell'informativa |
|
||||||
| Logo | Immagine nell'intestazione |
|
| Soglia da assegnare (giorni) | Dopo quanti giorni dal rientro un territorio è "da assegnare" |
|
||||||
| URL pubblico base | Base dei link PDF condivisibili |
|
| Soglia priorità (giorni) | Dopo quanti giorni dal rientro un territorio diventa prioritario |
|
||||||
| Giorni giacenza "da assegnare" | Soglia oltre cui un territorio è da assegnare |
|
| Soglia rientro (giorni) | Oltre questa un'assegnazione è "da rientrare"; è anche la durata del link alla piantina |
|
||||||
| Mesi giacenza "prioritari" | Soglia oltre cui un territorio diventa prioritario |
|
| Limite liste Home | Quanti territori mostrare nelle due liste della Home |
|
||||||
| Giorni per "smarrito" | Soglia oltre cui un'assegnazione è da rientrare |
|
| "Scarica PDF" nel viewer | Mostra o nasconde il pulsante di download a chi apre il link |
|
||||||
| Elementi nelle liste della home| Quanti record mostrare nelle tre liste rapide |
|
| Conservazione audit (giorni) | Dopo quanti giorni le voci del registro delle attività si cancellano |
|
||||||
| Validità link PDF (mesi) | Scadenza dei link pubblici alle cartoline |
|
|
||||||
| Download PDF visibile | Mostra o nasconde il pulsante di download |
|
Il server di posta ha una pagina sua (*Impostazioni → Server di posta*). Il
|
||||||
| Retention audit log (giorni) | Dopo quanti giorni i log vengono cancellati |
|
logo nell'intestazione, se c'è, arriva da un backup: la pagina non lo carica.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Aggiornamenti
|
## 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`
|
1. ricrea le directory di storage e risolve `APP_KEY`
|
||||||
2. attende il database
|
2. attende il database
|
||||||
@@ -687,9 +691,11 @@ docker compose --profile dev up -d --build
|
|||||||
- Anno teocratico corrente (creato automaticamente, settembre → agosto)
|
- Anno teocratico corrente (creato automaticamente, settembre → agosto)
|
||||||
- Media mensile di percorrenza e durata media delle assegnazioni
|
- Media mensile di percorrenza e durata media delle assegnazioni
|
||||||
- Campagna attiva con barra di progresso
|
- Campagna attiva con barra di progresso
|
||||||
- Tre liste rapide con azione diretta: **da assegnare**, **prioritari**,
|
- Due liste rapide con azione diretta: **da assegnare** (prima i prioritari,
|
||||||
**da rientrare**; quando l'elenco è troncato, **"Vedi tutti"** è un pulsante
|
con il badge ★, poi quelli fermi da più tempo) e **da rientrare**; quando
|
||||||
in testata che apre l'elenco territori già filtrato
|
l'elenco è troncato, **"Vedi tutti"** è un pulsante in testata che apre
|
||||||
|
l'elenco territori già filtrato. "Assegna →" apre la pagina *Assegna* con
|
||||||
|
il territorio scelto e senza l'elenco dei territori assegnati
|
||||||
|
|
||||||
### Territori
|
### Territori
|
||||||
- CRUD completo con upload PDF e miniatura generata automaticamente
|
- CRUD completo con upload PDF e miniatura generata automaticamente
|
||||||
@@ -698,7 +704,9 @@ docker compose --profile dev up -d --build
|
|||||||
il form)
|
il form)
|
||||||
- Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina
|
- Filtri per zona, tipologia e stato, conservati nell'indirizzo della pagina
|
||||||
- Badge di stato colorati e indicatore di priorità (manuale o automatica)
|
- Badge di stato colorati e indicatore di priorità (manuale o automatica)
|
||||||
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico
|
- Dettaglio con visualizzatore PDF e storico assegnazioni per anno teocratico:
|
||||||
|
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
|
- Link PDF pubblico condivisibile, a scadenza configurabile
|
||||||
- Soft delete con cestino (ripristino o eliminazione definitiva)
|
- Soft delete con cestino (ripristino o eliminazione definitiva)
|
||||||
- Import massivo di PDF da cartella o archivio ZIP, elaborato in background
|
- Import massivo di PDF da cartella o archivio ZIP, elaborato in background
|
||||||
@@ -728,7 +736,9 @@ docker compose --profile dev up -d --build
|
|||||||
"Invia link" per riprendere. Anche dall'elenco dei territori assegnati si
|
"Invia link" per riprendere. Anche dall'elenco dei territori assegnati si
|
||||||
può copiare o condividere il link
|
può copiare o condividere il link
|
||||||
- L'elenco dei territori assegnati si carica subito dopo la pagina e si filtra
|
- L'elenco dei territori assegnati si carica subito dopo la pagina e si filtra
|
||||||
nel browser, senza attese
|
nel browser, senza attese; ogni territorio ha il pulsante **Rientro**, che
|
||||||
|
porta alla registrazione del rientro e poi torna all'elenco. Aperta da
|
||||||
|
"Assegna →" della Home, la pagina mostra solo il modulo, senza l'elenco
|
||||||
- Rientro con calcolo automatico dei giorni
|
- Rientro con calcolo automatico dei giorni
|
||||||
- **Prompt campagna**: se il territorio era fuori durante una campagna viene
|
- **Prompt campagna**: se il territorio era fuori durante una campagna viene
|
||||||
chiesto se è stato percorso per la campagna; oltre la soglia "da rientrare"
|
chiesto se è stato percorso per la campagna; oltre la soglia "da rientrare"
|
||||||
@@ -736,6 +746,62 @@ docker compose --profile dev up -d --build
|
|||||||
- **Dopo una conferma o un annulla si torna alla pagina di partenza**, con i
|
- **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)
|
suoi filtri, ricerca, ordinamento e pagina (aperta dal menu: come prima)
|
||||||
|
|
||||||
|
### Gruppi di servizio
|
||||||
|
- Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome,
|
||||||
|
**sorvegliante** (un proclamatore attivo), **membri** e **pacchetto** di
|
||||||
|
territori. Un proclamatore sta in un solo gruppo; il sorvegliante ne è
|
||||||
|
anche membro
|
||||||
|
- Creando il gruppo il sorvegliante riceve un'**utenza** con un'**email
|
||||||
|
fittizia** creata dall'applicazione: iniziale del nome e cognome sul
|
||||||
|
dominio `termanager.it` (Francesco Fontana → `ffontana@termanager.it`; se
|
||||||
|
è già presa, un numero in coda), e una password temporanea. Si vedono una
|
||||||
|
volta sola; la password va cambiata al primo accesso (la pagina dice
|
||||||
|
"Password temporanea", non "Ripristino da backup"). L'email non si
|
||||||
|
chiede, non riceve posta e non va confermata. Due gruppi non possono avere
|
||||||
|
lo stesso nome. Se perde la password, l'amministratore ne genera un'altra
|
||||||
|
dalla pagina del gruppo
|
||||||
|
- I territori del pacchetto si aggiungono in blocco tra quelli in reparto,
|
||||||
|
**in ordine di priorità** come "Da assegnare" nella Home (prima i
|
||||||
|
prioritari, poi quelli fermi da più tempo), con ricerca, filtri per zona,
|
||||||
|
tipologia e "solo prioritari", e "Seleziona tutti quelli mostrati".
|
||||||
|
Restano **"In gruppo …"**: non sono un'assegnazione (statistiche e medie non
|
||||||
|
cambiano), escono da "Da assegnare" e dalla pagina *Assegna*
|
||||||
|
- Il sorvegliante entra e vede **solo "Il mio gruppo"** e il proprio profilo
|
||||||
|
(ogni altra pagina risponde 403): dà i territori ai membri — o a sé — e ne
|
||||||
|
nasce un'**assegnazione normale**, che conta come le altre ed è segnata
|
||||||
|
"via gruppo …"; poi manda il link (copia o condividi), registra i rientri,
|
||||||
|
apre la piantina e rimanda in reparto quelli non ancora dati. Il suo
|
||||||
|
pacchetto è in ordine di priorità (prima i da dare, prioritari in testa,
|
||||||
|
poi quelli già dati) e si filtra per stato, zona, tipologia e "solo
|
||||||
|
prioritari"; sotto il nome del gruppo compaiono nome e cognome del
|
||||||
|
sorvegliante
|
||||||
|
- Al **rientro** il territorio torna in reparto ed esce dal pacchetto
|
||||||
|
- **Sciogliere il gruppo** toglie il ruolo al sorvegliante 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
|
### Campagne
|
||||||
- CRUD con date di inizio e fine
|
- CRUD con date di inizio e fine
|
||||||
- Stato automatico: attiva, futura o conclusa
|
- Stato automatico: attiva, futura o conclusa
|
||||||
@@ -771,24 +837,31 @@ docker compose --profile dev up -d --build
|
|||||||
|
|
||||||
## Ruoli e permessi
|
## Ruoli e permessi
|
||||||
|
|
||||||
Tre ruoli con permessi granulari (spatie/laravel-permission):
|
Quattro ruoli con permessi granulari (spatie/laravel-permission):
|
||||||
|
|
||||||
| Permesso | Amministratore | Assistente | Operatore |
|
| Permesso | Amministratore | Assistente | Operatore | Sorvegliante |
|
||||||
|-----------------------|:--------------:|:----------:|:---------:|
|
|-----------------------|:--------------:|:----------:|:---------:|:------------:|
|
||||||
| `settings.manage` | ✅ | | |
|
| `settings.manage` | ✅ | | | |
|
||||||
| `territori.manage` | ✅ | | |
|
| `territori.manage` | ✅ | | | |
|
||||||
| `proclamatori.manage` | ✅ | ✅ | |
|
| `proclamatori.manage` | ✅ | ✅ | | |
|
||||||
| `campagne.manage` | ✅ | ✅ | |
|
| `campagne.manage` | ✅ | ✅ | | |
|
||||||
| `registro.view` | ✅ | ✅ | |
|
| `registro.view` | ✅ | ✅ | | |
|
||||||
| `registro.export` | ✅ | | |
|
| `registro.export` | ✅ | | | |
|
||||||
| `territori.assign` | ✅ | ✅ | ✅ |
|
| `territori.assign` | ✅ | ✅ | ✅ | |
|
||||||
| `territori.return` | ✅ | ✅ | ✅ |
|
| `territori.return` | ✅ | ✅ | ✅ | |
|
||||||
| `audit.view` | ✅ | | |
|
| `audit.view` | ✅ | | | |
|
||||||
| `audit.export` | ✅ | | |
|
| `audit.export` | ✅ | | | |
|
||||||
|
| `gruppo.gestisci` | | | | ✅ |
|
||||||
|
|
||||||
L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
|
L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
|
||||||
Il menu laterale mostra solo le voci per cui l'utente ha i permessi.
|
Il menu laterale mostra solo le voci per cui l'utente ha i permessi.
|
||||||
|
|
||||||
|
Il ruolo **sorvegliante** non si assegna dalla pagina *Utenti*: nasce creando
|
||||||
|
un gruppo di servizio e sparisce sciogliendolo. Chi ha solo quel ruolo è
|
||||||
|
confinato da `LimitaSorvegliante` alla pagina del proprio gruppo, al rientro
|
||||||
|
dei territori del gruppo e al profilo; le sue azioni ricontrollano che
|
||||||
|
territorio e assegnazione siano del suo gruppo.
|
||||||
|
|
||||||
I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi
|
I permessi sono verificati sia sulle rotte sia sulle richieste Livewire, quindi
|
||||||
una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
||||||
|
|
||||||
@@ -812,13 +885,6 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
|
- **Sessioni**: in Redis, cookie httpOnly e `Secure` in produzione
|
||||||
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
|
- **Esecuzione PHP**: solo `index.php` è un entrypoint valido; ogni altro `.php`
|
||||||
restituisce 404
|
restituisce 404
|
||||||
- **Versione di PHP fissata alla patch** (`ARG PHP_VERSION` nel Dockerfile,
|
|
||||||
oggi 8.3.36 per CVE-2026-109618/109619): una build o prende quella versione
|
|
||||||
o fallisce, mai una base vecchia rimasta in cache. Va alzata a ogni rilascio
|
|
||||||
di sicurezza del ramo 8.3
|
|
||||||
- **Sessioni native di PHP chiuse**: `session.upload_progress.enabled = 0` e
|
|
||||||
`session.use_strict_mode = 1` in `php.ini` (l'applicazione usa le sessioni
|
|
||||||
di Laravel; è la mitigazione indicata per le stesse CVE)
|
|
||||||
|
|
||||||
### GDPR
|
### GDPR
|
||||||
- **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
|
- **Informativa** (menu *Privacy*, solo amministratore): i valori che possono
|
||||||
@@ -834,7 +900,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
||||||
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
||||||
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
||||||
in chiaro
|
in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome
|
||||||
|
di un proclamatore)
|
||||||
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
|
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
|
||||||
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
|
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
|
||||||
in un luogo protetto e cancellati quando non servono
|
in un luogo protetto e cancellati quando non servono
|
||||||
@@ -845,15 +912,16 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
|
|
||||||
| Tabella | Descrizione |
|
| Tabella | Descrizione |
|
||||||
|-------------------|--------------------------------------------------------------|
|
|-------------------|--------------------------------------------------------------|
|
||||||
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova |
|
| `users` | Utenti; `password_change_required` blocca l'accesso finché non si sceglie una password nuova (`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 |
|
| `settings` | Configurazione singleton |
|
||||||
| `zone` | Zone territoriali |
|
| `zone` | Zone territoriali |
|
||||||
| `tipologie` | Tipologie di territorio |
|
| `tipologie` | Tipologie di territorio |
|
||||||
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti) |
|
| `proclamatori` | Assegnatari, con nome/cognome cifrati e soft delete (niente contatti); `gruppo_servizio_id` è il gruppo di cui fanno parte |
|
||||||
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete |
|
| `gruppi_servizio` | Gruppi di servizio 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 |
|
| `anni_teocratici` | Periodi settembre → agosto |
|
||||||
| `campagne` | Periodi speciali con date e descrizione |
|
| `campagne` | Periodi speciali con date e descrizione |
|
||||||
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`) |
|
| `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo ancora attivo, `gruppo_nome` che resta anche dopo lo scioglimento |
|
||||||
| `activity_log` | Audit trail |
|
| `activity_log` | Audit trail |
|
||||||
|
|
||||||
**Vincoli e indici principali**
|
**Vincoli e indici principali**
|
||||||
@@ -880,16 +948,23 @@ Calcolato dinamicamente, non memorizzato:
|
|||||||
|
|
||||||
| Stato | Condizione |
|
| Stato | Condizione |
|
||||||
|----------------|-----------------------------------------------------|
|
|----------------|-----------------------------------------------------|
|
||||||
| `in_reparto` | Attivo, nessuna assegnazione aperta |
|
| `in_reparto` | Attivo, nessuna assegnazione aperta, fuori dai pacchetti dei gruppi |
|
||||||
|
| `in_gruppo` | Nel pacchetto di un gruppo di servizio, non ancora dato a un membro |
|
||||||
| `assegnato` | Ha un'assegnazione con `returned_at` nullo |
|
| `assegnato` | Ha un'assegnazione con `returned_at` nullo |
|
||||||
| `da_rientrare` | Assegnato da più giorni della soglia configurata |
|
| `da_rientrare` | Assegnato da più giorni della soglia configurata |
|
||||||
| `inattivo` | Contrassegnato come non attivo |
|
| `inattivo` | Contrassegnato come non attivo |
|
||||||
|
|
||||||
### Priorità
|
### Priorità
|
||||||
Un territorio è prioritario se lo è per **flag manuale** o perché non viene
|
Un territorio è prioritario se lo è per **flag manuale** o perché è fermo
|
||||||
assegnato da più mesi della soglia configurata. La soglia automatica **vince
|
(dall'ultimo rientro, o dalla creazione se mai assegnato) da più giorni della
|
||||||
sempre** sul flag: l'amministratore può aggiungere priorità, non toglierla
|
soglia configurata. La soglia automatica **vince sempre** sul flag:
|
||||||
quando la soglia è scattata.
|
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
|
### Percentuale di percorrenza delle campagne
|
||||||
- **Denominatore**: tutti i territori attivi
|
- **Denominatore**: tutti i territori attivi
|
||||||
@@ -904,6 +979,15 @@ lo decide l'operatore con la spunta. Se più campagne corrispondono viene
|
|||||||
proposta la più recente. I territori assegnati durante la campagna e non
|
proposta la più recente. I territori assegnati durante la campagna e non
|
||||||
ancora rientrati compaiono nel dettaglio come "in corso".
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architettura
|
## Architettura
|
||||||
@@ -913,7 +997,7 @@ ancora rientrati compaiono nel dettaglio come "in corso".
|
|||||||
| Componente | Tecnologia |
|
| Componente | Tecnologia |
|
||||||
|-------------------|--------------------------------------------|
|
|-------------------|--------------------------------------------|
|
||||||
| Framework | Laravel 11 |
|
| Framework | Laravel 11 |
|
||||||
| PHP | 8.3 (FPM), versione fissata nel Dockerfile |
|
| PHP | 8.3 (FPM) |
|
||||||
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
|
| Interfaccia | Livewire 3.5 + TailwindCSS 4 + Alpine.js |
|
||||||
| Database | MariaDB 11 |
|
| Database | MariaDB 11 |
|
||||||
| Cache/Sessioni/Code| Redis 7 |
|
| Cache/Sessioni/Code| Redis 7 |
|
||||||
@@ -928,14 +1012,16 @@ ancora rientrati compaiono nel dettaglio come "in corso".
|
|||||||
```
|
```
|
||||||
TerManager2/
|
TerManager2/
|
||||||
├── app/
|
├── 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/Controllers/ # PDF pubblici, import, backup, login, cambio password
|
||||||
│ ├── Http/Middleware/ # RequirePasswordChange
|
│ ├── Http/Middleware/ # RequirePasswordChange, LimitaSorvegliante
|
||||||
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
|
│ ├── Jobs/ # Import PDF, backup, ripristino, azzeramento (in coda)
|
||||||
│ ├── Livewire/ # Tutti i componenti di interfaccia
|
│ ├── Livewire/ # Tutti i componenti di interfaccia (Gruppi/: gruppi di servizio)
|
||||||
│ ├── Models/ # 9 modelli Eloquent
|
│ ├── Models/ # 12 modelli Eloquent
|
||||||
│ ├── Providers/ # AppServiceProvider
|
│ ├── 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
|
├── bootstrap/app.php # Middleware, proxy e host fidati
|
||||||
├── config/ # Configurazione Laravel
|
├── config/ # Configurazione Laravel
|
||||||
├── database/
|
├── database/
|
||||||
@@ -957,7 +1043,7 @@ TerManager2/
|
|||||||
│ ├── components/btn.blade.php # L'unico pulsante dell'applicazione
|
│ ├── components/btn.blade.php # L'unico pulsante dell'applicazione
|
||||||
│ └── livewire/ # Una vista per componente
|
│ └── livewire/ # Una vista per componente
|
||||||
├── routes/web.php # Rotte con i relativi permessi
|
├── 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
|
├── phpunit.xml # Configurazione e ambiente dei test
|
||||||
├── docker-compose.yml # Unico, produzione e sviluppo
|
├── docker-compose.yml # Unico, produzione e sviluppo
|
||||||
├── .env.example # Tutta la configurazione
|
├── .env.example # Tutta la configurazione
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ class LoginController extends Controller
|
|||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
activity()->causedBy(auth()->user())->log('login');
|
activity()->causedBy(auth()->user())->log('login');
|
||||||
|
|
||||||
|
// Il sorvegliante di gruppo va dritto al suo gruppo.
|
||||||
|
if (Auth::user()->isSorvegliante() && Auth::user()->roles->count() === 1) {
|
||||||
|
return redirect()->route('mio-gruppo');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard'));
|
return redirect()->intended(route('dashboard'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,9 @@ use Illuminate\Support\Facades\Cache;
|
|||||||
use Illuminate\Validation\Rules\Password;
|
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
|
* Volutamente NON e' un componente Livewire: la pagina deve restare
|
||||||
* raggiungibile mentre RequirePasswordChange respinge tutto il resto, incluse
|
* 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 arriva qui spontaneamente (dal profilo) puo' tornare indietro;
|
||||||
// chi e' stato dirottato dal middleware no.
|
// chi e' stato dirottato dal middleware no.
|
||||||
'forced' => (bool) $user->password_change_required,
|
'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
|
// 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
|
// 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.
|
// lo si legge qui, che e' la prima pagina che quella persona vede.
|
||||||
@@ -71,6 +75,7 @@ class PasswordChangeController extends Controller
|
|||||||
$user->update([
|
$user->update([
|
||||||
'password' => $validated['password'],
|
'password' => $validated['password'],
|
||||||
'password_change_required' => false,
|
'password_change_required' => false,
|
||||||
|
'password_temporanea' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// La sessione corrente sopravvive al cambio, le altre no: dopo un
|
// La sessione corrente sopravvive al cambio, le altre no: dopo un
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ class TerritorioFileController extends Controller
|
|||||||
return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf');
|
return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il PDF di un territorio del pacchetto, per il sorvegliante di gruppo:
|
||||||
|
* solo se il territorio è nel pacchetto del gruppo che sorveglia.
|
||||||
|
*/
|
||||||
|
public function pdfGruppo(Territorio $territorio): Response
|
||||||
|
{
|
||||||
|
$gruppo = auth()->user()->gruppoSorvegliato();
|
||||||
|
abort_unless($gruppo && $territorio->gruppo_servizio_id === $gruppo->id, 403);
|
||||||
|
|
||||||
|
return $this->pdf($territorio);
|
||||||
|
}
|
||||||
|
|
||||||
public function miniatura(Territorio $territorio): Response
|
public function miniatura(Territorio $territorio): Response
|
||||||
{
|
{
|
||||||
return $this->servi($territorio->thumbnail_path, null, null);
|
return $this->servi($territorio->thumbnail_path, null, null);
|
||||||
|
|||||||
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,6 +5,7 @@ namespace App\Jobs;
|
|||||||
use App\Models\AnnoTeocratico;
|
use App\Models\AnnoTeocratico;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
use App\Models\Campagna;
|
use App\Models\Campagna;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
@@ -75,7 +76,7 @@ class ResetApplication implements ShouldQueue
|
|||||||
$stato->avanza($this->operationId, 1);
|
$stato->avanza($this->operationId, 1);
|
||||||
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
||||||
|
|
||||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10);
|
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 11);
|
||||||
|
|
||||||
DB::transaction(function () use ($stato) {
|
DB::transaction(function () use ($stato) {
|
||||||
$passi = [
|
$passi = [
|
||||||
@@ -87,6 +88,7 @@ class ResetApplication implements ShouldQueue
|
|||||||
'Piantine' => fn () => TerritorioMappa::query()->delete(),
|
'Piantine' => fn () => TerritorioMappa::query()->delete(),
|
||||||
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
|
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
|
||||||
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
||||||
|
'Gruppi di servizio' => fn () => GruppoServizio::query()->delete(),
|
||||||
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
||||||
'Tipologie' => fn () => Tipologia::query()->delete(),
|
'Tipologie' => fn () => Tipologia::query()->delete(),
|
||||||
'Zone' => fn () => Zona::query()->delete(),
|
'Zone' => fn () => Zona::query()->delete(),
|
||||||
|
|||||||
@@ -250,6 +250,9 @@ class RestoreBackupArchive implements ShouldQueue
|
|||||||
{
|
{
|
||||||
DB::table('users')->update([
|
DB::table('users')->update([
|
||||||
'password_change_required' => true,
|
'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,
|
'remember_token' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Assegnazioni;
|
namespace App\Livewire\Assegnazioni;
|
||||||
|
|
||||||
|
use App\Livewire\Concerns\InviaLink;
|
||||||
use App\Livewire\Concerns\RicordaRitorno;
|
use App\Livewire\Concerns\RicordaRitorno;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\Attributes\Locked;
|
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
@@ -15,25 +14,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
class Assegna extends Component
|
class Assegna extends Component
|
||||||
{
|
{
|
||||||
use RicordaRitorno;
|
use RicordaRitorno;
|
||||||
|
use InviaLink;
|
||||||
// ─── Invio del link ─────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Dopo l'assegnazione non si torna subito alla pagina: si sceglie il
|
|
||||||
// canale (copia link o Condividi: il destinatario lo sceglie chi usa
|
|
||||||
// l'app dai suoi contatti, i proclamatori non hanno numero né email),
|
|
||||||
// si dice se il link è partito, e solo allora si va avanti. Se la
|
|
||||||
// procedura si interrompe, l'assegnazione resta con link_da_inviare e lo
|
|
||||||
// si segnala dove compare.
|
|
||||||
|
|
||||||
/** L'assegnazione di cui si sta mandando il link. */
|
|
||||||
#[Locked]
|
|
||||||
public ?int $invioId = null;
|
|
||||||
|
|
||||||
/** Il canale usato in questo invio (Assegnazione::CANALI_LINK). */
|
|
||||||
#[Locked]
|
|
||||||
public ?string $canaleUsato = null;
|
|
||||||
|
|
||||||
public string $erroreInvio = '';
|
|
||||||
|
|
||||||
public ?int $territorio_id = null;
|
public ?int $territorio_id = null;
|
||||||
public ?int $proclamatore_id = null;
|
public ?int $proclamatore_id = null;
|
||||||
@@ -42,11 +23,19 @@ class Assegna extends Component
|
|||||||
// Optional pre-selection from parent context
|
// Optional pre-selection from parent context
|
||||||
public ?int $preselectedTerritorioId = null;
|
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)
|
public function mount(?int $territorioId = null)
|
||||||
{
|
{
|
||||||
$this->preselectedTerritorioId = $territorioId;
|
$this->preselectedTerritorioId = $territorioId;
|
||||||
$this->territorio_id = $territorioId;
|
$this->territorio_id = $territorioId;
|
||||||
$this->assigned_at = now()->format('Y-m-d');
|
$this->assigned_at = now()->format('Y-m-d');
|
||||||
|
$this->elencoNascosto = request()->boolean('dalla_home');
|
||||||
|
|
||||||
// ?invia=<id>: riprende l'invio di un link rimasto in sospeso (dalla
|
// ?invia=<id>: riprende l'invio di un link rimasto in sospeso (dalla
|
||||||
// scheda del territorio).
|
// scheda del territorio).
|
||||||
@@ -83,6 +72,12 @@ class Assegna extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nel pacchetto di un gruppo di servizio: lo assegna il sorvegliante.
|
||||||
|
if ($territorio->gruppo_servizio_id) {
|
||||||
|
$this->addError('territorio_id', "Il territorio {$territorio->numero} è nel pacchetto del gruppo {$territorio->gruppo?->nome}: lo assegna il sorvegliante, oppure va prima tolto dal pacchetto.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
|
||||||
if (!$proclamatore->attivo) {
|
if (!$proclamatore->attivo) {
|
||||||
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
$this->addError('proclamatore_id', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
|
||||||
@@ -140,91 +135,16 @@ class Assegna extends Component
|
|||||||
$this->apriInvio($assegnazione->id);
|
$this->apriInvio($assegnazione->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── Invio del link (trait InviaLink) ──────────────────────
|
||||||
* Apre l'invio del link: subito dopo l'assegnazione, o da un'assegnazione
|
|
||||||
* rimasta in sospeso ("Invia link" nell'elenco, che è un componente a sé
|
protected function puoInviare(Assegnazione $assegnazione): bool
|
||||||
* e lo chiede con l'evento apri-invio).
|
|
||||||
*/
|
|
||||||
#[On('apri-invio')]
|
|
||||||
public function apriInvio(int $assegnazioneId): void
|
|
||||||
{
|
{
|
||||||
$this->authorize('territori.assign');
|
return auth()->user()->can('territori.assign');
|
||||||
|
|
||||||
$assegnazione = Assegnazione::aperte()->find($assegnazioneId);
|
|
||||||
if (! $assegnazione) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->invioId = $assegnazione->id;
|
|
||||||
$this->canaleUsato = null;
|
|
||||||
$this->erroreInvio = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function paginaDopoInvio(): string
|
||||||
* Copia link o Condividi: l'invio vero lo fa chi usa l'app (il
|
|
||||||
* destinatario lo sceglie lui dai suoi contatti), qui si annota il canale.
|
|
||||||
*/
|
|
||||||
public function canale(string $canale): void
|
|
||||||
{
|
{
|
||||||
$assegnazione = $this->assegnazioneInInvio();
|
return $this->destinazione(route('assegnazioni.assegna'));
|
||||||
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 nome, numero o email.
|
|
||||||
activity()->causedBy(auth()->user())
|
|
||||||
->performedOn($assegnazione)
|
|
||||||
->withProperties([
|
|
||||||
'territorio' => $assegnazione->territorio?->numero,
|
|
||||||
'proclamatore_id' => $assegnazione->proclamatore_id,
|
|
||||||
'canale' => $inviato ? $this->canaleUsato : null,
|
|
||||||
])
|
|
||||||
->log($inviato ? 'link_inviato' : 'link_non_inviato');
|
|
||||||
|
|
||||||
$numero = $assegnazione->territorio?->numero;
|
|
||||||
$nome = $assegnazione->proclamatore?->nome_completo;
|
|
||||||
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
|
|
||||||
|
|
||||||
session()->flash('success', $inviato
|
|
||||||
? "Territorio {$numero} assegnato a {$nome}: link inviato."
|
|
||||||
: "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco.");
|
|
||||||
return $this->redirect($this->destinazione(route('assegnazioni.assegna')), navigate: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assegnazioneInInvio(): ?Assegnazione
|
|
||||||
{
|
|
||||||
if (! $this->invioId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->authorize('territori.assign');
|
|
||||||
|
|
||||||
return Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
@@ -233,30 +153,27 @@ class Assegna extends Component
|
|||||||
// i territori già assegnati, che non si possono scegliere: cercandone
|
// i territori già assegnati, che non si possono scegliere: cercandone
|
||||||
// uno si legge a chi è, invece di un "nessun risultato". La miniatura
|
// uno si legge a chi è, invece di un "nessun risultato". La miniatura
|
||||||
// serve all'anteprima, che si aggiorna nel browser.
|
// 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()
|
$territori = Territorio::attivi()
|
||||||
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore'])
|
->with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'gruppo'])
|
||||||
->get()
|
->get()
|
||||||
->sortBy('numero', SORT_NATURAL);
|
->sortBy('numero', SORT_NATURAL);
|
||||||
|
|
||||||
$opzioniTerritori = $territori
|
$opzioniTerritori = $territori
|
||||||
->map(fn (Territorio $t) => $t->opzioneScelta(
|
->map(fn (Territorio $t) => $t->opzioneScelta(match (true) {
|
||||||
$t->assegnazioneCorrente ? 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—') : null
|
(bool) $t->assegnazioneCorrente => 'già assegnato a ' . ($t->assegnazioneCorrente->proclamatore?->nome_completo ?? '—'),
|
||||||
) + array_filter(['miniatura' => $t->miniaturaUrl()]))
|
(bool) $t->gruppo_servizio_id => 'nel pacchetto del gruppo ' . ($t->gruppo?->nome ?? ''),
|
||||||
|
default => null,
|
||||||
|
}) + array_filter(['miniatura' => $t->miniaturaUrl()]))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
|
$opzioniProclamatori = Proclamatore::opzioniScelta(Proclamatore::attivi()->get());
|
||||||
|
|
||||||
// L'elenco dei territori assegnati sta in ElencoAssegnati.
|
// L'elenco dei territori assegnati sta in ElencoAssegnati.
|
||||||
// L'invio in corso; se nel frattempo l'assegnazione è rientrata o
|
|
||||||
// sparita non c'è più niente da mandare.
|
|
||||||
$invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
|
|
||||||
if ($this->invioId && ! $invio) {
|
|
||||||
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('livewire.assegnazioni.assegna', [
|
return view('livewire.assegnazioni.assegna', [
|
||||||
'invio' => $invio,
|
'invio' => $this->invioCorrente(),
|
||||||
'opzioniTerritori' => $opzioniTerritori,
|
'opzioniTerritori' => $opzioniTerritori,
|
||||||
'opzioniProclamatori' => $opzioniProclamatori,
|
'opzioniProclamatori' => $opzioniProclamatori,
|
||||||
'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
|
'anteprimaIniziale' => $this->territorio_id ? $territori->firstWhere('id', $this->territorio_id)?->miniaturaUrl() : null,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class Rientra extends Component
|
|||||||
|
|
||||||
public function mount(Assegnazione $assegnazione)
|
public function mount(Assegnazione $assegnazione)
|
||||||
{
|
{
|
||||||
|
$this->autorizza($assegnazione);
|
||||||
$this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']);
|
$this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']);
|
||||||
|
|
||||||
if ($assegnazione->returned_at) {
|
if ($assegnazione->returned_at) {
|
||||||
@@ -69,8 +70,28 @@ class Rientra extends Component
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chi ha territori.return registra qualsiasi rientro. Il sorvegliante di
|
||||||
|
* gruppo (gruppo.gestisci) solo quelli del suo gruppo: assegnazioni nate
|
||||||
|
* dal gruppo o territori del suo pacchetto.
|
||||||
|
*/
|
||||||
|
private function autorizza(Assegnazione $assegnazione): void
|
||||||
|
{
|
||||||
|
$utente = auth()->user();
|
||||||
|
if ($utente->can('territori.return')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gruppo = $utente->can('gruppo.gestisci') ? $utente->gruppoSorvegliato() : null;
|
||||||
|
abort_unless(
|
||||||
|
$gruppo && ($assegnazione->gruppo_servizio_id === $gruppo->id || $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function save()
|
public function save()
|
||||||
{
|
{
|
||||||
|
$this->autorizza($this->assegnazione);
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
// mount() rejects an already-returned assignment, but the component
|
// mount() rejects an already-returned assignment, but the component
|
||||||
@@ -112,7 +133,18 @@ class Rientra extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni.");
|
session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni.");
|
||||||
return $this->redirect($this->destinazione(route('territori.show', $territorio)), navigate: true);
|
return $this->redirect($this->destinazione($this->paginaPredefinita()), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dove si torna senza una pagina di partenza: la scheda del territorio,
|
||||||
|
* o "Il mio gruppo" per il sorvegliante, che la scheda non la vede.
|
||||||
|
*/
|
||||||
|
public function paginaPredefinita(): string
|
||||||
|
{
|
||||||
|
return auth()->user()->can('territori.assign')
|
||||||
|
? route('territori.show', $this->assegnazione->territorio_id)
|
||||||
|
: route('mio-gruppo');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
app/Livewire/Gruppi/GruppoShow.php
Normal file
204
app/Livewire/Gruppi/GruppoShow.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = Str::password(12, symbols: false);
|
||||||
|
$utente->forceFill(['password' => $password, 'password_change_required' => true, 'password_temporanea' => true])->save();
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($utente)->log('password_reimpostata');
|
||||||
|
|
||||||
|
$this->passwordTemporanea = $password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toglie il ruolo di sorvegliante: il gruppo si scioglie 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
223
app/Livewire/Gruppi/MioGruppo.php
Normal file
223
app/Livewire/Gruppi/MioGruppo.php
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<?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 = '';
|
||||||
|
|
||||||
|
// 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->dataAssegnazione = now()->format('Y-m-d');
|
||||||
|
$this->resetValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function annullaAssegnazione(): void
|
||||||
|
{
|
||||||
|
$this->reset(['territorioDaAssegnare', 'membroId']);
|
||||||
|
$this->resetValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assegna(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'membroId' => 'required|integer',
|
||||||
|
'dataAssegnazione' => 'required|date|before_or_equal:today',
|
||||||
|
], [
|
||||||
|
'membroId.required' => 'Scegli a chi dare il territorio.',
|
||||||
|
'dataAssegnazione.before_or_equal' => 'La data non può essere nel futuro.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gruppo = $this->gruppoOFallisci();
|
||||||
|
$territorio = $this->territorioDelPacchetto((int) $this->territorioDaAssegnare);
|
||||||
|
$membro = Proclamatore::find($this->membroId);
|
||||||
|
|
||||||
|
if (! $membro) {
|
||||||
|
$this->addError('membroId', 'Scegli un membro del gruppo.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione));
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$this->addError('membroId', $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reset(['territorioDaAssegnare', 'membroId']);
|
||||||
|
|
||||||
|
// Come dalla pagina Assegna: si apre l'invio del link.
|
||||||
|
$this->apriInvio($assegnazione->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rimandare in reparto ───────────────────────────────────
|
||||||
|
|
||||||
|
public function rimanda(int $territorioId): void
|
||||||
|
{
|
||||||
|
$territorio = $this->territorioDelPacchetto($territorioId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(GruppiServizio::class)->rimandaInReparto($this->gruppoOFallisci(), $territorio);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$this->avvisaErrore($e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->avvisa("Territorio {$territorio->numero} rimandato in reparto.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Link già inviato / condiviso dall'elenco ───────────────
|
||||||
|
|
||||||
|
private function assegnazioneDelGruppo(int $id): Assegnazione
|
||||||
|
{
|
||||||
|
$assegnazione = Assegnazione::aperte()->with('territorio')->find($id);
|
||||||
|
abort_unless($assegnazione && $this->puoInviare($assegnazione), 403);
|
||||||
|
|
||||||
|
return $assegnazione;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleLinkSent(int $assegnazioneId): void
|
||||||
|
{
|
||||||
|
$assegnazione = $this->assegnazioneDelGruppo($assegnazioneId);
|
||||||
|
$assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Condividi" dall'elenco andato a buon fine (elencoCercabile.condividi). */
|
||||||
|
public function segnaCondiviso(int $assegnazioneId): void
|
||||||
|
{
|
||||||
|
$this->assegnazioneDelGruppo($assegnazioneId)->registraInvioLink(true, 'condividi');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Invio del link (trait InviaLink) ───────────────────────
|
||||||
|
|
||||||
|
protected function puoInviare(Assegnazione $assegnazione): bool
|
||||||
|
{
|
||||||
|
$gruppo = $this->gruppo();
|
||||||
|
|
||||||
|
return $gruppo !== null
|
||||||
|
&& ($assegnazione->gruppo_servizio_id === $gruppo->id
|
||||||
|
|| $assegnazione->territorio?->gruppo_servizio_id === $gruppo->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function paginaDopoInvio(): string
|
||||||
|
{
|
||||||
|
return route('mio-gruppo');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$gruppo = $this->gruppo()?->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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,35 +18,10 @@ class Home extends Component
|
|||||||
$this->authorize('territori.assign');
|
$this->authorize('territori.assign');
|
||||||
|
|
||||||
$settings = Setting::instance();
|
$settings = Setting::instance();
|
||||||
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
|
|
||||||
|
|
||||||
$territori = Territorio::inReparto()
|
$territori = Territorio::ordinaPerPriorita(
|
||||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
|
||||||
->get()
|
);
|
||||||
->map(function (Territorio $territorio) use ($priorityThreshold) {
|
|
||||||
$ultima = $territorio->ultimaAssegnazione;
|
|
||||||
if ($ultima && $ultima->returned_at) {
|
|
||||||
$giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
|
|
||||||
} elseif (! $ultima) {
|
|
||||||
$giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
|
|
||||||
} else {
|
|
||||||
$giorniGiacenza = 0;
|
|
||||||
}
|
|
||||||
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
|
|
||||||
$territorio->setAttribute(
|
|
||||||
'home_is_prioritario',
|
|
||||||
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
|
|
||||||
);
|
|
||||||
return $territorio;
|
|
||||||
})
|
|
||||||
->sort(function (Territorio $left, Territorio $right) {
|
|
||||||
$p = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
|
|
||||||
if ($p !== 0) return $p;
|
|
||||||
$g = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
|
|
||||||
if ($g !== 0) return $g;
|
|
||||||
return strnatcasecmp((string) $left->numero, (string) $right->numero);
|
|
||||||
})
|
|
||||||
->values();
|
|
||||||
|
|
||||||
$pdf = Pdf::loadView('pdf.territori-lista', [
|
$pdf = Pdf::loadView('pdf.territori-lista', [
|
||||||
'titolo' => 'Territori da Assegnare',
|
'titolo' => 'Territori da Assegnare',
|
||||||
@@ -90,7 +65,6 @@ class Home extends Component
|
|||||||
{
|
{
|
||||||
$settings = Setting::instance();
|
$settings = Setting::instance();
|
||||||
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
|
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
|
||||||
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
|
|
||||||
$annoCorrente = AnnoTeocratico::corrente();
|
$annoCorrente = AnnoTeocratico::corrente();
|
||||||
$campagnaAttiva = Campagna::attiva();
|
$campagnaAttiva = Campagna::attiva();
|
||||||
|
|
||||||
@@ -149,46 +123,11 @@ class Home extends Component
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick lists
|
// Quick lists: la logica di priorità sta in Territorio::ordinaPerPriorita
|
||||||
$territoriDaAssegnare = Territorio::inReparto()
|
// (la usano anche il pacchetto dei gruppi di servizio e "Il mio gruppo").
|
||||||
->with('zona', 'tipologia', 'ultimaAssegnazione')
|
$territoriDaAssegnare = Territorio::ordinaPerPriorita(
|
||||||
->get()
|
Territorio::inReparto()->with('zona', 'tipologia', 'ultimaAssegnazione')->get()
|
||||||
->map(function (Territorio $territorio) use ($priorityThreshold) {
|
)->take($homeLimit)->values();
|
||||||
$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();
|
|
||||||
|
|
||||||
$daRientrare = Territorio::daRientrare()
|
$daRientrare = Territorio::daRientrare()
|
||||||
->with(['zona', 'assegnazioneCorrente.proclamatore'])
|
->with(['zona', 'assegnazioneCorrente.proclamatore'])
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class Privacy extends Component
|
|||||||
public const PERMESSI = [
|
public const PERMESSI = [
|
||||||
'settings.manage' => 'impostazioni, utenti, backup e import',
|
'settings.manage' => 'impostazioni, utenti, backup e import',
|
||||||
'proclamatori.manage' => 'anagrafica dei proclamatori',
|
'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',
|
'territori.manage' => 'gestione dei territori e delle piantine',
|
||||||
'campagne.manage' => 'campagne',
|
'campagne.manage' => 'campagne',
|
||||||
'registro.view' => 'consultazione del registro delle assegnazioni',
|
'registro.view' => 'consultazione del registro delle assegnazioni',
|
||||||
|
|||||||
@@ -38,13 +38,17 @@ class ProclamatoreEdit extends Component
|
|||||||
|
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
|
// Un sorvegliante reso non attivo scioglie il gruppo (Proclamatore::booted).
|
||||||
|
$gruppo = ! $this->attivo ? $this->proclamatore->gruppoSorvegliato() : null;
|
||||||
|
|
||||||
$this->proclamatore->update([
|
$this->proclamatore->update([
|
||||||
'nome' => $this->nome,
|
'nome' => $this->nome,
|
||||||
'cognome' => $this->cognome,
|
'cognome' => $this->cognome,
|
||||||
'attivo' => $this->attivo,
|
'attivo' => $this->attivo,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");
|
$avviso = "Proclamatore {$this->proclamatore->nome_completo} aggiornato." . ($gruppo ? ' ' . ProclamatoreIndex::gruppoSciolto($gruppo) : '');
|
||||||
|
session()->flash('success', $avviso);
|
||||||
return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
|
return $this->redirect($this->destinazione(route('proclamatori.index')), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@ class ProclamatoreEdit extends Component
|
|||||||
return view('livewire.proclamatori.proclamatore-form', [
|
return view('livewire.proclamatori.proclamatore-form', [
|
||||||
'titolo' => "Modifica {$this->proclamatore->nome_completo}",
|
'titolo' => "Modifica {$this->proclamatore->nome_completo}",
|
||||||
'btnLabel' => 'Salva Modifiche',
|
'btnLabel' => 'Salva Modifiche',
|
||||||
|
'gruppoSorvegliato' => $this->proclamatore->gruppoSorvegliato(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Livewire\Concerns\Avvisa;
|
|||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
|
|
||||||
class ProclamatoreIndex extends Component
|
class ProclamatoreIndex extends Component
|
||||||
@@ -57,7 +58,13 @@ class ProclamatoreIndex extends Component
|
|||||||
$this->authorize('settings.manage');
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
$proclamatore = Proclamatore::findOrFail($id);
|
$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]);
|
$proclamatore->update(['attivo' => !$proclamatore->attivo]);
|
||||||
|
|
||||||
|
if ($gruppo) {
|
||||||
|
$this->avvisa("{$proclamatore->nome_completo} non è più attivo. " . self::gruppoSciolto($gruppo));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteProclamatore(int $id)
|
public function deleteProclamatore(int $id)
|
||||||
@@ -71,8 +78,11 @@ class ProclamatoreIndex extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nel cestino lascia il gruppo; se ne era il sorvegliante, il gruppo
|
||||||
|
// si scioglie (Proclamatore::booted).
|
||||||
|
$gruppo = $proclamatore->gruppoSorvegliato();
|
||||||
$proclamatore->delete();
|
$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)
|
public function anonimizza(int $id)
|
||||||
@@ -80,8 +90,15 @@ class ProclamatoreIndex extends Component
|
|||||||
$this->authorize('settings.manage');
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
$proclamatore = Proclamatore::findOrFail($id);
|
$proclamatore = Proclamatore::findOrFail($id);
|
||||||
|
$gruppo = $proclamatore->gruppoSorvegliato();
|
||||||
$proclamatore->anonimizza();
|
$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()
|
public function render()
|
||||||
@@ -89,7 +106,7 @@ class ProclamatoreIndex extends Component
|
|||||||
// withCount rather than letting the view call
|
// withCount rather than letting the view call
|
||||||
// ->assegnazioni()->aperte()->count() per row, which issued one
|
// ->assegnazioni()->aperte()->count() per row, which issued one
|
||||||
// COUNT(*) per displayed proclamatore on every render.
|
// 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'),
|
'assegnazioni as assegnazioni_aperte_count' => fn ($q) => $q->whereNull('returned_at'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -107,6 +124,8 @@ class ProclamatoreIndex extends Component
|
|||||||
$filtered = $filtered->where('attivo', true);
|
$filtered = $filtered->where('attivo', true);
|
||||||
} elseif ($this->filtroStato === 'inattivo') {
|
} elseif ($this->filtroStato === 'inattivo') {
|
||||||
$filtered = $filtered->where('attivo', false);
|
$filtered = $filtered->where('attivo', false);
|
||||||
|
} elseif ($this->filtroStato === 'gruppo') {
|
||||||
|
$filtered = $filtered->whereNotNull('gruppo_servizio_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort in-memory
|
// Sort in-memory
|
||||||
@@ -131,6 +150,8 @@ class ProclamatoreIndex extends Component
|
|||||||
$query->where('attivo', true);
|
$query->where('attivo', true);
|
||||||
} elseif ($this->filtroStato === 'inattivo') {
|
} elseif ($this->filtroStato === 'inattivo') {
|
||||||
$query->where('attivo', false);
|
$query->where('attivo', false);
|
||||||
|
} elseif ($this->filtroStato === 'gruppo') {
|
||||||
|
$query->whereNotNull('gruppo_servizio_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// cognome/nome encrypted, so sort in-memory
|
// cognome/nome encrypted, so sort in-memory
|
||||||
@@ -153,6 +174,8 @@ class ProclamatoreIndex extends Component
|
|||||||
|
|
||||||
return view('livewire.proclamatori.proclamatore-index', [
|
return view('livewire.proclamatori.proclamatore-index', [
|
||||||
'proclamatori' => $proclamatori,
|
'proclamatori' => $proclamatori,
|
||||||
|
// Per avvisare, prima di disattivare o cestinare, che il gruppo si scioglie.
|
||||||
|
'gruppiSorvegliati' => GruppoServizio::pluck('nome', 'sorvegliante_id'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Livewire\Concerns\RicordaRitorno;
|
|||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
class ProclamatoreShow extends Component
|
class ProclamatoreShow extends Component
|
||||||
{
|
{
|
||||||
@@ -23,8 +24,11 @@ class ProclamatoreShow extends Component
|
|||||||
$assegnazioni = Assegnazione::where('proclamatore_id', $this->proclamatore->id)
|
$assegnazioni = Assegnazione::where('proclamatore_id', $this->proclamatore->id)
|
||||||
->with(['territorio', 'annoTeocratico', 'campagna'])
|
->with(['territorio', 'annoTeocratico', 'campagna'])
|
||||||
->orderByDesc('assigned_at')
|
->orderByDesc('assigned_at')
|
||||||
|
->orderByDesc('id')
|
||||||
->get()
|
->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 = [
|
$stats = [
|
||||||
'totale_assegnazioni' => Assegnazione::where('proclamatore_id', $this->proclamatore->id)->count(),
|
'totale_assegnazioni' => Assegnazione::where('proclamatore_id', $this->proclamatore->id)->count(),
|
||||||
@@ -37,9 +41,21 @@ class ProclamatoreShow extends Component
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Gruppo di servizio: sorvegliante (con i dettagli del gruppo e della
|
||||||
|
// sua utenza) o membro. I gruppi sciolti non esistono più.
|
||||||
|
$gruppoSorvegliato = $this->proclamatore->gruppoSorvegliato();
|
||||||
|
$utenza = $gruppoSorvegliato ? $this->proclamatore->utente : null;
|
||||||
|
|
||||||
return view('livewire.proclamatori.proclamatore-show', [
|
return view('livewire.proclamatori.proclamatore-show', [
|
||||||
'assegnazioniPerAnno' => $assegnazioni,
|
'assegnazioniPerAnno' => $assegnazioni,
|
||||||
'stats' => $stats,
|
'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();
|
$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([
|
$validated = $this->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
] + ($emailFissa ? [] : [
|
||||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||||
]);
|
]));
|
||||||
|
|
||||||
$user->fill([
|
$user->fill(['name' => $validated['name']] + ($emailFissa ? [] : ['email' => $validated['email']]));
|
||||||
'name' => $validated['name'],
|
|
||||||
'email' => $validated['email'],
|
|
||||||
]);
|
|
||||||
$emailCambiata = $user->isDirty('email');
|
$emailCambiata = $user->isDirty('email');
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
// L'indirizzo nuovo va confermato (User::booted lo segna da
|
// L'indirizzo nuovo va confermato (User::booted lo segna da
|
||||||
// confermare): finché non apre il link, l'account resta bloccato.
|
// confermare): finché non apre il link, l'account resta bloccato.
|
||||||
if ($emailCambiata) {
|
// Il sorvegliante di gruppo non conferma l'email (User::hasVerifiedEmail).
|
||||||
|
if ($emailCambiata && ! $user->isSorvegliante()) {
|
||||||
$user->inviaConfermaEmail()
|
$user->inviaConfermaEmail()
|
||||||
? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.")
|
? session()->flash('success', "Profilo aggiornato. Conferma il nuovo indirizzo: ti abbiamo mandato un'email a {$user->email}.")
|
||||||
: session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.');
|
: session()->flash('error', 'Profilo aggiornato, ma l\'email di conferma non è partita: richiedila qui sotto.');
|
||||||
@@ -77,6 +80,7 @@ class Profile extends Component
|
|||||||
auth()->user()->update([
|
auth()->user()->update([
|
||||||
'password' => $this->password,
|
'password' => $this->password,
|
||||||
'password_change_required' => false,
|
'password_change_required' => false,
|
||||||
|
'password_temporanea' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->reset(['current_password', 'password', 'password_confirmation']);
|
$this->reset(['current_password', 'password', 'password_confirmation']);
|
||||||
@@ -86,8 +90,13 @@ class Profile extends Component
|
|||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
|
// Il sorvegliante vede qui i dettagli del suo gruppo di servizio.
|
||||||
|
$gruppo = auth()->user()->gruppoSorvegliato();
|
||||||
|
|
||||||
return view('livewire.profile', [
|
return view('livewire.profile', [
|
||||||
'user' => auth()->user(),
|
'user' => auth()->user(),
|
||||||
|
'gruppo' => $gruppo,
|
||||||
|
'riepilogoGruppo' => $gruppo?->riepilogo(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,10 @@
|
|||||||
namespace App\Livewire\Settings;
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
use App\Livewire\Concerns\Avvisa;
|
use App\Livewire\Concerns\Avvisa;
|
||||||
use App\Models\Assegnazione;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Spatie\Activitylog\Models\Activity;
|
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
class UsersIndex extends Component
|
class UsersIndex extends Component
|
||||||
@@ -34,7 +31,10 @@ class UsersIndex extends Component
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
// "sorvegliante" si dà solo creando un gruppo di servizio, con
|
||||||
|
// l'utenza collegata al proclamatore: da qui non si assegna.
|
||||||
$this->availableRoles = Role::query()
|
$this->availableRoles = Role::query()
|
||||||
|
->where('name', '!=', 'sorvegliante')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->pluck('name')
|
->pluck('name')
|
||||||
->all();
|
->all();
|
||||||
@@ -111,9 +111,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
|
public function startEdit(int $userId): void
|
||||||
{
|
{
|
||||||
$user = User::query()->with('roles')->findOrFail($userId);
|
$user = User::query()->with('roles')->findOrFail($userId);
|
||||||
|
if ($this->gestitaDaiGruppi($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->editingUserId = $user->id;
|
$this->editingUserId = $user->id;
|
||||||
$this->editName = $user->name;
|
$this->editName = $user->name;
|
||||||
@@ -143,6 +162,9 @@ class UsersIndex extends Component
|
|||||||
|
|
||||||
$validated = $this->validate($this->editRules());
|
$validated = $this->validate($this->editRules());
|
||||||
$user = User::query()->findOrFail($this->editingUserId);
|
$user = User::query()->findOrFail($this->editingUserId);
|
||||||
|
if ($this->gestitaDaiGruppi($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
|
// Togliere il ruolo di amministratore a sé stessi, o all'ultimo
|
||||||
// rimasto, lascia l'installazione senza nessuno che possa gestire
|
// rimasto, lascia l'installazione senza nessuno che possa gestire
|
||||||
@@ -193,6 +215,9 @@ class UsersIndex extends Component
|
|||||||
{
|
{
|
||||||
$currentUser = auth()->user();
|
$currentUser = auth()->user();
|
||||||
$user = User::query()->with('roles')->findOrFail($userId);
|
$user = User::query()->with('roles')->findOrFail($userId);
|
||||||
|
if ($this->gestitaDaiGruppi($user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $currentUser || $currentUser->id === $user->id) {
|
if (! $currentUser || $currentUser->id === $user->id) {
|
||||||
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
|
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
|
||||||
@@ -204,37 +229,7 @@ class UsersIndex extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($user, $currentUser) {
|
$user->eliminaConservandoStorico($currentUser, $user->name, $user->email);
|
||||||
$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();
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($this->editingUserId === $userId) {
|
if ($this->editingUserId === $userId) {
|
||||||
$this->cancelEdit();
|
$this->cancelEdit();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Jobs\ImportTerritoryPdfFolder;
|
|||||||
use App\Models\AnnoTeocratico;
|
use App\Models\AnnoTeocratico;
|
||||||
use App\Models\Assegnazione;
|
use App\Models\Assegnazione;
|
||||||
use App\Models\Campagna;
|
use App\Models\Campagna;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\Territorio;
|
use App\Models\Territorio;
|
||||||
@@ -469,6 +470,7 @@ class XmlExchange extends Component
|
|||||||
try {
|
try {
|
||||||
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
|
DB::transaction(function () use ($xml, $actorId, $attore, $attoreEraAmministratore, &$stats) {
|
||||||
Assegnazione::query()->delete();
|
Assegnazione::query()->delete();
|
||||||
|
GruppoServizio::query()->delete();
|
||||||
Campagna::query()->delete();
|
Campagna::query()->delete();
|
||||||
Territorio::query()->withTrashed()->forceDelete();
|
Territorio::query()->withTrashed()->forceDelete();
|
||||||
Proclamatore::query()->withTrashed()->forceDelete();
|
Proclamatore::query()->withTrashed()->forceDelete();
|
||||||
@@ -520,10 +522,18 @@ class XmlExchange extends Component
|
|||||||
// accounts in the file are (re)created. $userMap remaps the
|
// accounts in the file are (re)created. $userMap remaps the
|
||||||
// created_by/returned_by FKs on the assignments below.
|
// created_by/returned_by FKs on the assignments below.
|
||||||
$userMap = [];
|
$userMap = [];
|
||||||
|
$utentiDaCollegare = [];
|
||||||
foreach (($xml->users->user ?? []) as $userNode) {
|
foreach (($xml->users->user ?? []) as $userNode) {
|
||||||
$email = trim((string) ($userNode->email ?? ''));
|
$email = trim((string) ($userNode->email ?? ''));
|
||||||
$legacyId = (string) ($userNode['legacy_id'] ?? '');
|
$legacyId = (string) ($userNode['legacy_id'] ?? '');
|
||||||
|
|
||||||
|
// 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 === '') {
|
if ($email === '') {
|
||||||
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
|
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
|
||||||
continue;
|
continue;
|
||||||
@@ -572,6 +582,7 @@ class XmlExchange extends Component
|
|||||||
if ($passwordHash !== '' && ! $eAttore) {
|
if ($passwordHash !== '' && ! $eAttore) {
|
||||||
$meta['password'] = $passwordHash;
|
$meta['password'] = $passwordHash;
|
||||||
$meta['password_change_required'] = true;
|
$meta['password_change_required'] = true;
|
||||||
|
$meta['password_temporanea'] = false;
|
||||||
}
|
}
|
||||||
if (! $eAttore) {
|
if (! $eAttore) {
|
||||||
$meta['remember_token'] = null;
|
$meta['remember_token'] = null;
|
||||||
@@ -589,6 +600,10 @@ class XmlExchange extends Component
|
|||||||
|
|
||||||
DB::table('users')->where('id', $user->id)->update($meta);
|
DB::table('users')->where('id', $user->id)->update($meta);
|
||||||
|
|
||||||
|
// Utenza di un sorvegliante: si collega al proclamatore dopo che
|
||||||
|
// i proclamatori sono stati importati (vedi i gruppi di servizio).
|
||||||
|
$utentiDaCollegare[$user->id] = (string) ($userNode->legacy_proclamatore_id ?? '');
|
||||||
|
|
||||||
$stats['utenti_importati']++;
|
$stats['utenti_importati']++;
|
||||||
if ($legacyId !== '') {
|
if ($legacyId !== '') {
|
||||||
$userMap[$legacyId] = $user->id;
|
$userMap[$legacyId] = $user->id;
|
||||||
@@ -624,6 +639,7 @@ class XmlExchange extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
$proclamatoreMap = [];
|
$proclamatoreMap = [];
|
||||||
|
$gruppiDeiProclamatori = [];
|
||||||
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
|
foreach (($xml->proclamatori->proclamatore ?? []) as $proclamatoreNode) {
|
||||||
$p = Proclamatore::create([
|
$p = Proclamatore::create([
|
||||||
'nome' => (string) $proclamatoreNode->nome,
|
'nome' => (string) $proclamatoreNode->nome,
|
||||||
@@ -634,14 +650,46 @@ class XmlExchange extends Component
|
|||||||
]);
|
]);
|
||||||
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
|
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
|
||||||
$stats['proclamatori_importati']++;
|
$stats['proclamatori_importati']++;
|
||||||
|
$gruppiDeiProclamatori[$p->id] = (string) ($proclamatoreNode->legacy_gruppo_id ?? '');
|
||||||
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
|
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
|
||||||
if ($legacyId !== '') {
|
if ($legacyId !== '') {
|
||||||
$proclamatoreMap[$legacyId] = $p->id;
|
$proclamatoreMap[$legacyId] = $p->id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$annoMap = [];
|
// Gruppi di servizio, dopo i proclamatori: il sorvegliante è uno di
|
||||||
foreach (($xml->anni_teocratici->anno ?? []) as $annoNode) {
|
// loro. Poi i collegamenti che li aspettavano (membri, utenze).
|
||||||
|
$gruppoMap = [];
|
||||||
|
foreach (($xml->gruppi_servizio->gruppo ?? []) as $gruppoNode) {
|
||||||
|
$legacySorvegliante = (string) ($gruppoNode->legacy_sorvegliante_id ?? '');
|
||||||
|
// 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;
|
$label = (string) $annoNode->label;
|
||||||
$anno = AnnoTeocratico::create([
|
$anno = AnnoTeocratico::create([
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
@@ -706,6 +754,7 @@ class XmlExchange extends Component
|
|||||||
'note' => (string) ($territorioNode->note ?? ''),
|
'note' => (string) ($territorioNode->note ?? ''),
|
||||||
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
||||||
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
|
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
|
||||||
|
'gruppo_servizio_id' => $gruppoMap[(string) ($territorioNode->legacy_gruppo_id ?? '')] ?? null,
|
||||||
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
|
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
|
||||||
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
|
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
|
||||||
]);
|
]);
|
||||||
@@ -768,6 +817,8 @@ class XmlExchange extends Component
|
|||||||
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
||||||
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
|
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
|
||||||
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
|
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
|
||||||
|
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null,
|
||||||
|
'gruppo_nome' => trim((string) ($assegnazioneNode->gruppo_nome ?? '')) ?: null,
|
||||||
'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null,
|
'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null,
|
||||||
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
|
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
|
||||||
'note' => (string) ($assegnazioneNode->note ?? ''),
|
'note' => (string) ($assegnazioneNode->note ?? ''),
|
||||||
@@ -824,16 +875,19 @@ class XmlExchange extends Component
|
|||||||
// remember_token no: all'import si azzera comunque, e nel file
|
// remember_token no: all'import si azzera comunque, e nel file
|
||||||
// sarebbe solo una credenziale in più per chi lo tiene.
|
// sarebbe solo una credenziale in più per chi lo tiene.
|
||||||
'roles' => $u->roles->pluck('name')->all(),
|
'roles' => $u->roles->pluck('name')->all(),
|
||||||
|
// Sorvegliante di gruppo: il proclamatore collegato.
|
||||||
|
'proclamatore_id' => $u->proclamatore_id ? (string) $u->proclamatore_id : '',
|
||||||
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
||||||
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
||||||
])->all(),
|
])->all(),
|
||||||
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
'zones' => $this->righeEsportate(Zona::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||||
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
'tipologie' => $this->righeEsportate(Tipologia::query()->orderBy('id'), ['id', 'nome', 'attivo', 'created_at', 'updated_at']),
|
||||||
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at']),
|
'proclamatori' => $this->righeEsportate(Proclamatore::query()->withTrashed()->orderBy('id'), ['id', 'nome', 'cognome', 'attivo', 'gruppo_servizio_id', 'deleted_at', 'created_at', 'updated_at']),
|
||||||
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
|
'gruppi_servizio' => $this->righeEsportate(GruppoServizio::query()->orderBy('id'), ['id', 'nome', 'sorvegliante_id', 'created_at', 'updated_at']),
|
||||||
|
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'gruppo_servizio_id', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
|
||||||
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||||
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
|
||||||
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'pdf_access_code', 'created_at', 'updated_at']),
|
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1232,6 +1286,7 @@ class XmlExchange extends Component
|
|||||||
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
|
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
|
||||||
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
||||||
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
||||||
|
$node->addChild('legacy_proclamatore_id', (string) ($user['proclamatore_id'] ?? ''));
|
||||||
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
|
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
|
||||||
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
||||||
$rolesNode = $node->addChild('roles');
|
$rolesNode = $node->addChild('roles');
|
||||||
@@ -1265,6 +1320,7 @@ class XmlExchange extends Component
|
|||||||
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
|
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
|
||||||
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
|
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
|
||||||
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
|
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
|
||||||
|
$node->addChild('legacy_gruppo_id', (string) ($proclamatore['gruppo_servizio_id'] ?? ''));
|
||||||
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
|
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
|
||||||
$this->addTimestampNodes($node, $proclamatore);
|
$this->addTimestampNodes($node, $proclamatore);
|
||||||
}
|
}
|
||||||
@@ -1280,6 +1336,7 @@ class XmlExchange extends Component
|
|||||||
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
|
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
|
||||||
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
|
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
|
||||||
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
|
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
|
||||||
|
$node->addChild('legacy_gruppo_id', (string) ($territorio['gruppo_servizio_id'] ?? ''));
|
||||||
$this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
|
$this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
|
||||||
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
|
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
|
||||||
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
|
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
|
||||||
@@ -1296,6 +1353,16 @@ class XmlExchange extends Component
|
|||||||
$this->addTimestampNodes($node, $anno);
|
$this->addTimestampNodes($node, $anno);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gruppi di servizio: il sorvegliante è un proclamatore (legacy id).
|
||||||
|
$gruppiNode = $xml->addChild('gruppi_servizio');
|
||||||
|
foreach (($dataset['gruppi_servizio'] ?? []) as $gruppo) {
|
||||||
|
$node = $gruppiNode->addChild('gruppo');
|
||||||
|
$this->addLegacyIdAttribute($node, $gruppo);
|
||||||
|
$this->addXmlText($node, 'nome', (string) ($gruppo['nome'] ?? ''));
|
||||||
|
$node->addChild('legacy_sorvegliante_id', (string) ($gruppo['sorvegliante_id'] ?? ''));
|
||||||
|
$this->addTimestampNodes($node, $gruppo);
|
||||||
|
}
|
||||||
|
|
||||||
$campagneNode = $xml->addChild('campagne');
|
$campagneNode = $xml->addChild('campagne');
|
||||||
foreach ($dataset['campagne'] as $campagna) {
|
foreach ($dataset['campagne'] as $campagna) {
|
||||||
$node = $campagneNode->addChild('campagna');
|
$node = $campagneNode->addChild('campagna');
|
||||||
@@ -1321,6 +1388,8 @@ class XmlExchange extends Component
|
|||||||
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
|
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
|
||||||
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
|
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
|
||||||
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
|
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
|
||||||
|
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? ''));
|
||||||
|
$this->addXmlText($node, 'gruppo_nome', (string) ($assegnazione['gruppo_nome'] ?? ''));
|
||||||
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
|
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
|
||||||
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
|
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
|
||||||
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class TerritorioIndex extends Component
|
|||||||
'tipologia',
|
'tipologia',
|
||||||
'assegnazioneCorrente.proclamatore',
|
'assegnazioneCorrente.proclamatore',
|
||||||
'ultimaAssegnazione',
|
'ultimaAssegnazione',
|
||||||
|
'gruppo',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($this->search) {
|
if ($this->search) {
|
||||||
@@ -179,6 +180,7 @@ class TerritorioIndex extends Component
|
|||||||
if ($this->filterStato) {
|
if ($this->filterStato) {
|
||||||
match ($this->filterStato) {
|
match ($this->filterStato) {
|
||||||
'in_reparto' => $query->inReparto(),
|
'in_reparto' => $query->inReparto(),
|
||||||
|
'in_gruppo' => $query->inGruppo(),
|
||||||
'assegnato' => $query->assegnato(),
|
'assegnato' => $query->assegnato(),
|
||||||
'da_rientrare' => $query->daRientrare(),
|
'da_rientrare' => $query->daRientrare(),
|
||||||
'inattivo' => $query->where('attivo', false),
|
'inattivo' => $query->where('attivo', false),
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ class TerritorioShow extends Component
|
|||||||
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
|
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
|
||||||
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa'])
|
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa'])
|
||||||
->orderByDesc('assigned_at')
|
->orderByDesc('assigned_at')
|
||||||
|
->orderByDesc('id')
|
||||||
->get()
|
->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', [
|
return view('livewire.territori.territorio-show', [
|
||||||
'activeAssignment' => $this->territorio->assegnazioneCorrente,
|
'activeAssignment' => $this->territorio->assegnazioneCorrente,
|
||||||
|
|||||||
@@ -25,8 +25,16 @@ class Assegnazione extends Model
|
|||||||
'note',
|
'note',
|
||||||
'created_by',
|
'created_by',
|
||||||
'returned_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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -35,6 +43,7 @@ class Assegnazione extends Model
|
|||||||
'counted_in_campaign' => 'boolean',
|
'counted_in_campaign' => 'boolean',
|
||||||
'link_sent' => 'boolean',
|
'link_sent' => 'boolean',
|
||||||
'link_da_inviare' => 'boolean',
|
'link_da_inviare' => 'boolean',
|
||||||
|
'gruppo_servizio_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +65,20 @@ class Assegnazione extends Model
|
|||||||
$territorio->update(['prioritario' => false]);
|
$territorio->update(['prioritario' => false]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Al rientro un territorio del pacchetto di un gruppo torna in reparto
|
||||||
|
// ed esce dal pacchetto. Qui e non nella pagina Rientra: vale per ogni
|
||||||
|
// via (rientro del sorvegliante, dell'amministratore, Registro).
|
||||||
|
static::updated(function (self $assegnazione): void {
|
||||||
|
if (! $assegnazione->wasChanged('returned_at') || $assegnazione->returned_at === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Territorio::withTrashed()
|
||||||
|
->whereKey($assegnazione->territorio_id)
|
||||||
|
->whereNotNull('gruppo_servizio_id')
|
||||||
|
->update(['gruppo_servizio_id' => null]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,59 @@ class Proclamatore extends Model
|
|||||||
// Niente email né telefono (tolti il 19 ottobre 2026): il link della
|
// Niente email né telefono (tolti il 19 ottobre 2026): il link della
|
||||||
// piantina lo manda chi assegna, copiandolo o con "Condividi", e il
|
// piantina lo manda chi assegna, copiandolo o con "Condividi", e il
|
||||||
// destinatario lo sceglie dai propri contatti.
|
// destinatario lo sceglie dai propri contatti.
|
||||||
protected $fillable = ['nome', 'cognome', 'attivo'];
|
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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
@@ -24,6 +76,7 @@ class Proclamatore extends Model
|
|||||||
'nome' => 'encrypted',
|
'nome' => 'encrypted',
|
||||||
'cognome' => 'encrypted',
|
'cognome' => 'encrypted',
|
||||||
'attivo' => 'boolean',
|
'attivo' => 'boolean',
|
||||||
|
'gruppo_servizio_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +103,13 @@ class Proclamatore extends Model
|
|||||||
*/
|
*/
|
||||||
public function opzioneScelta(?string $motivo = null): array
|
public function opzioneScelta(?string $motivo = null): array
|
||||||
{
|
{
|
||||||
|
// Il gruppo di servizio come nota, solo se già caricato (opzioniScelta).
|
||||||
|
$gruppo = $this->relationLoaded('gruppo') ? $this->gruppo_etichetta : null;
|
||||||
|
|
||||||
return array_filter([
|
return array_filter([
|
||||||
'valore' => (string) $this->id,
|
'valore' => (string) $this->id,
|
||||||
'etichetta' => $this->nome_completo,
|
'etichetta' => $this->nome_completo,
|
||||||
|
'dettaglio' => $gruppo !== null ? lcfirst($gruppo) : null,
|
||||||
'chiavi' => [(string) $this->cognome, (string) $this->nome],
|
'chiavi' => [(string) $this->cognome, (string) $this->nome],
|
||||||
'motivo' => $motivo,
|
'motivo' => $motivo,
|
||||||
], fn ($v) => $v !== null);
|
], fn ($v) => $v !== null);
|
||||||
@@ -60,10 +117,16 @@ class Proclamatore extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Le opzioni di un elenco di proclamatori, in ordine di cognome e nome.
|
* Le opzioni di un elenco di proclamatori, in ordine di cognome e nome.
|
||||||
* L'ordine si fa qui: in SQL ordinerebbe i testi cifrati.
|
* L'ordine si fa qui: in SQL ordinerebbe i testi cifrati. Con $conGruppo
|
||||||
|
* chi fa parte di un gruppo di servizio lo dice accanto al nome (non serve
|
||||||
|
* dove sono tutti dello stesso gruppo, come in "Il mio gruppo").
|
||||||
*/
|
*/
|
||||||
public static function opzioniScelta(iterable $proclamatori): array
|
public static function opzioniScelta(iterable $proclamatori, bool $conGruppo = true): array
|
||||||
{
|
{
|
||||||
|
if ($conGruppo && $proclamatori instanceof \Illuminate\Database\Eloquent\Collection) {
|
||||||
|
$proclamatori->loadMissing('gruppo');
|
||||||
|
}
|
||||||
|
|
||||||
return collect($proclamatori)
|
return collect($proclamatori)
|
||||||
->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome))
|
->sortBy(fn (self $p) => mb_strtolower($p->cognome . ' ' . $p->nome))
|
||||||
->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo'))
|
->map(fn (self $p) => $p->opzioneScelta($p->attivo ? null : 'non attivo'))
|
||||||
@@ -71,6 +134,22 @@ class Proclamatore extends Model
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Sorvegliante del gruppo Zisa" o "Gruppo Zisa"; null fuori dai gruppi.
|
||||||
|
* Senza gruppo_servizio_id non fa query.
|
||||||
|
*/
|
||||||
|
public function getGruppoEtichettaAttribute(): ?string
|
||||||
|
{
|
||||||
|
$gruppo = $this->gruppo;
|
||||||
|
if (! $gruppo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $gruppo->sorvegliante_id === $this->id
|
||||||
|
? "Sorvegliante del gruppo {$gruppo->nome}"
|
||||||
|
: "Gruppo {$gruppo->nome}";
|
||||||
|
}
|
||||||
|
|
||||||
public function assegnazioni()
|
public function assegnazioni()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
|
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
|
||||||
@@ -95,6 +174,9 @@ class Proclamatore extends Model
|
|||||||
$this->nome = 'Anonimo';
|
$this->nome = 'Anonimo';
|
||||||
$this->cognome = 'Proclamatore #' . $this->id;
|
$this->cognome = 'Proclamatore #' . $this->id;
|
||||||
$this->attivo = false;
|
$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->save();
|
||||||
$this->delete(); // soft delete
|
$this->delete(); // soft delete
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,20 @@ class Territorio extends Model
|
|||||||
'thumbnail_path',
|
'thumbnail_path',
|
||||||
'attivo',
|
'attivo',
|
||||||
'prioritario',
|
'prioritario',
|
||||||
|
'gruppo_servizio_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Il gruppo di servizio nel cui pacchetto si trova, se c'è. */
|
||||||
|
public function gruppo()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(GruppoServizio::class, 'gruppo_servizio_id');
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'attivo' => 'boolean',
|
'attivo' => 'boolean',
|
||||||
|
'gruppo_servizio_id' => 'integer',
|
||||||
'prioritario' => 'boolean',
|
'prioritario' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -95,9 +103,25 @@ class Territorio extends Model
|
|||||||
return 'assegnato';
|
return 'assegnato';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nel pacchetto di un gruppo e non ancora dato a nessuno: non è in
|
||||||
|
// reparto (non si assegna dalla pagina normale), è del gruppo.
|
||||||
|
if ($this->gruppo_servizio_id) {
|
||||||
|
return 'in_gruppo';
|
||||||
|
}
|
||||||
|
|
||||||
return 'in_reparto';
|
return 'in_reparto';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lo stato da mostrare: "In gruppo Zisa" invece di "In gruppo". */
|
||||||
|
public function getStatoEtichettaAttribute(): string
|
||||||
|
{
|
||||||
|
$stato = $this->stato;
|
||||||
|
|
||||||
|
return $stato === 'in_gruppo'
|
||||||
|
? 'In gruppo ' . ($this->gruppo?->nome ?? '')
|
||||||
|
: str_replace('_', ' ', ucfirst($stato));
|
||||||
|
}
|
||||||
|
|
||||||
public function getAssegnatarioAttribute(): ?Proclamatore
|
public function getAssegnatarioAttribute(): ?Proclamatore
|
||||||
{
|
{
|
||||||
return $this->assegnazioneCorrente?->proclamatore;
|
return $this->assegnazioneCorrente?->proclamatore;
|
||||||
@@ -145,6 +169,41 @@ class Territorio extends Model
|
|||||||
return false;
|
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
|
* 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ò
|
* numero, zona e tipologia. Con $motivo compare cercando ma non si può
|
||||||
@@ -172,12 +231,23 @@ class Territorio extends Model
|
|||||||
|
|
||||||
public function scopeInReparto($query)
|
public function scopeInReparto($query)
|
||||||
{
|
{
|
||||||
|
// Quelli nel pacchetto di un gruppo non sono in reparto: stanno al
|
||||||
|
// gruppo finché il sorvegliante non li dà o non li rimanda indietro.
|
||||||
return $query->attivi()
|
return $query->attivi()
|
||||||
|
->whereNull('gruppo_servizio_id')
|
||||||
->whereDoesntHave('assegnazioni', function ($q) {
|
->whereDoesntHave('assegnazioni', function ($q) {
|
||||||
$q->whereNull('returned_at');
|
$q->whereNull('returned_at');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nel pacchetto di un gruppo e non ancora dati a nessuno. */
|
||||||
|
public function scopeInGruppo($query)
|
||||||
|
{
|
||||||
|
return $query->attivi()
|
||||||
|
->whereNotNull('gruppo_servizio_id')
|
||||||
|
->whereDoesntHave('assegnazioni', fn ($q) => $q->whereNull('returned_at'));
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeAssegnato($query)
|
public function scopeAssegnato($query)
|
||||||
{
|
{
|
||||||
return $query->attivi()
|
return $query->attivi()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use Illuminate\Notifications\Notifiable;
|
|||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
use Spatie\Activitylog\Traits\LogsActivity;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
use Spatie\Activitylog\LogOptions;
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
|
* MustVerifyEmail: l'account funziona solo con l'indirizzo confermato
|
||||||
@@ -24,6 +26,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
'password_change_required',
|
'password_change_required',
|
||||||
|
'password_temporanea',
|
||||||
|
'proclamatore_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -37,9 +41,80 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'password_change_required' => 'boolean',
|
'password_change_required' => 'boolean',
|
||||||
|
// 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
|
public function getActivitylogOptions(): LogOptions
|
||||||
{
|
{
|
||||||
return LogOptions::defaults()
|
return LogOptions::defaults()
|
||||||
@@ -48,6 +123,32 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
->dontSubmitEmptyLogs();
|
->dontSubmitEmptyLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'utenza di un sorvegliante porta nome (ed email, spesso) di un
|
||||||
|
* proclamatore: nel registro delle attività i proclamatori compaiono solo
|
||||||
|
* con l'identificativo, quindi anche qui.
|
||||||
|
*/
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
|
||||||
|
{
|
||||||
|
$proclamatoreId = $this->proclamatore_id ?? $this->getOriginal('proclamatore_id');
|
||||||
|
if (! $proclamatoreId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activity->properties = collect($activity->properties)->map(function ($valori) use ($proclamatoreId) {
|
||||||
|
if (! is_array($valori)) {
|
||||||
|
return $valori;
|
||||||
|
}
|
||||||
|
foreach (['name', 'email'] as $campo) {
|
||||||
|
if (array_key_exists($campo, $valori)) {
|
||||||
|
$valori[$campo] = "proclamatore #{$proclamatoreId}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $valori;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
|
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
|
||||||
@@ -65,6 +166,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
*/
|
*/
|
||||||
public function inviaConfermaEmail(): bool
|
public function inviaConfermaEmail(): bool
|
||||||
{
|
{
|
||||||
|
if ($this->isSorvegliante()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->sendEmailVerificationNotification();
|
$this->sendEmailVerificationNotification();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
320
app/Services/GruppiServizio.php
Normal file
320
app/Services/GruppiServizio.php
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<?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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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): Assegnazione
|
||||||
|
{
|
||||||
|
$this->attivo($gruppo);
|
||||||
|
|
||||||
|
// Si decide sui dati di adesso, non su quelli con cui è partita la
|
||||||
|
// pagina: nel frattempo l'amministratore può aver cambiato pacchetto
|
||||||
|
// o membri.
|
||||||
|
$territorio->refresh();
|
||||||
|
$membro->refresh();
|
||||||
|
|
||||||
|
if ($territorio->gruppo_servizio_id !== $gruppo->id) {
|
||||||
|
throw new InvalidArgumentException("Il territorio {$territorio->numero} non è nel pacchetto del gruppo.");
|
||||||
|
}
|
||||||
|
if ($membro->gruppo_servizio_id !== $gruppo->id) {
|
||||||
|
throw new InvalidArgumentException("{$membro->nome_completo} non fa parte del gruppo.");
|
||||||
|
}
|
||||||
|
if (! $membro->attivo) {
|
||||||
|
throw new InvalidArgumentException("{$membro->nome_completo} non è attivo.");
|
||||||
|
}
|
||||||
|
if ($data->isFuture()) {
|
||||||
|
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data) {
|
||||||
|
$bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first();
|
||||||
|
if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) {
|
||||||
|
return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo.";
|
||||||
|
}
|
||||||
|
|
||||||
|
$conflitto = Assegnazione::sovrappostaA($territorio->id, $data);
|
||||||
|
if ($conflitto) {
|
||||||
|
return "Il territorio {$territorio->numero} risulta assegnato {$conflitto->descrizionePeriodo()}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Assegnazione::create([
|
||||||
|
'territorio_id' => $territorio->id,
|
||||||
|
'proclamatore_id' => $membro->id,
|
||||||
|
'anno_teocratico_id' => AnnoTeocratico::perData($data)->id,
|
||||||
|
'assigned_at' => $data,
|
||||||
|
'created_by' => auth()->id(),
|
||||||
|
'gruppo_servizio_id' => $gruppo->id,
|
||||||
|
// Il nome resta anche quando il gruppo, sciolto, non c'è più.
|
||||||
|
'gruppo_nome' => $gruppo->nome,
|
||||||
|
'link_da_inviare' => (bool) $territorio->pdf_path,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (is_string($esito)) {
|
||||||
|
throw new InvalidArgumentException($esito);
|
||||||
|
}
|
||||||
|
|
||||||
|
activity()->causedBy(auth()->user())->performedOn($esito)
|
||||||
|
->withProperties([
|
||||||
|
'territorio' => $territorio->numero,
|
||||||
|
'proclamatore_id' => $membro->id,
|
||||||
|
'gruppo_servizio_id' => $gruppo->id,
|
||||||
|
])
|
||||||
|
->log('assigned');
|
||||||
|
|
||||||
|
return $esito;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scioglie il gruppo (si toglie il ruolo di sorvegliante, 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
\App\Http\Middleware\RequirePasswordChange::class,
|
\App\Http\Middleware\RequirePasswordChange::class,
|
||||||
|
// Utenze disattivate fuori; il sorvegliante di gruppo solo nel suo gruppo.
|
||||||
|
\App\Http\Middleware\LimitaSorvegliante::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -25,6 +25,7 @@ class RolesAndPermissionsSeeder extends Seeder
|
|||||||
'territori.return',
|
'territori.return',
|
||||||
'audit.view',
|
'audit.view',
|
||||||
'audit.export',
|
'audit.export',
|
||||||
|
'gruppo.gestisci',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($permissions as $permission) {
|
foreach ($permissions as $permission) {
|
||||||
@@ -43,7 +44,9 @@ class RolesAndPermissionsSeeder extends Seeder
|
|||||||
|
|
||||||
// Amministratore: all permissions
|
// Amministratore: all permissions
|
||||||
$admin = Role::firstOrCreate(['name' => 'amministratore']);
|
$admin = Role::firstOrCreate(['name' => 'amministratore']);
|
||||||
$admin->givePermissionTo($permissions);
|
// Tutti tranne gruppo.gestisci, che è del solo sorvegliante: l'admin
|
||||||
|
// gestisce i gruppi da settings.manage, non ne ha uno suo.
|
||||||
|
$admin->givePermissionTo(array_values(array_diff($permissions, ['gruppo.gestisci'])));
|
||||||
|
|
||||||
// Assistente: proclamatori, campagne, assign, return
|
// Assistente: proclamatori, campagne, assign, return
|
||||||
$assistente = Role::firstOrCreate(['name' => 'assistente']);
|
$assistente = Role::firstOrCreate(['name' => 'assistente']);
|
||||||
@@ -61,5 +64,10 @@ class RolesAndPermissionsSeeder extends Seeder
|
|||||||
'territori.assign',
|
'territori.assign',
|
||||||
'territori.return',
|
'territori.return',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Sorvegliante di gruppo: solo il proprio gruppo di servizio. Il ruolo
|
||||||
|
// si dà creando un gruppo (Gruppi di servizio), mai dalla pagina Utenti.
|
||||||
|
$sorvegliante = Role::firstOrCreate(['name' => 'sorvegliante']);
|
||||||
|
$sorvegliante->givePermissionTo(['gruppo.gestisci']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
|
||||||
# imposta solo per provare in locale una versione diversa.
|
|
||||||
args:
|
|
||||||
- PHP_VERSION
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
@@ -50,10 +46,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
|
||||||
# imposta solo per provare in locale una versione diversa.
|
|
||||||
args:
|
|
||||||
- PHP_VERSION
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: www-data
|
user: www-data
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -97,10 +89,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
|
||||||
# imposta solo per provare in locale una versione diversa.
|
|
||||||
args:
|
|
||||||
- PHP_VERSION
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: www-data
|
user: www-data
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -219,10 +207,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/php/Dockerfile
|
dockerfile: docker/php/Dockerfile
|
||||||
# Vuota = la versione fissata nel Dockerfile (ARG PHP_VERSION). Si
|
|
||||||
# imposta solo per provare in locale una versione diversa.
|
|
||||||
args:
|
|
||||||
- PHP_VERSION
|
|
||||||
# Senza questo si costruirebbe l'immagine di produzione, che essendo
|
# Senza questo si costruirebbe l'immagine di produzione, che essendo
|
||||||
# installata senza dipendenze di sviluppo non contiene phpunit.
|
# installata senza dipendenze di sviluppo non contiene phpunit.
|
||||||
target: test
|
target: test
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Versione di PHP, fissata alla patch. Va dichiarata prima del primo FROM per
|
|
||||||
# poterla usare nel FROM dello stage `base`.
|
|
||||||
#
|
|
||||||
# Fissata e non "8.3": quel tag si sposta, ma Docker tiene in cache la base già
|
|
||||||
# scaricata, e una ricostruzione senza --pull (anche su Dokploy) poteva uscire
|
|
||||||
# con un PHP vecchio senza che nessuno se ne accorgesse. Con la patch scritta
|
|
||||||
# qui, una build o prende questa versione o fallisce.
|
|
||||||
#
|
|
||||||
# 8.3.36: corregge CVE-2026-109618 e CVE-2026-109619 (esecuzione di codice da
|
|
||||||
# remoto nel core di PHP, CVSS 9.2, rilascio del 21/10/2026). Non scendere
|
|
||||||
# sotto. Aggiornala a ogni rilascio di sicurezza del ramo 8.3.
|
|
||||||
#
|
|
||||||
# Solo per provare in locale prima che l'immagine corretta sia su Docker Hub:
|
|
||||||
# PHP_VERSION=8.3.35 docker compose ... (mai per un deploy).
|
|
||||||
ARG PHP_VERSION=8.3.36
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 1 — PHP dependencies (no dev, production autoloader)
|
# Stage 1 — PHP dependencies (no dev, production autoloader)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -81,7 +65,7 @@ RUN npm run build
|
|||||||
# estensioni e sullo stesso php.ini della produzione: un test che passa qui
|
# estensioni e sullo stesso php.ini della produzione: un test che passa qui
|
||||||
# non puo' passare grazie a un ambiente diverso da quello vero.
|
# non puo' passare grazie a un ambiente diverso da quello vero.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM php:${PHP_VERSION}-fpm AS base
|
FROM php:8.3-fpm AS base
|
||||||
|
|
||||||
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container
|
# nginx + supervisor run in-image alongside php-fpm: a separate nginx container
|
||||||
# would need its own copy of public/ plus the storage volume just to serve
|
# would need its own copy of public/ plus the storage volume just to serve
|
||||||
@@ -92,11 +76,6 @@ FROM php:${PHP_VERSION}-fpm AS base
|
|||||||
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
|
# are named per Debian release (libicu72 vs libicu76, libzip4 vs libzip5), so
|
||||||
# purging means pinning package names that break whenever the php base image
|
# purging means pinning package names that break whenever the php base image
|
||||||
# moves to a new Debian. A few tens of MB is worth not having that breakage.
|
# moves to a new Debian. A few tens of MB is worth not having that breakage.
|
||||||
#
|
|
||||||
# phpredis si scarica da GitHub, versione fissata, e non con `pecl install`:
|
|
||||||
# il 21/10/2026, giorno della patch di sicurezza di PHP, pecl.php.net
|
|
||||||
# rispondeva 504 e la build falliva proprio quando bisognava aggiornare.
|
|
||||||
ARG PHPREDIS_VERSION=6.3.0
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
@@ -122,13 +101,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
intl \
|
intl \
|
||||||
zip \
|
zip \
|
||||||
opcache \
|
opcache \
|
||||||
&& mkdir -p /usr/src/php/ext/redis \
|
&& pecl install redis \
|
||||||
&& curl -fsSL --retry 5 --retry-delay 5 \
|
&& docker-php-ext-enable redis \
|
||||||
"https://github.com/phpredis/phpredis/archive/refs/tags/${PHPREDIS_VERSION}.tar.gz" \
|
|
||||||
| tar xz --strip-components=1 -C /usr/src/php/ext/redis \
|
|
||||||
&& echo redis >> /usr/src/php-available-exts \
|
|
||||||
&& docker-php-ext-install -j"$(nproc)" redis \
|
|
||||||
&& docker-php-source delete \
|
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,6 @@ memory_limit = 256M
|
|||||||
max_execution_time = 120
|
max_execution_time = 120
|
||||||
max_input_vars = 3000
|
max_input_vars = 3000
|
||||||
|
|
||||||
[Session]
|
|
||||||
; Mitigazione di CVE-2026-109618/109619 (RCE nel core di PHP, 21/10/2026),
|
|
||||||
; come indicato da Sansec in attesa della patch, e da tenere anche dopo:
|
|
||||||
; l'avanzamento degli upload nelle sessioni native di PHP non serve
|
|
||||||
; all'applicazione (Laravel ha le sue sessioni, in Redis, e Livewire non lo
|
|
||||||
; usa) ed è il punto da cui passano questi attacchi. Lo strict mode vale solo
|
|
||||||
; per le sessioni native, che qui non si usano: costa niente.
|
|
||||||
session.upload_progress.enabled = 0
|
|
||||||
session.use_strict_mode = 1
|
|
||||||
|
|
||||||
[opcache]
|
[opcache]
|
||||||
opcache.enable = 1
|
opcache.enable = 1
|
||||||
opcache.memory_consumption = 128
|
opcache.memory_consumption = 128
|
||||||
|
|||||||
@@ -1652,6 +1652,18 @@ html { scroll-behavior: smooth; }
|
|||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid var(--tm-bordo) !important;
|
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
|
/* 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. */
|
width and block, so their inner layout isn't squeezed by the flex row. */
|
||||||
table.table-cards td[colspan] {
|
table.table-cards td[colspan] {
|
||||||
|
|||||||
@@ -21,7 +21,15 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@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">
|
<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>
|
<strong>Ripristino da backup eseguito.</strong>
|
||||||
Il pacchetto di backup contiene le password di tutti gli account, quindi
|
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'"
|
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||||
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 lg:z-auto lg:shadow-none lg:border-r lg:border-gray-200 pt-16 lg:pt-0">
|
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 lg:z-auto lg:shadow-none lg:border-r lg:border-gray-200 pt-16 lg:pt-0">
|
||||||
<nav class="mt-4 px-3 space-y-0.5">
|
<nav class="mt-4 px-3 space-y-0.5">
|
||||||
|
{{-- Il sorvegliante di gruppo vede solo il suo gruppo (LimitaSorvegliante).
|
||||||
|
Il ruolo, non @can: l'amministratore passa ogni @can
|
||||||
|
(Gate::before) ma un gruppo suo non ce l'ha. --}}
|
||||||
|
@if(auth()->user()->isSorvegliante())
|
||||||
|
<a href="{{ route('mio-gruppo') }}"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('mio-gruppo*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Il mio gruppo
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@unless(auth()->user()->isSorvegliante() && auth()->user()->roles->count() === 1)
|
||||||
<a href="{{ route('dashboard') }}"
|
<a href="{{ route('dashboard') }}"
|
||||||
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('dashboard') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
|
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('dashboard') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/></svg>
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/></svg>
|
||||||
Home
|
Home
|
||||||
</a>
|
</a>
|
||||||
|
@endunless
|
||||||
|
|
||||||
@can('territori.assign')
|
@can('territori.assign')
|
||||||
<a href="{{ route('territori.index') }}"
|
<a href="{{ route('territori.index') }}"
|
||||||
@@ -146,6 +159,11 @@
|
|||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
Utenti
|
Utenti
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ route('gruppi.index') }}"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('gruppi.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||||
|
Gruppi di servizio
|
||||||
|
</a>
|
||||||
<a href="{{ route('zone.index') }}"
|
<a href="{{ route('zone.index') }}"
|
||||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('zone.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('zone.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
|||||||
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
|
||||||
@@ -8,76 +8,8 @@
|
|||||||
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p>
|
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Invio del link (Assegna::apriInvio): blocca la pagina finché non si
|
@include('livewire.assegnazioni._invio-link')
|
||||||
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
|
|
||||||
|
|
||||||
<div x-data="{ formOpen: true }">
|
<div x-data="{ formOpen: true }">
|
||||||
|
|
||||||
@@ -148,6 +80,9 @@
|
|||||||
|
|
||||||
{{-- Elenco dei territori assegnati: componente a sé, caricato subito
|
{{-- Elenco dei territori assegnati: componente a sé, caricato subito
|
||||||
dopo la pagina (ElencoAssegnati, #[Lazy]). Le azioni del form non
|
dopo la pagina (ElencoAssegnati, #[Lazy]). Le azioni del form non
|
||||||
lo ridisegnano. --}}
|
lo ridisegnano. Non c'è quando si arriva da "Assegna →" della Home
|
||||||
|
(Assegna::$elencoNascosto). --}}
|
||||||
|
@unless($elencoNascosto)
|
||||||
<livewire:assegnazioni.elenco-assegnati />
|
<livewire:assegnazioni.elenco-assegnati />
|
||||||
|
@endunless
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,9 @@
|
|||||||
@endphp
|
@endphp
|
||||||
{{-- Riga compatta: classi brevi (tm-assegnato-*, in app.css), copia con
|
{{-- Riga compatta: classi brevi (tm-assegnato-*, in app.css), copia con
|
||||||
il metodo condiviso copia(): sono ~80 righe, ogni byte si
|
il metodo condiviso copia(): sono ~80 righe, ogni byte si
|
||||||
moltiplica. I pulsanti restano <x-btn> (StileUnificatoTest). --}}
|
moltiplica. I pulsanti restano <x-btn> (StileUnificatoTest).
|
||||||
|
"Rientro" porta alla registrazione del rientro e torna qui
|
||||||
|
(data-ritorno); indirizzo relativo e niente a capo: pesa meno. --}}
|
||||||
<div wire:key="assegnato-{{ $a->id }}" data-cerca="{{ $cerca[$a->id] }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
|
<div wire:key="assegnato-{{ $a->id }}" data-cerca="{{ $cerca[$a->id] }}" x-show="corrisponde($el.dataset.cerca)" class="tm-assegnato">
|
||||||
<div class="tm-assegnato-testa">
|
<div class="tm-assegnato-testa">
|
||||||
<span><a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="tm-assegnato-numero">N° {{ $a->territorio?->numero }}</a> @if($a->territorio?->zona?->nome)<span class="tm-assegnato-zona">{{ $a->territorio->zona->nome }}</span>@endif</span>
|
<span><a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="tm-assegnato-numero">N° {{ $a->territorio?->numero }}</a> @if($a->territorio?->zona?->nome)<span class="tm-assegnato-zona">{{ $a->territorio->zona->nome }}</span>@endif</span>
|
||||||
@@ -50,7 +52,7 @@
|
|||||||
<div class="tm-assegnato-sospeso" role="status"><svg aria-hidden="true"><use href="#tm-i-avviso"/></svg> Link ancora da inviare <x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="$dispatch('apri-invio', { assegnazioneId: {{ $a->id }} })">Invia link</x-btn></div>
|
<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
|
@endif
|
||||||
@if($pdfUrl)
|
@if($pdfUrl)
|
||||||
<div class="tm-assegnato-link" data-link data-id="{{ $a->id }}" data-messaggio="{{ $a->messaggioLink($pdfUrl) }}" data-titolo="Territorio N° {{ $a->territorio?->numero }}">
|
<div class="tm-assegnato-link" data-link data-id="{{ $a->id }}" data-messaggio="{{ $a->messaggioLink($pdfUrl) }}" data-titolo="Territorio N° {{ $a->territorio?->numero }}">@can('territori.return')<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a, false)">Rientro</x-btn>@endcan
|
||||||
<code x-on:click="window.getSelection().selectAllChildren($el)">{{ $pdfUrl }}</code>
|
<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>
|
<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). --}}
|
{{-- Condividi: menu di condivisione del telefono, solo dove il browser lo offre (elencoCercabile.condividi). --}}
|
||||||
@@ -59,7 +61,10 @@
|
|||||||
<x-btn :variante="$a->link_sent ? 'primary' : 'secondary'" tono="tenue" dimensione="sm" wire:click="toggleLinkSent({{ $a->id }})" aria-pressed="{{ $a->link_sent ? 'true' : 'false' }}" title="{{ $a->link_sent ? 'Link inviato — clicca per annullare' : 'Segna come inviato' }}"><svg aria-hidden="true"><use href="{{ $a->link_sent ? '#tm-i-spunta' : '#tm-i-invio' }}"/></svg> {{ $a->link_sent ? 'Link inviato' : 'Non inviato' }}</x-btn>
|
<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>
|
</div>
|
||||||
@else
|
@else
|
||||||
<p class="tm-assegnato-senza">Nessun PDF disponibile</p>
|
<div class="tm-assegnato-link">
|
||||||
|
@can('territori.return')<x-btn data-ritorno variante="primary" dimensione="sm" :href="route('assegnazioni.rientra', $a, false)">Rientro</x-btn>@endcan
|
||||||
|
<p class="tm-assegnato-senza">Nessun PDF disponibile</p>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="{{ $ritorno ?? route('territori.show', $assegnazione->territorio) }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
<a href="{{ $ritorno ?? $this->paginaPredefinita() }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||||
{{ $ritorno ? 'Torna indietro' : 'Torna al territorio' }}
|
{{ $ritorno ? 'Torna indietro' : (auth()->user()->can('territori.assign') ? 'Torna al territorio' : 'Torna al mio gruppo') }}
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3 pt-4">
|
<div class="flex flex-wrap items-center gap-3 pt-4">
|
||||||
<x-btn variante="primary" type="submit">Registra rientro</x-btn>
|
<x-btn variante="primary" type="submit">Registra rientro</x-btn>
|
||||||
<x-btn variante="secondary" :href="$ritorno ?? route('territori.show', $assegnazione->territorio)">Annulla</x-btn>
|
<x-btn variante="secondary" :href="$ritorno ?? $this->paginaPredefinita()">Annulla</x-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
160
resources/views/livewire/gruppi/mio-gruppo.blade.php
Normal file
160
resources/views/livewire/gruppi/mio-gruppo.blade.php
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<div>
|
||||||
|
{{-- Il mio gruppo (MioGruppo): la sola pagina del sorvegliante di gruppo.
|
||||||
|
Pensata prima per il telefono: schede una sotto l'altra, pulsanti
|
||||||
|
grandi. Copia, Condividi e ricerca vengono da elencoCercabile
|
||||||
|
(resources/js/app.js), l'invio del link dal partial _invio-link. --}}
|
||||||
|
@if(! $gruppo)
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 text-sm text-gray-600">
|
||||||
|
Non sei il sorvegliante di un gruppo di servizio attivo.
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mb-5">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Gruppo {{ $gruppo->nome }}</h1>
|
||||||
|
@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><span class="tm-assegnato-numero">N° {{ $t->numero }}</span> @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif
|
||||||
|
@if($t->in_priorita)<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>@endif</span>
|
||||||
|
@if($a)
|
||||||
|
<span class="tm-assegnato-info"><b>{{ $a->proclamatore?->nome_completo }}</b> · dal {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
|
||||||
|
@else
|
||||||
|
<span class="tm-assegnato-info"><b>Da dare</b>@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>
|
||||||
|
<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>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -110,19 +110,19 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a data-ritorno href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
|
<a data-ritorno href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600 transition-colors">N° {{ $t->numero }}</a>
|
||||||
@if($t->home_is_prioritario)
|
@if($t->in_priorita)
|
||||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide badge-pulse" style="background:#fef3c7;color:#92400e">★ Prioritario</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500">
|
||||||
{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}
|
{{ $t->zona?->nome }} — {{ $t->tipologia?->nome }}
|
||||||
@if($t->home_giorni_giacenza > 0)
|
@if($t->giacenza_giorni > 0)
|
||||||
— <span class="font-medium">{{ $t->home_giorni_giacenza }} gg</span> in reparto
|
— <span class="font-medium">{{ $t->giacenza_giorni }} gg</span> in reparto
|
||||||
@endif
|
@endif
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@can('territori.assign')
|
@can('territori.assign')
|
||||||
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $t->id])">Assegna →</x-btn>
|
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.assegna', ['territorioId' => $t->id, 'dalla_home' => 1])">Assegna →</x-btn>
|
||||||
@endcan
|
@endcan
|
||||||
</li>
|
</li>
|
||||||
@empty
|
@empty
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Dati trattati</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-2">Dati trattati</h2>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
<li><strong>Proclamatori</strong>: solo nome e cognome, cifrati nel database ({{ $cifratura }}), e se sono attivi. Nessun numero di telefono né indirizzo email.</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>Territori e piantine</strong>: numero, zona, tipologia, confini, mappe e PDF — nessun dato personale.</li>
|
||||||
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio, con date di assegnazione e rientro, conteggio nelle campagne, codice del link alla piantina e stato del suo invio (inviato o no, e con quale canale).</li>
|
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio, con date di assegnazione e rientro, conteggio nelle campagne, codice del link alla piantina, stato del suo invio (inviato o no, e con quale canale) e, se l'ha data il sorvegliante di un gruppo di servizio, il nome del gruppo.</li>
|
||||||
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo.</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>
|
<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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
|
<li><strong>{{ ucfirst($ruolo['nome']) }}</strong>: {{ $ruolo['permessi'] ? implode('; ', $ruolo['permessi']) : 'nessun permesso' }}.</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato.</p>
|
<p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email fittizia e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile.</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>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>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>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
|
||||||
|
|||||||
@@ -25,6 +25,12 @@
|
|||||||
<input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
<input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||||
<label for="attivo" class="text-sm text-gray-700">Attivo</label>
|
<label for="attivo" class="text-sm text-gray-700">Attivo</label>
|
||||||
</div>
|
</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">
|
<div class="flex items-center gap-3 pt-4">
|
||||||
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>
|
<x-btn variante="primary" type="submit">{{ $btnLabel }}</x-btn>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<option value="">Tutti</option>
|
<option value="">Tutti</option>
|
||||||
<option value="attivo">Attivi</option>
|
<option value="attivo">Attivi</option>
|
||||||
<option value="inattivo">Inattivi</option>
|
<option value="inattivo">Inattivi</option>
|
||||||
|
<option value="gruppo">In un gruppo di servizio</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,11 +54,16 @@
|
|||||||
<td data-label="Cognome" class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td>
|
<td data-label="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="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">
|
<td data-label="Stato" class="px-4 py-3 text-sm">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
@if($proclamatore->attivo)
|
@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>
|
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attivo</span>
|
||||||
@else
|
@else
|
||||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
|
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
|
||||||
@endif
|
@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>
|
||||||
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-right cell-actions">
|
<td class="px-4 py-3 text-sm text-right cell-actions">
|
||||||
@@ -73,6 +79,15 @@
|
|||||||
title="Modifica" aria-label="Modifica il proclamatore">
|
title="Modifica" aria-label="Modifica il proclamatore">
|
||||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||||
</x-btn>
|
</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
|
<x-btn variante="secondary" tono="tenue" dimensione="sm" icona
|
||||||
wire:click="toggleActive({{ $proclamatore->id }})"
|
wire:click="toggleActive({{ $proclamatore->id }})"
|
||||||
title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}"
|
title="{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}"
|
||||||
@@ -83,9 +98,10 @@
|
|||||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
<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
|
@endif
|
||||||
</x-btn>
|
</x-btn>
|
||||||
|
@endif
|
||||||
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
|
<x-btn variante="danger" tono="tenue" dimensione="sm" icona
|
||||||
wire:click="deleteProclamatore({{ $proclamatore->id }})"
|
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">
|
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>
|
<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>
|
</x-btn>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
|
{{ $ritorno ? 'Torna indietro' : 'Torna alla lista' }}
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
|
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
|
||||||
|
@if($proclamatore->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>
|
</div>
|
||||||
@can('settings.manage')
|
@can('settings.manage')
|
||||||
<x-btn data-ritorno variante="secondary" :href="route('proclamatori.edit', $proclamatore)">
|
<x-btn data-ritorno variante="secondary" :href="route('proclamatori.edit', $proclamatore)">
|
||||||
@@ -36,56 +39,118 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 --}}
|
{{-- Assignment history --}}
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<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>
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Storico Assegnazioni</h3>
|
||||||
|
|
||||||
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni)
|
{{-- Come nella scheda del territorio: una tabella sola, colonne fisse,
|
||||||
<div class="mb-6">
|
l'anno come riga di separazione (tm-riga-gruppo), dal più recente. --}}
|
||||||
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4>
|
@if($assegnazioniPerAnno->isEmpty())
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100">
|
|
||||||
@foreach($assegnazioni as $assegnazione)
|
|
||||||
<tr>
|
|
||||||
<td data-label="Territorio" class="px-3 py-2">
|
|
||||||
<a data-ritorno href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">
|
|
||||||
{{ $assegnazione->territorio?->numero ?? 'N/A' }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
|
||||||
<td data-label="Rientrato" class="px-3 py-2">
|
|
||||||
@if($assegnazione->returned_at)
|
|
||||||
{{ $assegnazione->returned_at->format('d/m/Y') }}
|
|
||||||
@else
|
|
||||||
<span class="text-amber-600 font-medium">In corso</span>
|
|
||||||
@endif
|
|
||||||
</td>
|
|
||||||
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</td>
|
|
||||||
<td data-label="Campagna" class="px-3 py-2">
|
|
||||||
@if($assegnazione->counted_in_campaign)
|
|
||||||
<span class="text-green-600">{{ $assegnazione->campagna?->descrizione }}</span>
|
|
||||||
@else
|
|
||||||
<span class="text-gray-400">-</span>
|
|
||||||
@endif
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@empty
|
|
||||||
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
|
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
|
||||||
@endforelse
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full table-fixed text-sm table-cards">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-[36%] px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
|
||||||
|
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
|
||||||
|
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
|
||||||
|
<th class="w-[9%] px-3 py-2 text-right text-xs font-medium text-gray-500">Giorni</th>
|
||||||
|
<th class="w-[25%] px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
@foreach($assegnazioniPerAnno as $annoLabel => $assegnazioni)
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<tr class="tm-riga-gruppo">
|
||||||
|
<th colspan="5" scope="colgroup" class="px-3 pt-5 pb-2 text-left text-sm font-semibold text-indigo-600 bg-white">Anno Teocratico {{ $annoLabel }}</th>
|
||||||
|
</tr>
|
||||||
|
@foreach($assegnazioni as $assegnazione)
|
||||||
|
<tr>
|
||||||
|
<td data-label="Territorio" class="px-3 py-2">
|
||||||
|
<a data-ritorno href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $assegnazione->territorio?->numero ?? 'N/A' }}</a>@if($assegnazione->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo_nome }}</span>@endif
|
||||||
|
</td>
|
||||||
|
<td data-label="Assegnato" class="px-3 py-2 tabular-nums">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||||
|
<td data-label="Rientrato" class="px-3 py-2 tabular-nums">
|
||||||
|
@if($assegnazione->returned_at)
|
||||||
|
{{ $assegnazione->returned_at->format('d/m/Y') }}
|
||||||
|
@else
|
||||||
|
<span class="text-amber-600 font-medium">In corso</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td data-label="Giorni" class="px-3 py-2 text-right tabular-nums">{{ $assegnazione->giorni }}</td>
|
||||||
|
<td data-label="Campagna" class="px-3 py-2">
|
||||||
|
@if($assegnazione->counted_in_campaign)
|
||||||
|
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
|
||||||
|
@elseif($assegnazione->counted_in_campaign === false)
|
||||||
|
<span class="text-gray-400">No</span>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-300">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
@endforeach
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<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>
|
<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>
|
</a>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Il mio profilo</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Il mio profilo</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,10 +25,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 --}}
|
{{-- Dati profilo --}}
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<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>
|
<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'))
|
@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">
|
<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">
|
<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
|
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
|
<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">
|
<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
|
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<x-btn variante="primary" type="submit">Salva modifiche</x-btn>
|
<x-btn variante="primary" type="submit">Salva modifiche</x-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}@if($a->gruppo_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="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
|
||||||
<td data-label="Rientrato" class="px-3 py-2">
|
<td data-label="Rientrato" class="px-3 py-2">
|
||||||
@if($a->returned_at)
|
@if($a->returned_at)
|
||||||
|
|||||||
@@ -129,6 +129,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2 cell-actions">
|
<td class="px-4 py-2 cell-actions">
|
||||||
|
@if($user->proclamatore_id)
|
||||||
|
{{-- Utenza di un sorvegliante di gruppo: si gestisce dal gruppo. --}}
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
Sorvegliante: si gestisce da Gruppi di servizio
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
|
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="startEdit({{ $user->id }})">Modifica</x-btn>
|
||||||
@unless($user->hasVerifiedEmail())
|
@unless($user->hasVerifiedEmail())
|
||||||
@@ -145,6 +151,7 @@
|
|||||||
Elimina
|
Elimina
|
||||||
</x-btn>
|
</x-btn>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</td>
|
</td>
|
||||||
@endif
|
@endif
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
<select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
<select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
<option value="">Tutti gli stati</option>
|
<option value="">Tutti gli stati</option>
|
||||||
<option value="in_reparto">In reparto</option>
|
<option value="in_reparto">In reparto</option>
|
||||||
|
<option value="in_gruppo">In un gruppo di servizio</option>
|
||||||
<option value="prioritari">Prioritari</option>
|
<option value="prioritari">Prioritari</option>
|
||||||
<option value="assegnato">Assegnato</option>
|
<option value="assegnato">Assegnato</option>
|
||||||
<option value="da_rientrare">Da rientrare</option>
|
<option value="da_rientrare">Da rientrare</option>
|
||||||
@@ -126,12 +127,13 @@
|
|||||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
|
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
{{ match($stato) {
|
{{ match($stato) {
|
||||||
'in_reparto' => 'bg-green-100 text-green-800',
|
'in_reparto' => 'bg-green-100 text-green-800',
|
||||||
|
'in_gruppo' => 'bg-purple-100 text-purple-800',
|
||||||
'assegnato' => 'bg-blue-100 text-blue-800',
|
'assegnato' => 'bg-blue-100 text-blue-800',
|
||||||
'da_rientrare' => 'bg-red-100 text-red-800',
|
'da_rientrare' => 'bg-red-100 text-red-800',
|
||||||
'inattivo' => 'bg-gray-100 text-gray-600',
|
'inattivo' => 'bg-gray-100 text-gray-600',
|
||||||
default => 'bg-gray-100 text-gray-600'
|
default => 'bg-gray-100 text-gray-600'
|
||||||
} }}">
|
} }}">
|
||||||
{{ str_replace('_', ' ', ucfirst($stato)) }}
|
{{ $territorio->stato_etichetta }}
|
||||||
</span>
|
</span>
|
||||||
@if($territorio->is_prioritario)
|
@if($territorio->is_prioritario)
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">
|
||||||
|
|||||||
@@ -55,12 +55,13 @@
|
|||||||
<span class="inline-flex items-center mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium
|
<span class="inline-flex items-center mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
{{ match($stato) {
|
{{ match($stato) {
|
||||||
'in_reparto' => 'bg-green-100 text-green-800',
|
'in_reparto' => 'bg-green-100 text-green-800',
|
||||||
|
'in_gruppo' => 'bg-purple-100 text-purple-800',
|
||||||
'assegnato' => 'bg-blue-100 text-blue-800',
|
'assegnato' => 'bg-blue-100 text-blue-800',
|
||||||
'da_rientrare' => 'bg-red-100 text-red-800',
|
'da_rientrare' => 'bg-red-100 text-red-800',
|
||||||
'inattivo' => 'bg-gray-100 text-gray-600',
|
'inattivo' => 'bg-gray-100 text-gray-600',
|
||||||
default => 'bg-gray-100 text-gray-600'
|
default => 'bg-gray-100 text-gray-600'
|
||||||
} }}">
|
} }}">
|
||||||
{{ str_replace('_', ' ', ucfirst($stato)) }}
|
{{ $territorio->stato_etichetta }}
|
||||||
</span>
|
</span>
|
||||||
@if($territorio->is_prioritario)
|
@if($territorio->is_prioritario)
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">★ Prioritario</span>
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">★ Prioritario</span>
|
||||||
@@ -160,50 +161,56 @@
|
|||||||
<h3 class="text-lg font-semibold text-gray-900">Storico Assegnazioni</h3>
|
<h3 class="text-lg font-semibold text-gray-900">Storico Assegnazioni</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni)
|
{{-- Una tabella sola, colonne a larghezza fissa: con una tabella per
|
||||||
<div class="mb-6">
|
anno ciascuna si dimensionava sul suo contenuto e le colonne non
|
||||||
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4>
|
erano in linea. L'anno è una riga di separazione (tm-riga-gruppo).
|
||||||
<div class="overflow-x-auto">
|
Ordine: dal più recente, per anno e per data di assegnazione. --}}
|
||||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
@if($assegnazioniPerAnno->isEmpty())
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
|
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100">
|
|
||||||
@foreach($assegnazioni as $assegnazione)
|
|
||||||
<tr>
|
|
||||||
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
|
||||||
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
|
||||||
<td data-label="Rientrato" class="px-3 py-2">
|
|
||||||
@if($assegnazione->returned_at)
|
|
||||||
{{ $assegnazione->returned_at->format('d/m/Y') }}
|
|
||||||
@else
|
|
||||||
<span class="text-amber-600 font-medium">In corso</span>
|
|
||||||
@endif
|
|
||||||
</td>
|
|
||||||
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</td>
|
|
||||||
<td data-label="Campagna" class="px-3 py-2">
|
|
||||||
@if($assegnazione->counted_in_campaign)
|
|
||||||
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
|
|
||||||
@elseif($assegnazione->counted_in_campaign === false)
|
|
||||||
<span class="text-gray-400">No</span>
|
|
||||||
@else
|
|
||||||
<span class="text-gray-300">-</span>
|
|
||||||
@endif
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@empty
|
|
||||||
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
|
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
|
||||||
@endforelse
|
@else
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full table-fixed text-sm table-cards">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-[36%] px-3 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
|
||||||
|
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
|
||||||
|
<th class="w-[15%] px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
|
||||||
|
<th class="w-[9%] px-3 py-2 text-right text-xs font-medium text-gray-500">Giorni</th>
|
||||||
|
<th class="w-[25%] px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
@foreach($assegnazioniPerAnno as $annoLabel => $assegnazioni)
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<tr class="tm-riga-gruppo">
|
||||||
|
<th colspan="5" scope="colgroup" class="px-3 pt-5 pb-2 text-left text-sm font-semibold text-indigo-600 bg-white">Anno Teocratico {{ $annoLabel }}</th>
|
||||||
|
</tr>
|
||||||
|
@foreach($assegnazioni as $assegnazione)
|
||||||
|
<tr>
|
||||||
|
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}@if($assegnazione->gruppo_nome)<span class="ml-1 inline-flex rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">via gruppo {{ $assegnazione->gruppo_nome }}</span>@endif</td>
|
||||||
|
<td data-label="Assegnato" class="px-3 py-2 tabular-nums">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||||
|
<td data-label="Rientrato" class="px-3 py-2 tabular-nums">
|
||||||
|
@if($assegnazione->returned_at)
|
||||||
|
{{ $assegnazione->returned_at->format('d/m/Y') }}
|
||||||
|
@else
|
||||||
|
<span class="text-amber-600 font-medium">In corso</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td data-label="Giorni" class="px-3 py-2 text-right tabular-nums">{{ $assegnazione->giorni }}</td>
|
||||||
|
<td data-label="Campagna" class="px-3 py-2">
|
||||||
|
@if($assegnazione->counted_in_campaign)
|
||||||
|
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
|
||||||
|
@elseif($assegnazione->counted_in_campaign === false)
|
||||||
|
<span class="text-gray-400">No</span>
|
||||||
|
@else
|
||||||
|
<span class="text-gray-300">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
@endforeach
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,9 +44,9 @@
|
|||||||
<td style="font-weight:bold">{{ $t->numero }}</td>
|
<td style="font-weight:bold">{{ $t->numero }}</td>
|
||||||
<td>{{ $t->zona?->nome ?? '-' }}</td>
|
<td>{{ $t->zona?->nome ?? '-' }}</td>
|
||||||
<td>{{ $t->tipologia?->nome ?? '-' }}</td>
|
<td>{{ $t->tipologia?->nome ?? '-' }}</td>
|
||||||
<td>{{ $t->home_giorni_giacenza }} gg</td>
|
<td>{{ $t->giacenza_giorni }} gg</td>
|
||||||
<td>
|
<td>
|
||||||
@if($t->home_is_prioritario)
|
@if($t->in_priorita)
|
||||||
<span class="prioritario">★ Prioritario</span>
|
<span class="prioritario">★ Prioritario</span>
|
||||||
@else
|
@else
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ use App\Livewire\Proclamatori\ProclamatoreEdit;
|
|||||||
use App\Livewire\Proclamatori\ProclamatoreCestino;
|
use App\Livewire\Proclamatori\ProclamatoreCestino;
|
||||||
use App\Livewire\Assegnazioni\Assegna;
|
use App\Livewire\Assegnazioni\Assegna;
|
||||||
use App\Livewire\Assegnazioni\Rientra;
|
use App\Livewire\Assegnazioni\Rientra;
|
||||||
|
use App\Livewire\Gruppi\GruppiIndex;
|
||||||
|
use App\Livewire\Gruppi\GruppoShow;
|
||||||
|
use App\Livewire\Gruppi\MioGruppo;
|
||||||
use App\Livewire\Campagne\CampagnaIndex;
|
use App\Livewire\Campagne\CampagnaIndex;
|
||||||
use App\Livewire\Campagne\CampagnaCreate;
|
use App\Livewire\Campagne\CampagnaCreate;
|
||||||
use App\Livewire\Campagne\CampagnaShow;
|
use App\Livewire\Campagne\CampagnaShow;
|
||||||
@@ -163,10 +166,20 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::middleware('permission:territori.assign')->group(function () {
|
Route::middleware('permission:territori.assign')->group(function () {
|
||||||
Route::get('assegnazioni/assegna/{territorioId?}', Assegna::class)->name('assegnazioni.assegna');
|
Route::get('assegnazioni/assegna/{territorioId?}', Assegna::class)->name('assegnazioni.assegna');
|
||||||
});
|
});
|
||||||
Route::middleware('permission:territori.return')->group(function () {
|
// Il rientro lo registra anche il sorvegliante di gruppo, ma solo per il
|
||||||
|
// suo gruppo: il controllo fine è in Rientra::autorizza().
|
||||||
|
Route::middleware('permission:territori.return|gruppo.gestisci')->group(function () {
|
||||||
Route::get('assegnazioni/{assegnazione}/rientra', Rientra::class)->name('assegnazioni.rientra');
|
Route::get('assegnazioni/{assegnazione}/rientra', Rientra::class)->name('assegnazioni.rientra');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Il mio gruppo: la sola pagina del sorvegliante di gruppo (vedi anche
|
||||||
|
// il middleware LimitaSorvegliante).
|
||||||
|
Route::middleware('permission:gruppo.gestisci')->group(function () {
|
||||||
|
Route::get('mio-gruppo', MioGruppo::class)->name('mio-gruppo');
|
||||||
|
Route::get('mio-gruppo/territori/{territorio}/pdf', [TerritorioFileController::class, 'pdfGruppo'])
|
||||||
|
->name('mio-gruppo.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
// Campagne — visualizzazione per chi ha campagne.manage; sola lettura per i
|
// Campagne — visualizzazione per chi ha campagne.manage; sola lettura per i
|
||||||
// non-admin.
|
// non-admin.
|
||||||
Route::middleware('permission:campagne.manage')->group(function () {
|
Route::middleware('permission:campagne.manage')->group(function () {
|
||||||
@@ -200,6 +213,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::middleware('permission:settings.manage')->group(function () {
|
Route::middleware('permission:settings.manage')->group(function () {
|
||||||
Route::get('impostazioni', SettingsEdit::class)->name('settings.edit');
|
Route::get('impostazioni', SettingsEdit::class)->name('settings.edit');
|
||||||
Route::get('utenti', UsersIndex::class)->name('users.index');
|
Route::get('utenti', UsersIndex::class)->name('users.index');
|
||||||
|
Route::get('gruppi', GruppiIndex::class)->name('gruppi.index');
|
||||||
|
Route::get('gruppi/{gruppo}', GruppoShow::class)->name('gruppi.show');
|
||||||
Route::get('zone', ZoneIndex::class)->name('zone.index');
|
Route::get('zone', ZoneIndex::class)->name('zone.index');
|
||||||
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
|
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
|
||||||
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');
|
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');
|
||||||
|
|||||||
768
tests/Feature/GruppiServizioTest.php
Normal file
768
tests/Feature/GruppiServizioTest.php
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Assegnazioni\Assegna;
|
||||||
|
use App\Livewire\Assegnazioni\Rientra;
|
||||||
|
use App\Livewire\Gruppi\GruppiIndex;
|
||||||
|
use App\Livewire\Gruppi\GruppoShow;
|
||||||
|
use App\Livewire\Gruppi\MioGruppo;
|
||||||
|
use App\Livewire\Settings\UsersIndex;
|
||||||
|
use App\Livewire\Settings\XmlExchange;
|
||||||
|
use App\Models\AnnoTeocratico;
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\GruppiServizio;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gruppi di servizio (19 ottobre 2026): un sorvegliante con un'utenza
|
||||||
|
* limitata al proprio gruppo distribuisce ai membri i territori del pacchetto.
|
||||||
|
*/
|
||||||
|
class GruppiServizioTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutVite();
|
||||||
|
Storage::fake('public');
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
||||||
|
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
|
||||||
|
|
||||||
|
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
|
||||||
|
$this->admin->assignRole('amministratore');
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function proclamatore(string $cognome, string $nome = 'Test'): Proclamatore
|
||||||
|
{
|
||||||
|
return Proclamatore::create(['nome' => $nome, 'cognome' => $cognome, 'attivo' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function territorio(string $numero, bool $pdf = true): Territorio
|
||||||
|
{
|
||||||
|
if ($pdf) {
|
||||||
|
Storage::disk('public')->put("territori-pdf/t{$numero}.pdf", '%PDF-1.4');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => $pdf ? "territori-pdf/t{$numero}.pdf" : null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gruppo "Zisa": sorvegliante Rossi, membri Verdi e Bianchi, pacchetto 12, 14, 35. */
|
||||||
|
private function gruppo(): array
|
||||||
|
{
|
||||||
|
$rossi = $this->proclamatore('Rossi', 'Mario');
|
||||||
|
$verdi = $this->proclamatore('Verdi', 'Anna');
|
||||||
|
$bianchi = $this->proclamatore('Bianchi', 'Luca');
|
||||||
|
[$gruppo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi, [$verdi->id, $bianchi->id]);
|
||||||
|
$territori = collect(['12', '14', '35'])->map(fn ($n) => $this->territorio($n));
|
||||||
|
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, $territori->pluck('id')->all());
|
||||||
|
$territori = $territori->map->fresh();
|
||||||
|
|
||||||
|
$sorvegliante = User::where('email', 'mrossi@termanager.it')->sole();
|
||||||
|
// Il cambio password al primo accesso ha i suoi test: qui si lavora.
|
||||||
|
// E l'email non confermata, come nella realtà (TestCase le conferma).
|
||||||
|
$sorvegliante->forceFill(['password_change_required' => false, 'email_verified_at' => null])->save();
|
||||||
|
|
||||||
|
return compact('gruppo', 'password', 'rossi', 'verdi', 'bianchi', 'territori', 'sorvegliante');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Creazione ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_creare_un_gruppo_da_un_utenza_limitata_al_sorvegliante(): void
|
||||||
|
{
|
||||||
|
$rossi = $this->proclamatore('Rossi', 'Mario');
|
||||||
|
|
||||||
|
// L'email non si chiede: è fittizia, iniziale del nome e cognome.
|
||||||
|
$pagina = Livewire::test(GruppiIndex::class)
|
||||||
|
->assertDontSee('Email per l')
|
||||||
|
->set('nome', 'Zisa')
|
||||||
|
->set('sorveglianteId', $rossi->id)
|
||||||
|
->call('crea')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSee('mrossi@termanager.it');
|
||||||
|
|
||||||
|
$gruppo = GruppoServizio::sole();
|
||||||
|
$utente = User::where('email', 'mrossi@termanager.it')->sole();
|
||||||
|
|
||||||
|
$this->assertSame($rossi->id, $gruppo->sorvegliante_id);
|
||||||
|
$this->assertSame($gruppo->id, $rossi->fresh()->gruppo_servizio_id, 'Il sorvegliante è anche membro');
|
||||||
|
$this->assertSame($rossi->id, $utente->proclamatore_id);
|
||||||
|
$this->assertTrue($utente->hasRole('sorvegliante'));
|
||||||
|
$this->assertTrue($utente->password_change_required);
|
||||||
|
// Nessuna conferma dell'email per il sorvegliante (TestCase conferma
|
||||||
|
// gli utenti creati: qui si toglie).
|
||||||
|
$utente->forceFill(['email_verified_at' => null])->save();
|
||||||
|
$this->assertTrue($utente->fresh()->hasVerifiedEmail());
|
||||||
|
|
||||||
|
// La password temporanea si vede una volta, ed è quella vera.
|
||||||
|
$password = $pagina->get('passwordTemporanea');
|
||||||
|
$this->assertNotEmpty($password);
|
||||||
|
$pagina->assertSee($password);
|
||||||
|
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_l_email_fittizia_viene_da_iniziale_del_nome_e_cognome(): void
|
||||||
|
{
|
||||||
|
$email = fn (string $cognome, string $nome) => GruppiServizio::emailFittizia($this->proclamatore($cognome, $nome));
|
||||||
|
|
||||||
|
$this->assertSame('ffontana@termanager.it', $email('Fontana', 'Francesco'));
|
||||||
|
$this->assertSame('fdangelo@termanager.it', $email("D'Angelo", 'Francesco'));
|
||||||
|
$this->assertSame('ndeluca@termanager.it', $email('De Luca', 'Niccolò'));
|
||||||
|
|
||||||
|
// Già presa da un altro utente: un numero in coda.
|
||||||
|
User::create(['name' => 'Francesco Fontana', 'email' => 'ffontana@termanager.it', 'password' => 'password-di-prova']);
|
||||||
|
$this->assertSame('ffontana2@termanager.it', $email('Fontana', 'Federica'));
|
||||||
|
|
||||||
|
// Due gruppi con lo stesso nome no.
|
||||||
|
app(GruppiServizio::class)->crea('Zisa', $this->proclamatore('Gialli'));
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
app(GruppiServizio::class)->crea('Zisa', $this->proclamatore('Blu'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_proclamatore_sta_in_un_solo_gruppo(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $zisa, 'verdi' => $verdi] = $this->gruppo();
|
||||||
|
[$noce] = app(GruppiServizio::class)->crea('Noce', $this->proclamatore('Neri'));
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
app(GruppiServizio::class)->aggiungiMembri($noce, [$verdi->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_ruolo_sorvegliante_non_si_da_dalla_pagina_utenti(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $sorvegliante] = $this->gruppo();
|
||||||
|
|
||||||
|
$utenti = Livewire::test(UsersIndex::class);
|
||||||
|
$this->assertNotContains('sorvegliante', $utenti->get('availableRoles'));
|
||||||
|
|
||||||
|
$utenti->call('startEdit', $sorvegliante->id)->assertSet('editingUserId', null);
|
||||||
|
$utenti->call('deleteUser', $sorvegliante->id);
|
||||||
|
$this->assertNotNull($sorvegliante->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pacchetto ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_il_pacchetto_non_e_un_assegnazione_e_non_e_in_reparto(): void
|
||||||
|
{
|
||||||
|
['territori' => $territori] = $this->gruppo();
|
||||||
|
$libero = $this->territorio('99');
|
||||||
|
|
||||||
|
$this->assertSame(0, Assegnazione::count(), 'Il pacchetto non crea assegnazioni: medie e statistiche non cambiano');
|
||||||
|
$this->assertSame([$libero->id], Territorio::inReparto()->pluck('id')->all());
|
||||||
|
$this->assertSame('in_gruppo', $territori[0]->fresh()->stato);
|
||||||
|
$this->assertSame('In gruppo Zisa', $territori[0]->fresh()->stato_etichetta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dalla_pagina_assegna_un_territorio_del_pacchetto_non_si_assegna(): void
|
||||||
|
{
|
||||||
|
['territori' => $territori] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::test(Assegna::class)
|
||||||
|
->set('territorio_id', $territori[0]->id)
|
||||||
|
->set('proclamatore_id', $this->proclamatore('Neri')->id)
|
||||||
|
->call('save')
|
||||||
|
->assertHasErrors('territorio_id');
|
||||||
|
|
||||||
|
$this->assertSame(0, Assegnazione::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_un_territorio_assegnato_non_entra_nel_pacchetto(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo] = $this->gruppo();
|
||||||
|
$t = $this->territorio('50');
|
||||||
|
Assegnazione::create([
|
||||||
|
'territorio_id' => $t->id, 'proclamatore_id' => $this->proclamatore('Neri')->id,
|
||||||
|
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$t->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Accesso del sorvegliante ───────────────────────────────
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_vede_solo_il_suo_gruppo_e_il_profilo(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $sorvegliante] = $this->gruppo();
|
||||||
|
$this->flushSession();
|
||||||
|
$this->actingAs($sorvegliante);
|
||||||
|
|
||||||
|
$this->get(route('dashboard'))->assertRedirect(route('mio-gruppo'));
|
||||||
|
$this->get(route('mio-gruppo'))->assertOk()->assertSee('Gruppo Zisa')->assertSee('N° 12');
|
||||||
|
$this->get(route('profile'))->assertOk();
|
||||||
|
|
||||||
|
foreach (['territori.index', 'proclamatori.index', 'registro.index', 'campagne.index', 'assegnazioni.assegna', 'users.index', 'gruppi.index', 'privacy'] as $rotta) {
|
||||||
|
$this->get(route($rotta))->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_l_amministratore_non_ha_la_voce_il_mio_gruppo(): void
|
||||||
|
{
|
||||||
|
$this->gruppo();
|
||||||
|
|
||||||
|
// Passa ogni @can (Gate::before), ma un gruppo suo non ce l'ha.
|
||||||
|
$this->get(route('dashboard'))->assertOk()->assertDontSee('Il mio gruppo')->assertSee('Gruppi di servizio');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_al_primo_accesso_il_sorvegliante_cambia_la_password_temporanea(): void
|
||||||
|
{
|
||||||
|
$rossi = $this->proclamatore('Rossi', 'Mario');
|
||||||
|
[$gruppo] = app(GruppiServizio::class)->crea('Zisa', $rossi);
|
||||||
|
$s = User::where('email', 'mrossi@termanager.it')->sole();
|
||||||
|
$this->assertTrue($s->password_temporanea);
|
||||||
|
$this->flushSession();
|
||||||
|
$this->actingAs($s);
|
||||||
|
|
||||||
|
$this->get(route('mio-gruppo'))->assertRedirect(route('password.change'));
|
||||||
|
$this->get(route('password.change'))->assertOk()
|
||||||
|
->assertSee('Password temporanea')
|
||||||
|
->assertDontSee('Ripristino da backup');
|
||||||
|
|
||||||
|
$this->post(route('password.change.store'), ['password' => 'una-password-mia-1', 'password_confirmation' => 'una-password-mia-1'])
|
||||||
|
->assertRedirect(route('dashboard'));
|
||||||
|
$this->assertFalse($s->fresh()->password_temporanea);
|
||||||
|
$this->assertFalse($s->fresh()->password_change_required);
|
||||||
|
$this->get(route('mio-gruppo'))->assertOk();
|
||||||
|
|
||||||
|
// "Nuova password temporanea" dalla pagina del gruppo: di nuovo temporanea.
|
||||||
|
$this->flushSession();
|
||||||
|
Livewire::actingAs($this->admin)->test(GruppoShow::class, ['gruppo' => $gruppo])->call('nuovaPassword');
|
||||||
|
$this->assertTrue($s->fresh()->password_temporanea);
|
||||||
|
$this->assertTrue($s->fresh()->password_change_required);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_entra_con_l_email_fittizia_senza_conferma_e_va_al_suo_gruppo(): void
|
||||||
|
{
|
||||||
|
$this->gruppo();
|
||||||
|
$this->flushSession();
|
||||||
|
auth()->logout();
|
||||||
|
$password = 'una-password-nuova-1';
|
||||||
|
User::where('email', 'mrossi@termanager.it')->sole()->forceFill(['password' => $password])->save();
|
||||||
|
|
||||||
|
$this->post(route('login.store'), ['email' => 'mrossi@termanager.it', 'password' => $password])
|
||||||
|
->assertRedirect(route('mio-gruppo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Il sorvegliante al lavoro ──────────────────────────────
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_assegna_a_un_membro_e_nasce_un_assegnazione_normale(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori, 'gruppo' => $gruppo] = $this->gruppo();
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)
|
||||||
|
->call('apriAssegnazione', $territori[0]->id)
|
||||||
|
->set('membroId', $verdi->id)
|
||||||
|
->call('assegna')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
// Come dalla pagina Assegna: si apre l'invio del link.
|
||||||
|
->assertSee('Hai inviato il link?');
|
||||||
|
|
||||||
|
$a = Assegnazione::sole();
|
||||||
|
$this->assertSame($verdi->id, $a->proclamatore_id);
|
||||||
|
$this->assertSame($gruppo->id, $a->gruppo_servizio_id, 'Tracciato che viene dal gruppo');
|
||||||
|
$this->assertTrue($a->link_da_inviare);
|
||||||
|
$this->assertSame($gruppo->id, $territori[0]->fresh()->gruppo_servizio_id, 'Resta nel pacchetto finché non rientra');
|
||||||
|
$this->assertSame('assegnato', $territori[0]->fresh()->stato);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_puo_tenere_un_territorio_per_se(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'rossi' => $rossi, 'territori' => $territori] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)
|
||||||
|
->call('apriAssegnazione', $territori[1]->id)
|
||||||
|
->set('membroId', $rossi->id)
|
||||||
|
->call('assegna')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSame($rossi->id, Assegnazione::sole()->proclamatore_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_solo_ai_membri_e_solo_i_territori_del_pacchetto(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
|
||||||
|
$estraneo = $this->proclamatore('Neri');
|
||||||
|
$fuori = $this->territorio('77');
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)
|
||||||
|
->call('apriAssegnazione', $territori[0]->id)
|
||||||
|
->set('membroId', $estraneo->id)
|
||||||
|
->call('assegna')
|
||||||
|
->assertHasErrors('membroId');
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)
|
||||||
|
->call('apriAssegnazione', $fuori->id)
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertSame(0, Assegnazione::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_rientro_lo_registra_il_sorvegliante_e_il_territorio_torna_in_reparto(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori, 'gruppo' => $gruppo] = $this->gruppo();
|
||||||
|
$a = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now()->subDays(3));
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
$this->actingAs($s)->get(route('assegnazioni.rientra', $a))->assertOk();
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(Rientra::class, ['assegnazione' => $a])
|
||||||
|
->call('save')
|
||||||
|
->assertRedirect(route('mio-gruppo'));
|
||||||
|
|
||||||
|
$this->assertNotNull($a->fresh()->returned_at);
|
||||||
|
$this->assertNull($territori[0]->fresh()->gruppo_servizio_id, 'Esce dal pacchetto');
|
||||||
|
$this->assertSame('in_reparto', $territori[0]->fresh()->stato);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_non_registra_rientri_di_altri(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s] = $this->gruppo();
|
||||||
|
$altro = Assegnazione::create([
|
||||||
|
'territorio_id' => $this->territorio('80')->id, 'proclamatore_id' => $this->proclamatore('Neri')->id,
|
||||||
|
'anno_teocratico_id' => AnnoTeocratico::sole()->id, 'assigned_at' => now(), 'created_by' => $this->admin->id,
|
||||||
|
]);
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
$this->actingAs($s)->get(route('assegnazioni.rientra', $altro))->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_rimanda_in_reparto_quelli_non_dati(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::actingAs($s)->test(MioGruppo::class)->call('rimanda', $territori[2]->id);
|
||||||
|
|
||||||
|
$this->assertNull($territori[2]->fresh()->gruppo_servizio_id);
|
||||||
|
$this->assertSame('in_reparto', $territori[2]->fresh()->stato);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_la_piantina_del_pacchetto_si_apre_solo_per_il_proprio_gruppo(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s, 'territori' => $territori] = $this->gruppo();
|
||||||
|
$fuori = $this->territorio('88');
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
$this->actingAs($s)->get(route('mio-gruppo.pdf', $territori[0]))->assertOk();
|
||||||
|
$this->actingAs($s)->get(route('mio-gruppo.pdf', $fuori))->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scioglimento ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_togliere_il_ruolo_scioglie_il_gruppo(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
|
||||||
|
// L'assegnazione la fa il sorvegliante, come nella realtà.
|
||||||
|
auth()->setUser($s);
|
||||||
|
$data = app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
|
||||||
|
auth()->setUser($this->admin);
|
||||||
|
|
||||||
|
Livewire::test(GruppoShow::class, ['gruppo' => $gruppo])
|
||||||
|
->call('sciogli')
|
||||||
|
->assertRedirect(route('gruppi.index'));
|
||||||
|
|
||||||
|
// Il gruppo non resta nello storico: non esiste più.
|
||||||
|
$this->assertNull(GruppoServizio::find($gruppo->id));
|
||||||
|
$this->get(route('gruppi.index'))->assertOk()->assertSee('Nessun gruppo di servizio');
|
||||||
|
// I non dati tornano in reparto; quello dato resta a Verdi, assegnazione
|
||||||
|
// normale, che del gruppo conserva solo il nome.
|
||||||
|
$this->assertNull($territori[1]->fresh()->gruppo_servizio_id);
|
||||||
|
$this->assertSame('in_reparto', $territori[2]->fresh()->stato);
|
||||||
|
$this->assertNull($data->fresh()->returned_at);
|
||||||
|
$this->assertSame('assegnato', $territori[0]->fresh()->stato);
|
||||||
|
$this->assertNull($data->fresh()->gruppo_servizio_id);
|
||||||
|
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
|
||||||
|
$this->get(route('territori.show', $territori[0]))->assertOk()->assertSee('via gruppo Zisa');
|
||||||
|
// Membri liberi; l'utenza del sorvegliante non c'è più, lui torna un
|
||||||
|
// proclamatore come gli altri.
|
||||||
|
$this->assertSame(0, Proclamatore::whereNotNull('gruppo_servizio_id')->count());
|
||||||
|
$this->assertNull(User::find($s->id));
|
||||||
|
$this->assertNotNull(Proclamatore::find($s->proclamatore_id));
|
||||||
|
$this->assertSame(0, \Illuminate\Support\Facades\DB::table('model_has_roles')->where('model_id', $s->id)->count());
|
||||||
|
// Quello che aveva fatto passa a chi ha sciolto il gruppo; nel registro
|
||||||
|
// le sue voci restano, senza dati personali.
|
||||||
|
$this->assertSame($this->admin->id, $data->fresh()->created_by);
|
||||||
|
$voce = \Spatie\Activitylog\Models\Activity::where('description', 'assigned')->sole();
|
||||||
|
$this->assertNull($voce->causer);
|
||||||
|
$this->assertSame('Sorvegliante del gruppo Zisa', $voce->properties['causer_name']);
|
||||||
|
$this->assertNull($voce->properties['causer_email']);
|
||||||
|
|
||||||
|
// Per ripartire serve un gruppo nuovo.
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, [$territori[1]->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sciolto_il_gruppo_l_ex_sorvegliante_non_entra_piu(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'sorvegliante' => $s] = $this->gruppo();
|
||||||
|
$password = 'una-password-nuova-1';
|
||||||
|
$s->forceFill(['password' => $password])->save();
|
||||||
|
app(GruppiServizio::class)->sciogli($gruppo);
|
||||||
|
$this->flushSession();
|
||||||
|
auth()->logout();
|
||||||
|
|
||||||
|
$this->post(route('login.store'), ['email' => 'mrossi@termanager.it', 'password' => $password])
|
||||||
|
->assertSessionHasErrors('email');
|
||||||
|
$this->assertGuest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $vecchio, 'rossi' => $rossi] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->sciogli($vecchio);
|
||||||
|
|
||||||
|
$pagina = Livewire::test(GruppiIndex::class);
|
||||||
|
$this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati');
|
||||||
|
|
||||||
|
// Sciolto, il nome del gruppo e l'email sono di nuovo liberi.
|
||||||
|
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa', $rossi->fresh());
|
||||||
|
|
||||||
|
$utente = User::where('proclamatore_id', $rossi->id)->sole();
|
||||||
|
$this->assertTrue($utente->hasRole('sorvegliante'));
|
||||||
|
$this->assertSame('mrossi@termanager.it', $utente->email);
|
||||||
|
$this->assertTrue($utente->password_change_required);
|
||||||
|
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
|
||||||
|
$this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sorvegliante non più attivo: il gruppo si scioglie ─────
|
||||||
|
|
||||||
|
/** Il gruppo è sciolto come da "Togli il ruolo": tutto tranne l'assegnazione già fatta. */
|
||||||
|
private function assertSciolto(array $g, Assegnazione $data): void
|
||||||
|
{
|
||||||
|
$this->assertNull(GruppoServizio::find($g['gruppo']->id), 'Gruppo sciolto ed eliminato');
|
||||||
|
$this->assertSame('Zisa', $data->fresh()->gruppo_nome);
|
||||||
|
$this->assertNull($g['territori'][1]->fresh()->gruppo_servizio_id, 'I non dati tornano in reparto');
|
||||||
|
$this->assertSame('in_reparto', $g['territori'][2]->fresh()->stato);
|
||||||
|
$this->assertNull($data->fresh()->returned_at, 'Quello già dato resta al membro');
|
||||||
|
$this->assertNull($g['bianchi']->fresh()->gruppo_servizio_id, 'Membri liberi');
|
||||||
|
$this->assertNull(User::find($g['sorvegliante']->id), 'Utenza eliminata');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disattivare_il_sorvegliante_dall_elenco_scioglie_il_gruppo(): void
|
||||||
|
{
|
||||||
|
$g = $this->gruppo();
|
||||||
|
$data = app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
|
||||||
|
->assertSeeHtml('è il sorvegliante del gruppo Zisa')
|
||||||
|
->call('toggleActive', $g['rossi']->id)
|
||||||
|
->assertDispatched('avviso', fn ($nome, $parametri) => str_contains($parametri['testo'], 'il gruppo è stato sciolto'));
|
||||||
|
|
||||||
|
$this->assertFalse($g['rossi']->fresh()->attivo);
|
||||||
|
$this->assertSciolto($g, $data);
|
||||||
|
|
||||||
|
// Riattivarlo non ricostruisce il gruppo: se ne crea uno nuovo.
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('toggleActive', $g['rossi']->id);
|
||||||
|
$this->assertSame(0, GruppoServizio::count());
|
||||||
|
$this->assertNull($g['rossi']->fresh()->gruppoSorvegliato());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disattivare_il_sorvegliante_dalla_modifica_scioglie_il_gruppo(): void
|
||||||
|
{
|
||||||
|
$g = $this->gruppo();
|
||||||
|
$data = app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
|
||||||
|
|
||||||
|
$this->get(route('proclamatori.edit', $g['rossi']))->assertOk()->assertSee('se non è più attivo il gruppo si scioglie');
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreEdit::class, ['proclamatore' => $g['rossi']])
|
||||||
|
->set('attivo', false)
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSciolto($g, $data);
|
||||||
|
$this->assertStringContainsString('il gruppo è stato sciolto', session('success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_spostare_nel_cestino_il_sorvegliante_scioglie_il_gruppo(): void
|
||||||
|
{
|
||||||
|
$g = $this->gruppo();
|
||||||
|
$data = app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('deleteProclamatore', $g['rossi']->id);
|
||||||
|
|
||||||
|
$this->assertTrue($g['rossi']->fresh()->trashed());
|
||||||
|
$this->assertSciolto($g, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disattivare_o_cestinare_un_membro_non_scioglie_il_gruppo(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'verdi' => $verdi, 'bianchi' => $bianchi] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
|
||||||
|
->call('toggleActive', $verdi->id)
|
||||||
|
->call('deleteProclamatore', $bianchi->id);
|
||||||
|
|
||||||
|
$this->assertNotNull(GruppoServizio::find($gruppo->id));
|
||||||
|
$this->assertSame($gruppo->id, $verdi->fresh()->gruppo_servizio_id, 'Non attivo resta membro, ma non riceve territori');
|
||||||
|
$this->assertNull($bianchi->fresh()->gruppo_servizio_id, 'Nel cestino lascia il gruppo');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dettagli del gruppo nel profilo ────────────────────────
|
||||||
|
|
||||||
|
public function test_la_scheda_del_proclamatore_mostra_il_gruppo_che_guida(): void
|
||||||
|
{
|
||||||
|
$g = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
|
||||||
|
|
||||||
|
$this->get(route('proclamatori.show', $g['rossi']))->assertOk()
|
||||||
|
->assertSeeInOrder(['Gruppo di servizio', 'Sorvegliante', 'Zisa', 'Membri', '3', 'Nel pacchetto', '3', 'Da dare', '2', 'Assegnazioni dal gruppo', '1'])
|
||||||
|
->assertSee('<code>mrossi@termanager.it</code>', false)
|
||||||
|
->assertSee('non è ancora entrato');
|
||||||
|
|
||||||
|
$this->get(route('proclamatori.show', $g['verdi']))->assertOk()
|
||||||
|
->assertSee('Membro del gruppo')
|
||||||
|
->assertSee('sorvegliante Rossi Mario');
|
||||||
|
|
||||||
|
// Sciolto il gruppo, nella scheda non ne resta traccia.
|
||||||
|
app(GruppiServizio::class)->sciogli($g['gruppo']);
|
||||||
|
$this->get(route('proclamatori.show', $g['rossi']))->assertOk()
|
||||||
|
->assertDontSee('Assegnazioni dal gruppo')
|
||||||
|
->assertDontSee('In passato');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_chi_fa_parte_di_un_gruppo_e_segnalato_in_elenco_scheda_e_scelta(): void
|
||||||
|
{
|
||||||
|
$g = $this->gruppo();
|
||||||
|
$this->proclamatore('Neri');
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
|
||||||
|
->assertSee('Sorvegliante del gruppo Zisa')
|
||||||
|
->assertSee('Gruppo Zisa')
|
||||||
|
->set('filtroStato', 'gruppo')
|
||||||
|
->assertSee('Verdi')->assertSee('Bianchi')->assertSee('Rossi')
|
||||||
|
->assertDontSee('Neri');
|
||||||
|
|
||||||
|
$this->get(route('proclamatori.show', $g['verdi']))->assertOk()->assertSee('Gruppo Zisa');
|
||||||
|
|
||||||
|
// Nella tendina di Assegna la nota accanto al nome...
|
||||||
|
$opzioni = collect(Livewire::test(Assegna::class)->viewData('opzioniProclamatori'))->keyBy('etichetta');
|
||||||
|
$this->assertSame('gruppo Zisa', $opzioni['Verdi Anna']['dettaglio']);
|
||||||
|
$this->assertSame('sorvegliante del gruppo Zisa', $opzioni['Rossi Mario']['dettaglio']);
|
||||||
|
$this->assertArrayNotHasKey('dettaglio', $opzioni['Neri Test']);
|
||||||
|
$this->assertArrayNotHasKey('motivo', $opzioni['Verdi Anna'], 'Si può comunque scegliere');
|
||||||
|
|
||||||
|
// ...che in "Il mio gruppo", dove sono tutti dello stesso gruppo, non serve.
|
||||||
|
$this->flushSession();
|
||||||
|
$membri = Livewire::actingAs($g['sorvegliante'])->test(MioGruppo::class)->viewData('opzioniMembri');
|
||||||
|
$this->assertSame([], collect($membri)->pluck('dettaglio')->filter()->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_profilo_del_sorvegliante_mostra_il_suo_gruppo(): void
|
||||||
|
{
|
||||||
|
$g = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->assegna($g['gruppo'], $g['territori'][0], $g['verdi'], now());
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
$this->actingAs($g['sorvegliante'])->get(route('profile'))->assertOk()
|
||||||
|
->assertSee('Il tuo gruppo di servizio')
|
||||||
|
->assertSeeInOrder(['Zisa', 'Membri', '3', 'Nel pacchetto', '3', 'Da dare', '2', 'Dati ai membri', '1'])
|
||||||
|
->assertSee('Torna al mio gruppo');
|
||||||
|
|
||||||
|
// L'amministratore un gruppo suo non ce l'ha (sessione nuova: è un
|
||||||
|
// altro utente).
|
||||||
|
$this->flushSession();
|
||||||
|
$this->actingAs($this->admin)->get(route('profile'))->assertOk()
|
||||||
|
->assertDontSee('Il tuo gruppo di servizio')
|
||||||
|
->assertSee('Torna alla home');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Proclamatori: anonimizzazione, registro ────────────────
|
||||||
|
|
||||||
|
public function test_anonimizzare_il_sorvegliante_scioglie_il_gruppo_e_ne_elimina_l_utenza(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id);
|
||||||
|
|
||||||
|
$this->assertNull(GruppoServizio::find($gruppo->id));
|
||||||
|
$this->assertNull(User::find($s->id), "Nome ed email dell'utenza spariscono con lei");
|
||||||
|
$this->assertSame('Anonimo', Proclamatore::withTrashed()->find($rossi->id)->nome);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s] = $this->gruppo();
|
||||||
|
$s->update(['email' => 'mario.rossi@example.test']);
|
||||||
|
|
||||||
|
$voci = \Spatie\Activitylog\Models\Activity::where('subject_type', User::class)->where('subject_id', $s->id)->get();
|
||||||
|
$this->assertNotEmpty($voci);
|
||||||
|
foreach ($voci as $voce) {
|
||||||
|
$json = json_encode($voce->properties);
|
||||||
|
$this->assertStringNotContainsString('Rossi', $json);
|
||||||
|
$this->assertStringNotContainsString('mario', $json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Le azioni del sorvegliante passano dal middleware ──────
|
||||||
|
|
||||||
|
public function test_le_azioni_livewire_del_sorvegliante_non_finiscono_in_403(): void
|
||||||
|
{
|
||||||
|
// Livewire::test() non passa dai middleware delle rotte: qui si chiama
|
||||||
|
// la rotta vera delle azioni (in Livewire 3.7 "default.livewire.update",
|
||||||
|
// che il filtro 'livewire.*' non riconosceva: ogni clic era un 403).
|
||||||
|
['sorvegliante' => $s] = $this->gruppo();
|
||||||
|
$this->flushSession();
|
||||||
|
$rotta = collect(app('router')->getRoutes()->getRoutes())
|
||||||
|
->first(fn ($r) => str_ends_with($r->uri(), 'livewire/update'));
|
||||||
|
$this->assertNotNull($rotta);
|
||||||
|
|
||||||
|
$this->withoutExceptionHandling();
|
||||||
|
try {
|
||||||
|
$this->actingAs($s)->post('/' . ltrim($rotta->uri(), '/'), ['components' => []], ['X-Livewire' => 'true']);
|
||||||
|
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||||
|
$this->assertNotSame(403, $e->getStatusCode(), $e->getMessage());
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Livewire rifiuta la richiesta vuota: non è il 403 del sorvegliante.
|
||||||
|
}
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Priorità e filtri ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** Territori in reparto con giacenze diverse: 40 (200 gg), 41 (50 gg), 42 (spunta prioritario), 43 (nuovo). */
|
||||||
|
private function territoriConPriorita(): array
|
||||||
|
{
|
||||||
|
// Soglia dei prioritari a 180 giorni, e senza l'istanza delle
|
||||||
|
// impostazioni rimasta in memoria da un altro test.
|
||||||
|
Setting::query()->update(['giorni_giacenza_prioritari' => 180]);
|
||||||
|
Setting::flushCache();
|
||||||
|
|
||||||
|
$zona = \App\Models\Zona::create(['nome' => 'Noce', 'attivo' => true]);
|
||||||
|
$altra = \App\Models\Zona::create(['nome' => 'Zisa', 'attivo' => true]);
|
||||||
|
$t = [];
|
||||||
|
foreach (['40' => 200, '41' => 50, '42' => 0, '43' => 0] as $numero => $giorni) {
|
||||||
|
$numero = (string) $numero; // le chiavi numeriche diventano interi
|
||||||
|
$t[$numero] = $this->territorio($numero);
|
||||||
|
$t[$numero]->forceFill([
|
||||||
|
'created_at' => now()->subDays($giorni),
|
||||||
|
'prioritario' => $numero === '42',
|
||||||
|
'zona_id' => $numero === '43' ? $altra->id : $zona->id,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $t;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_aggiungendo_al_pacchetto_i_territori_sono_in_ordine_di_priorita_e_filtrabili(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo] = $this->gruppo();
|
||||||
|
$t = $this->territoriConPriorita();
|
||||||
|
|
||||||
|
$pagina = Livewire::test(GruppoShow::class, ['gruppo' => $gruppo]);
|
||||||
|
// Prima i prioritari (per giacenza), poi gli altri: la regola della Home.
|
||||||
|
$this->assertSame(['40', '42', '41', '43'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
|
||||||
|
$pagina->assertSee('★ Prioritario')->assertSee('fermo da 200 gg');
|
||||||
|
|
||||||
|
$pagina->set('soloPrioritari', true);
|
||||||
|
$this->assertSame(['40', '42'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
|
||||||
|
|
||||||
|
$pagina->set('soloPrioritari', false)->set('filtroZona', (string) $t['43']->zona_id);
|
||||||
|
$this->assertSame(['43'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
|
||||||
|
|
||||||
|
$pagina->set('filtroZona', '')->set('cercaTerritorio', '41');
|
||||||
|
$this->assertSame(['41'], $pagina->viewData('candidatiTerritori')->pluck('numero')->all());
|
||||||
|
|
||||||
|
// "Seleziona tutti quelli mostrati" e aggiunta in blocco.
|
||||||
|
$pagina->set('cercaTerritorio', '')->set('soloPrioritari', true)
|
||||||
|
->call('selezionaMostrati')
|
||||||
|
->call('aggiungiTerritori');
|
||||||
|
$this->assertSame($gruppo->id, $t['40']->fresh()->gruppo_servizio_id);
|
||||||
|
$this->assertSame($gruppo->id, $t['42']->fresh()->gruppo_servizio_id);
|
||||||
|
$this->assertNull($t['41']->fresh()->gruppo_servizio_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_vede_il_pacchetto_in_ordine_di_priorita_e_lo_filtra(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $vecchi] = $this->gruppo();
|
||||||
|
$t = $this->territoriConPriorita();
|
||||||
|
app(GruppiServizio::class)->aggiungiAlPacchetto($gruppo, collect($t)->pluck('id')->all());
|
||||||
|
// Il pacchetto di gruppo() (12, 14, 35) va via, tranne il 12 che si dà.
|
||||||
|
app(GruppiServizio::class)->assegna($gruppo, $vecchi[0], $verdi, now());
|
||||||
|
app(GruppiServizio::class)->rimandaInReparto($gruppo, $vecchi[1]->fresh());
|
||||||
|
app(GruppiServizio::class)->rimandaInReparto($gruppo, $vecchi[2]->fresh());
|
||||||
|
$this->flushSession();
|
||||||
|
|
||||||
|
$pagina = Livewire::actingAs($s)->test(MioGruppo::class)
|
||||||
|
->assertSee('Sorvegliante: <strong>Mario Rossi</strong>', false);
|
||||||
|
// Da dare in ordine di priorità, poi quelli già dati.
|
||||||
|
$this->assertSame(['40', '42', '41', '43', '12'], $pagina->viewData('territori')->pluck('numero')->all());
|
||||||
|
|
||||||
|
$pagina->set('filtroStato', 'da_dare');
|
||||||
|
$this->assertSame(['40', '42', '41', '43'], $pagina->viewData('territori')->pluck('numero')->all());
|
||||||
|
$pagina->set('filtroStato', 'dati');
|
||||||
|
$this->assertSame(['12'], $pagina->viewData('territori')->pluck('numero')->all());
|
||||||
|
$pagina->set('filtroStato', '')->set('soloPrioritari', true);
|
||||||
|
$this->assertSame(['40', '42'], $pagina->viewData('territori')->pluck('numero')->all());
|
||||||
|
$pagina->set('soloPrioritari', false)->set('filtroZona', (string) $t['43']->zona_id);
|
||||||
|
$this->assertSame(['43'], $pagina->viewData('territori')->pluck('numero')->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Backup e azzeramento ───────────────────────────────────
|
||||||
|
|
||||||
|
public function test_l_azzeramento_toglie_anche_gruppi_e_utenza_del_sorvegliante(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori, 'sorvegliante' => $s] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
|
||||||
|
|
||||||
|
$stato = app(\App\Services\BackupOperationState::class);
|
||||||
|
$stato->initialize('azzeramento-gruppi', 'azzeramento', 'In attesa');
|
||||||
|
(new \App\Jobs\ResetApplication('azzeramento-gruppi', $this->admin->id))->handle($stato, app(\App\Services\BackupArchive::class));
|
||||||
|
|
||||||
|
$this->assertSame('completed', $stato->get('azzeramento-gruppi')['status'] ?? null, json_encode($stato->get('azzeramento-gruppi')));
|
||||||
|
$this->assertSame(0, GruppoServizio::count());
|
||||||
|
$this->assertNull(User::find($s->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
|
||||||
|
|
||||||
|
$xml = (new XmlExchange())->currentXmlBackup();
|
||||||
|
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
|
||||||
|
|
||||||
|
$g = GruppoServizio::sole();
|
||||||
|
$this->assertSame('Zisa', $g->nome);
|
||||||
|
$this->assertSame('Rossi Mario', $g->sorvegliante->nome_completo);
|
||||||
|
$this->assertEqualsCanonicalizing(['Rossi Mario', 'Verdi Anna', 'Bianchi Luca'], $g->membri->map->nome_completo->all());
|
||||||
|
$this->assertEqualsCanonicalizing(['12', '14', '35'], $g->territori->pluck('numero')->all());
|
||||||
|
$this->assertSame($g->id, Assegnazione::sole()->gruppo_servizio_id);
|
||||||
|
$this->assertSame('Zisa', Assegnazione::sole()->gruppo_nome);
|
||||||
|
$utenza = User::where('email', 'mrossi@termanager.it')->sole();
|
||||||
|
$this->assertTrue($utenza->hasRole('sorvegliante'));
|
||||||
|
$this->assertSame($g->sorvegliante_id, $utenza->proclamatore_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_l_xml_conserva_il_nome_del_gruppo_sulle_assegnazioni_dopo_lo_scioglimento(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now());
|
||||||
|
app(GruppiServizio::class)->sciogli($gruppo);
|
||||||
|
|
||||||
|
$xml = (new XmlExchange())->currentXmlBackup();
|
||||||
|
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
|
||||||
|
|
||||||
|
$this->assertSame(0, GruppoServizio::count());
|
||||||
|
$this->assertNull(Assegnazione::sole()->gruppo_servizio_id);
|
||||||
|
$this->assertSame('Zisa', Assegnazione::sole()->gruppo_nome);
|
||||||
|
// Dell'ex sorvegliante non resta un'utenza: solo l'amministratore.
|
||||||
|
$this->assertSame([$this->admin->id], User::pluck('id')->all());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -260,4 +260,30 @@ class InvioLinkTest extends TestCase
|
|||||||
->get(route('territori.index'))
|
->get(route('territori.index'))
|
||||||
->assertSee(route('piantine.editor', ['nuovo' => 1]), false);
|
->assertSee(route('piantine.editor', ['nuovo' => 1]), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_da_assegna_della_home_l_elenco_dei_territori_assegnati_non_c_e(): void
|
||||||
|
{
|
||||||
|
$t = $this->territorioConPdf('7');
|
||||||
|
$daHome = route('assegnazioni.assegna', ['territorioId' => $t->id, 'dalla_home' => 1]);
|
||||||
|
|
||||||
|
// "Assegna →" della Home porta il segnale...
|
||||||
|
$this->actingAs($this->admin)->get(route('dashboard'))->assertOk()->assertSee($daHome, false);
|
||||||
|
|
||||||
|
// ...e Assegna non mostra (né carica) l'elenco dei territori assegnati.
|
||||||
|
$this->get($daHome)->assertOk()->assertDontSee('assegnazioni.elenco-assegnati', false);
|
||||||
|
// Aperta dal menu o dalla scheda del territorio, l'elenco c'è.
|
||||||
|
$this->get(route('assegnazioni.assegna'))->assertSee('assegnazioni.elenco-assegnati', false);
|
||||||
|
$this->get(route('assegnazioni.assegna', ['territorioId' => $t->id]))->assertSee('assegnazioni.elenco-assegnati', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dall_elenco_degli_assegnati_si_va_al_rientro(): void
|
||||||
|
{
|
||||||
|
$this->assegna($this->mario(), $this->territorioConPdf('7'));
|
||||||
|
$a = Assegnazione::sole();
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading()->actingAs($this->admin)->test(ElencoAssegnati::class)
|
||||||
|
->assertSee('Rientro')
|
||||||
|
// Relativo: l'elenco ha un peso massimo per riga (AssegnaPrestazioniTest).
|
||||||
|
->assertSee('href="' . route('assegnazioni.rientra', $a, false) . '"', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
tests/Feature/StoricoAssegnazioniTest.php
Normal file
74
tests/Feature/StoricoAssegnazioniTest.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\AnnoTeocratico;
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lo storico delle assegnazioni nelle schede di territorio e proclamatore
|
||||||
|
* (20 ottobre 2026): una tabella sola con le colonne in linea, l'anno come
|
||||||
|
* riga di separazione, dal più recente.
|
||||||
|
*/
|
||||||
|
class StoricoAssegnazioniTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
private Territorio $territorio;
|
||||||
|
|
||||||
|
private Proclamatore $proclamatore;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->withoutVite();
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
||||||
|
|
||||||
|
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
|
||||||
|
$this->admin->assignRole('amministratore');
|
||||||
|
|
||||||
|
$this->territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
|
||||||
|
$this->proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
|
||||||
|
|
||||||
|
$vecchio = AnnoTeocratico::create(['label' => '2024-2025', 'start_date' => '2024-09-01', 'end_date' => '2025-08-31']);
|
||||||
|
$recente = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
|
||||||
|
|
||||||
|
// Create in disordine: l'ordine lo deve dare la pagina.
|
||||||
|
foreach ([[$vecchio, '2024-10-01', '2024-11-01'], [$recente, '2025-10-01', '2025-11-15'], [$vecchio, '2025-03-01', '2025-04-01']] as [$anno, $dal, $al]) {
|
||||||
|
Assegnazione::create([
|
||||||
|
'territorio_id' => $this->territorio->id, 'proclamatore_id' => $this->proclamatore->id,
|
||||||
|
'anno_teocratico_id' => $anno->id, 'assigned_at' => $dal, 'returned_at' => $al, 'created_by' => $this->admin->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nella_scheda_del_territorio_una_tabella_sola_dal_piu_recente(): void
|
||||||
|
{
|
||||||
|
$html = $this->actingAs($this->admin)->get(route('territori.show', $this->territorio))->assertOk()
|
||||||
|
->assertSeeInOrder(['Anno Teocratico 2025-2026', '01/10/2025', 'Anno Teocratico 2024-2025', '01/03/2025', '01/10/2024'])
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
$this->assertSame(1, substr_count($html, '>Proclamatore</th>'), "Un'intestazione sola: colonne in linea");
|
||||||
|
$this->assertSame(2, substr_count($html, 'tm-riga-gruppo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nella_scheda_del_proclamatore_una_tabella_sola_dal_piu_recente(): void
|
||||||
|
{
|
||||||
|
$html = $this->actingAs($this->admin)->get(route('proclamatori.show', $this->proclamatore))->assertOk()
|
||||||
|
->assertSeeInOrder(['Anno Teocratico 2025-2026', '01/10/2025', 'Anno Teocratico 2024-2025', '01/03/2025', '01/10/2024'])
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
$this->assertSame(1, substr_count($html, '>Territorio</th>'), "Un'intestazione sola: colonne in linea");
|
||||||
|
$this->assertSame(2, substr_count($html, 'tm-riga-gruppo'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* L'immagine deve partire da una versione di PHP fissata alla patch, e mai
|
|
||||||
* sotto quella che corregge CVE-2026-109618 e CVE-2026-109619 (esecuzione di
|
|
||||||
* codice da remoto nel core di PHP, 21/10/2026). Con un tag che si sposta
|
|
||||||
* ("8.3-fpm") la cache di Docker poteva riusare una base vulnerabile.
|
|
||||||
*/
|
|
||||||
class VersionePhpTest extends TestCase
|
|
||||||
{
|
|
||||||
private const MINIMA = '8.3.36';
|
|
||||||
|
|
||||||
private function dockerfile(): string
|
|
||||||
{
|
|
||||||
return (string) file_get_contents(dirname(__DIR__, 2) . '/docker/php/Dockerfile');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_la_versione_di_php_e_fissata_e_corretta(): void
|
|
||||||
{
|
|
||||||
// \s* prima di $: nella copia di lavoro su Windows il file ha CRLF.
|
|
||||||
$this->assertMatchesRegularExpression('/^ARG PHP_VERSION=(\d+\.\d+\.\d+)\s*$/m', $this->dockerfile());
|
|
||||||
|
|
||||||
preg_match('/^ARG PHP_VERSION=(\d+\.\d+\.\d+)\s*$/m', $this->dockerfile(), $trovata);
|
|
||||||
|
|
||||||
$this->assertTrue(
|
|
||||||
version_compare($trovata[1], self::MINIMA, '>='),
|
|
||||||
"PHP {$trovata[1]} è vulnerabile a CVE-2026-109618/109619: serve almeno " . self::MINIMA . '.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_la_mitigazione_delle_sessioni_resta_nel_php_ini(): void
|
|
||||||
{
|
|
||||||
// Indicata da Sansec per CVE-2026-109618/109619; all'applicazione non
|
|
||||||
// serve, quindi resta anche dopo la patch.
|
|
||||||
$ini = (string) file_get_contents(dirname(__DIR__, 2) . '/docker/php/php.ini');
|
|
||||||
|
|
||||||
$this->assertMatchesRegularExpression('/^session\.upload_progress\.enabled\s*=\s*0\s*$/m', $ini);
|
|
||||||
$this->assertMatchesRegularExpression('/^session\.use_strict_mode\s*=\s*1\s*$/m', $ini);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_l_immagine_usa_la_versione_fissata_e_non_un_tag_mobile(): void
|
|
||||||
{
|
|
||||||
preg_match_all('/^FROM php:(\S+)/m', $this->dockerfile(), $basi);
|
|
||||||
|
|
||||||
$this->assertNotEmpty($basi[1]);
|
|
||||||
foreach ($basi[1] as $tag) {
|
|
||||||
$this->assertStringStartsWith('${PHP_VERSION}', $tag, "FROM php:{$tag}: usa ARG PHP_VERSION, non un tag che si sposta.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user