++ Richieste dal modulo pubblico, territori cartacei, S-13 mensile per email

- /richiesta: modulo pubblico (nuovo territorio o riconsegna) con campo
  trappola, tempo minimo e limite orario; nome e note cifrati, avviso per
  email a chi ha richieste.gestisci (amministratore, assistente)
- Home: riquadro Richieste con i proclamatori dal nome simile; porta su
  Assegna/Rientra già esistenti, che chiudono la richiesta; Scarta
- Cartaceo sull'assegnazione (Assegna e Il mio gruppo), etichetta
  nell'elenco degli assegnati, pagina Cartacei, passa per l'XML
- registro:invia-s13 agli amministratori l'ultima domenica del mese;
  richieste:pulizia dopo 90 giorni dalla gestione
- Privacy, README e HANDOFF aggiornati; test in RichiesteTest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-21 17:32:09 +02:00
parent 5e40f0fe74
commit 7904c96db8
36 changed files with 1563 additions and 24 deletions

View File

@@ -4,7 +4,7 @@ Stato del progetto per chi ci mette mano dopo. Per installazione, deploy e uso
quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non quotidiano vedi [README.md](README.md); qui c'è il contesto che il README non
può dare: perché le cose sono come sono, e cosa resta aperto. può dare: perché le cose sono come sono, e cosa resta aperto.
Ultimo aggiornamento: **20 ottobre 2026** — si riprende da [Stato attuale](#stato-attuale). Ultimo aggiornamento: **21 ottobre 2026** — si riprende da [Stato attuale](#stato-attuale).
--- ---
@@ -34,14 +34,18 @@ produzione** — l'utente ha chiesto di aspettare il suo ok:
- pulsante **Rientro** nell'elenco dei territori assegnati; - pulsante **Rientro** nell'elenco dei territori assegnati;
- storico assegnazioni in una tabella sola, con le colonne in linea; - storico assegnazioni in una tabella sola, con le colonne in linea;
- *Assegna* aperta da "Assegna →" della Home senza l'elenco dei territori - *Assegna* aperta da "Assegna →" della Home senza l'elenco dei territori
assegnati. assegnati;
- password del sorvegliante dal pannello *Utenti*, anteprima dal numero in "Il
mio gruppo";
- [modulo pubblico delle richieste, cartacei, S-13 per email](#richieste-dal-modulo-pubblico-cartacei-s-13-per-email-21-ottobre-2026-solo-su-dev)
(migrazione `2026_10_21_000010`).
**Per portarli in produzione**: backup del database di produzione, merge di **Per portarli in produzione**: backup del database di produzione, merge di
`dev` in `main` (dopo il rilascio di sicurezza del 21/10 non è più un `dev` in `main` (dopo il rilascio di sicurezza del 21/10 non è più un
fast-forward), deploy (l'autodeploy non parte: vedi fast-forward), deploy (l'autodeploy non parte: vedi
[Dove gira davvero](#dove-gira-davvero)), poi controllo di migrazioni e [Dove gira davvero](#dove-gira-davvero)), poi controllo di migrazioni e
conteggi. In produzione non ci sono gruppi: le quattro migrazioni non hanno conteggi. In produzione non ci sono gruppi né richieste: le migrazioni non
dati da convertire. hanno dati da convertire.
| Aspetto | Stato | | Aspetto | Stato |
|-----------------------------|-------| |-----------------------------|-------|
@@ -63,7 +67,58 @@ sistema visivo, prima suite di test) è in [Da dove veniamo](#da-dove-veniamo).
## Lavori recenti — ottobre 2026 ## Lavori recenti — ottobre 2026
Dal più recente. Le sezioni del 12-19 ottobre sono in produzione; quelle del Dal più recente. Le sezioni del 12-19 ottobre sono in produzione; quelle del
20 ottobre e i gruppi di servizio solo sulla demo. 20-21 ottobre e i gruppi di servizio solo sulla demo.
### Richieste dal modulo pubblico, cartacei, S-13 per email (21 ottobre 2026, solo su dev)
Dal documento `_ARCHIVE/ATTIVITA sviluppo Territori Manager 2.0.docx`
(attività per la versione 2): analizzato con l'utente, che ha deciso così.
- **Modulo pubblico** `/richiesta` (rotta `richiesta`, fuori dal login,
`throttle:60,1`; componente `Richieste\ModuloRichiesta`, layout guest):
nome e cognome, *nuovo territorio* o *riconsegna* (con numero), nota. Non
mostra dati esistenti. Anti-spam: campo trappola `sito` (classe
`.tm-trappola`), tempo minimo `SECONDI_MINIMI` = 3 s (in questi due casi
risponde "inviata" senza salvare), `MASSIMO_ORA` = 5 per IP (chiave del
RateLimiter con lo sha1 dell'IP). Il tempo si legge con `now()`, così i
test possono viaggiare nel tempo.
- **Tabella `richieste`** (migrazione `2026_10_21_000010`, che crea anche il
permesso `richieste.gestisci` per amministratore e assistente e la colonna
`assegnazioni.cartaceo`): nome e note cifrati; nel registro delle attività
solo tipo, stato, numero (`richiesta_ricevuta` all'arrivo, poi i cambi di
stato). `RichiesteServizio`: `registra()`, `destinatari()`, `nomiSimili()`,
`assegnazioneAperta()`.
- **Email di avviso** (`Notifications\RichiestaRicevuta`) a chi ha
`richieste.gestisci` con l'email confermata, solo se la posta funziona
(`ConfigurazionePosta::funzionante()`). Non in coda: contiene il nome.
- **Conferma dalla Home** (riquadro *Richieste*, solo con il permesso):
scelta dell'utente, "così non si modifica troppo il codice del form
precompilato". Per un nuovo territorio la Home propone i proclamatori attivi
con un nome simile (punteggio per parola: uguale 3, iniziale 2, una lettera
di differenza 1; serve almeno 2) e ognuno apre *Assegna* con
`?proclamatore=&richiesta=&dalla_home=1`; per una riconsegna, *Rientra* con
`?richiesta=`. I due moduli leggono `richiesta` solo se aperta e se l'utente
ha il permesso (`#[Locked] richiestaId`), la mostrano sopra il form e, fatta
l'operazione, la segnano **evasa** (`Richiesta::evadi()`). Altrimenti
*Scarta*. La Home "messaggi" del documento sono solo queste richieste.
- **Pulizia**: `richieste:pulizia` ogni notte cancella le richieste gestite da
più di `PuliziaRichieste::GIORNI` = 90 giorni. Le richieste non sono
nell'XML né nel `.zip` (un ripristino non le tocca); l'azzeramento le
cancella (passo "Richieste" di `ResetApplication`, ora 12 passi).
- **Cartaceo sull'assegnazione** (scelta dell'utente: "va sull'assegnazione"):
spunta su *Assegna* e in "Il mio gruppo" (`GruppiServizio::assegna(...,
cartaceo:)`), etichetta "cartaceo" nell'elenco degli assegnati (solo dove è
vera: la riga resta sotto i 2,6 KB) e in "Il mio gruppo", pagina
**Cartacei** (`assegnazioni/cartacei`, `territori.assign`, voce di menu sotto
*Assegnazioni*) con i territori cartacei fuori. Passa per l'XML.
- **S-13 per email**: `registro:invia-s13 {--anno=}` manda
`Mail\ModuloS13Mail` (PDF allegato, non in coda) agli amministratori con
l'email confermata; nello scheduler `weeklyOn(0, '08:00')` con
`when(settimana dopo in un altro mese)` = **ultima domenica del mese**. Senza
posta non parte e lo dice. Nel registro `s13_inviato` con anno e quanti.
- Informativa privacy aggiornata (dati, conteggi, posta, conservazione;
revisione 21 ottobre 2026). Test in `tests/Feature/RichiesteTest.php` e
`GruppiServizioTest` (cartaceo dal sorvegliante, cartaceo nell'XML).
### Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026) ### Sicurezza: PHP CVE-2026-109618 / CVE-2026-109619 (21 ottobre 2026)

View File

@@ -3,7 +3,9 @@
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,
gruppi di servizio (un sorvegliante distribuisce ai membri un pacchetto di gruppi di servizio (un sorvegliante distribuisce ai membri un pacchetto di
territori) e registro storico completo. territori), un modulo pubblico con cui i proclamatori chiedono un territorio o
ne annunciano la riconsegna, l'elenco delle cartoline cartacee fuori, il
modulo S-13 per email ogni mese e registro storico completo.
I dati personali dei proclamatori sono **cifrati nel database**, e ogni 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.
@@ -50,7 +52,7 @@ eseguito né `composer install` né `npm build` all'avvio.
|----------------|------------------------------------------------|---------| |----------------|------------------------------------------------|---------|
| `app` | nginx + php-fpm — l'applicazione | via Traefik | | `app` | nginx + php-fpm — l'applicazione | via Traefik |
| `queue-worker` | Job in coda (import PDF massivi) | no | | `queue-worker` | Job in coda (import PDF massivi) | no |
| `scheduler` | Task pianificati (pulizia audit log) | no | | `scheduler` | Task pianificati (pulizie, S-13 mensile) | no |
| `mariadb` | Database | no | | `mariadb` | Database | no |
| `redis` | Cache, sessioni, code | no | | `redis` | Cache, sessioni, code | no |
@@ -630,12 +632,21 @@ docker exec -it <app> php artisan audit:cleanup
docker exec -it <app> php artisan backup:cleanup docker exec -it <app> php artisan backup:cleanup
docker exec -it <app> php artisan backup:cleanup --ore=0 # tutti docker exec -it <app> php artisan backup:cleanup --ore=0 # tutti
# Pulizia manuale delle richieste dal modulo pubblico gestite da più di 90 giorni
docker exec -it <app> php artisan richieste:pulizia
# Mandare subito il modulo S-13 agli amministratori (anno in corso, o un altro)
docker exec -it <app> php artisan registro:invia-s13
docker exec -it <app> php artisan registro:invia-s13 --anno=2025-2026
# Svuotare le cache # Svuotare le cache
docker exec -it <app> php artisan optimize:clear docker exec -it <app> php artisan optimize:clear
``` ```
Entrambe le pulizie girano già da sole ogni notte (container `scheduler`); i Le pulizie girano già da sole ogni notte e il modulo S-13 parte da solo
comandi qui sopra servono solo per anticiparle. l'ultima domenica del mese alle 8 (container `scheduler`); i comandi qui sopra
servono solo per anticiparli. Senza un server di posta funzionante il modulo
S-13 non parte (il comando lo dice).
--- ---
@@ -696,6 +707,8 @@ docker compose --profile dev up -d --build
l'elenco è troncato, **"Vedi tutti"** è un pulsante in testata che apre l'elenco è troncato, **"Vedi tutti"** è un pulsante in testata che apre
l'elenco territori già filtrato. "Assegna →" apre la pagina *Assegna* con l'elenco territori già filtrato. "Assegna →" apre la pagina *Assegna* con
il territorio scelto e senza l'elenco dei territori assegnati il territorio scelto e senza l'elenco dei territori assegnati
- **Richieste** dal modulo pubblico (amministratore e assistenti): vedi
[Richieste dal modulo pubblico](#richieste-dal-modulo-pubblico)
### Territori ### Territori
- CRUD completo con upload PDF e miniatura generata automaticamente - CRUD completo con upload PDF e miniatura generata automaticamente
@@ -745,6 +758,33 @@ docker compose --profile dev up -d --build
compare un avviso di tempo di percorrenza molto alto compare un avviso di tempo di percorrenza molto alto
- **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)
- **Cartolina cartacea**: assegnando (anche il sorvegliante di gruppo) si può
spuntare *Ha chiesto la cartolina cartacea*. Il segno sta sull'assegnazione:
nell'elenco degli assegnati compare l'etichetta "cartaceo" e la pagina
**Cartacei** (menu, sotto *Assegnazioni*) elenca i territori cartacei fuori
e a chi, con i giorni e il pulsante Rientro. Al rientro escono dall'elenco
### Richieste dal modulo pubblico
- **Modulo pubblico** all'indirizzo `/richiesta`, senza login (l'indirizzo
completo è scritto nella Home, da passare ai proclamatori): nome e cognome,
*nuovo territorio* o *riconsegna* (con il numero del territorio), una nota.
Non mostra nessun dato già presente
- **Contro lo spam**: un campo trappola nascosto, un tempo minimo fra apertura
e invio (in questi due casi risponde "inviata" senza salvare) e al massimo 5
richieste all'ora dallo stesso indirizzo
- **Avviso per email** ad amministratori e assistenti (permesso
`richieste.gestisci`) con l'email confermata, se il server di posta
funziona; la richiesta resta comunque nella Home
- **Nella Home**, riquadro *Richieste*: per un nuovo territorio i proclamatori
attivi con un nome simile a quello scritto (uguale, iniziale, una lettera
sbagliata, in qualsiasi ordine e senza accenti), ognuno porta al modulo
*Assegna* già con quel proclamatore; *Nessuno di questi* apre il modulo
vuoto. Per una riconsegna, a chi risulta assegnato il territorio e *Registra
rientro →*. Fatta l'assegnazione o il rientro, la richiesta risulta evasa;
altrimenti si può **scartare**
- Nome e nota sono cifrati nel database e non vanno nel registro delle
attività; le richieste gestite si cancellano da sole dopo 90 giorni
(`richieste:pulizia`, ogni notte)
### Gruppi di servizio ### Gruppi di servizio
- Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome, - Li gestisce solo l'amministratore (menu *Gruppi di servizio*): nome,
@@ -815,6 +855,10 @@ docker compose --profile dev up -d --build
### Registro assegnazioni ### Registro assegnazioni
- Filtri per anno teocratico, zona, tipologia, stato e ricerca testuale - Filtri per anno teocratico, zona, tipologia, stato e ricerca testuale
- Colonne: territorio, proclamatore, date, giorni, campagna - Colonne: territorio, proclamatore, date, giorni, campagna
- **Modulo S-13 per email**: l'ultima domenica di ogni mese alle 8 il modulo
S-13 dell'anno di servizio in corso parte in PDF agli amministratori,
all'email della loro utenza se confermata (`registro:invia-s13`, anche a
mano). Non passa dalla coda: il PDF contiene i nomi dei proclamatori
### Audit log ### Audit log
- Tracciamento automatico su tutti i modelli - Tracciamento automatico su tutti i modelli
@@ -855,6 +899,7 @@ Quattro ruoli con permessi granulari (spatie/laravel-permission):
| `territori.return` | ✅ | ✅ | ✅ | | | `territori.return` | ✅ | ✅ | ✅ | |
| `audit.view` | ✅ | | | | | `audit.view` | ✅ | | | |
| `audit.export` | ✅ | | | | | `audit.export` | ✅ | | | |
| `richieste.gestisci` | ✅ | ✅ | | |
| `gruppo.gestisci` | | | | ✅ | | `gruppo.gestisci` | | | | ✅ |
L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`). L'amministratore ha un bypass completo (`Gate::before` in `AppServiceProvider`).
@@ -881,7 +926,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
- **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle - **RBAC**: middleware `permission:` sulle rotte, riapplicato anche alle
richieste Livewire richieste Livewire
- **CSRF**: token su tutti i form - **CSRF**: token su tutti i form
- **Rate limiting**: sul login - **Rate limiting**: sul login e sul modulo pubblico delle richieste (5
all'ora per indirizzo, più campo trappola e tempo minimo contro lo spam)
- **Host header**: accettate solo richieste il cui `Host` corrisponde a `APP_URL` - **Host header**: accettate solo richieste il cui `Host` corrisponde a `APP_URL`
- **Scheme forwarding**: lo schema è deciso dal server, non dall'header del - **Scheme forwarding**: lo schema è deciso dal server, non dall'header del
client, per non falsare la validazione dei link firmati client, per non falsare la validazione dei link firmati
@@ -914,6 +960,14 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
- **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 (anche per l'utenza di un sorvegliante di gruppo, che porta il nome in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome
di un proclamatore) di un proclamatore)
- **Richieste dal modulo pubblico**: nome e nota cifrati come i dati dei
proclamatori, fuori dal registro delle attività e dalla coda (l'email di
avviso parte subito); cancellate 90 giorni dopo essere state gestite. Non
sono nei backup (né XML né `.zip`, che contiene lo stesso XML): sono
messaggi in arrivo, non dati gestionali, e un ripristino non le tocca.
L'azzeramento (*Zona pericolosa*) le cancella
- **Email**: il modulo S-13 (con i nomi) va solo agli amministratori con
l'email confermata, l'avviso delle richieste solo a chi le gestisce
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati - **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
@@ -933,7 +987,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
| `territori` | Cartoline con zona, tipologia, PDF, miniatura, soft delete; `gruppo_servizio_id` se sono nel pacchetto di un gruppo | | `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`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo ancora attivo, `gruppo_nome` che resta anche dopo lo scioglimento | | `assegnazioni` | Territorio ↔ proclamatore, con date, anno e campagna; stato dell'invio del link (`link_sent`, `link_da_inviare`, `link_canale`); `gruppo_servizio_id` se data dal sorvegliante di un gruppo ancora attivo, `gruppo_nome` che resta anche dopo lo scioglimento; `cartaceo` se il proclamatore ha chiesto la cartolina cartacea |
| `richieste` | Richieste dal modulo pubblico: tipo (`nuovo`/`riconsegna`), nome e nota cifrati, numero del territorio, stato (`aperta`/`evasa`/`scartata`), l'assegnazione che l'ha evasa, chi e quando l'ha gestita |
| `activity_log` | Audit trail | | `activity_log` | Audit trail |
**Vincoli e indici principali** **Vincoli e indici principali**

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Mail\ModuloS13Mail;
use App\Models\AnnoTeocratico;
use App\Models\User;
use App\Services\ConfigurazionePosta;
use App\Services\Registro\ModuloS13;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
/**
* Il modulo S-13 dell'anno di servizio in corso, per email agli
* amministratori (l'indirizzo della loro utenza, se confermato). Lo lancia lo
* scheduler l'ultima domenica di ogni mese (routes/console.php); a mano si
* può scegliere l'anno con --anno=2025-2026.
*
* Parte subito, non in coda: il PDF contiene i nomi dei proclamatori, che in
* coda starebbero in chiaro. Senza un server di posta funzionante non manda
* niente e lo dice.
*/
class InviaModuloS13 extends Command
{
protected $signature = 'registro:invia-s13 {--anno= : etichetta dell\'anno di servizio, es. 2025-2026 (predefinito: quello in corso)}';
protected $description = 'Manda per email agli amministratori il modulo S-13 dell\'anno di servizio';
public function handle(ModuloS13 $modulo, ConfigurazionePosta $posta): int
{
if (! $posta->funzionante()) {
$this->warn('Nessun server di posta funzionante: modulo S-13 non inviato.');
return self::SUCCESS;
}
$anno = $this->option('anno')
? AnnoTeocratico::where('label', $this->option('anno'))->first()
: AnnoTeocratico::corrente();
if (! $anno) {
$this->error("Anno di servizio {$this->option('anno')} non trovato.");
return self::FAILURE;
}
$destinatari = User::role('amministratore')->get()
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
->values();
if ($destinatari->isEmpty()) {
$this->warn('Nessun amministratore con un\'email confermata: modulo S-13 non inviato.');
return self::SUCCESS;
}
$pdf = $modulo->genera($anno);
// Uno per uno: un indirizzo rifiutato non ferma gli altri.
$inviati = 0;
foreach ($destinatari as $utente) {
try {
Mail::to($utente->email)->send(new ModuloS13Mail($utente, $anno, $pdf));
$inviati++;
} catch (\Throwable $e) {
report($e);
$this->error("Invio a {$utente->email} non riuscito: {$e->getMessage()}");
}
}
// Nel registro quanti, non chi: gli indirizzi stanno nelle utenze.
activity()->withProperties(['anno' => $anno->label, 'destinatari' => $inviati])->log('s13_inviato');
$this->info("Modulo S-13 {$anno->label} inviato a {$inviati} amministratori su {$destinatari->count()}.");
return $inviati === $destinatari->count() ? self::SUCCESS : self::FAILURE;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Models\Richiesta;
use Illuminate\Console\Command;
/**
* Le richieste dal modulo pubblico portano nome e note scritti da chi non ha
* un account: gestite (evase o scartate) servono ancora un po' per
* riferimento, poi si cancellano. Quelle aperte restano finché qualcuno non
* le gestisce.
*/
class PuliziaRichieste extends Command
{
/** Dopo quanti giorni dalla gestione una richiesta si cancella. */
public const GIORNI = 90;
protected $signature = 'richieste:pulizia';
protected $description = 'Cancella le richieste del modulo pubblico gestite da più di ' . self::GIORNI . ' giorni';
public function handle(): int
{
$cancellate = Richiesta::query()
->where('stato', '!=', Richiesta::APERTA)
->where('gestita_at', '<', now()->subDays(self::GIORNI))
->delete();
$this->info("Richieste cancellate: {$cancellate}");
return self::SUCCESS;
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\GruppoServizio; use App\Models\GruppoServizio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\TerritorioMappa; use App\Models\TerritorioMappa;
@@ -76,10 +77,12 @@ 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', 11); $stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 12);
DB::transaction(function () use ($stato) { DB::transaction(function () use ($stato) {
$passi = [ $passi = [
// Le richieste dal modulo pubblico, prima delle assegnazioni che evadono.
'Richieste' => fn () => Richiesta::query()->delete(),
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(), 'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
'Campagne' => fn () => Campagna::query()->delete(), 'Campagne' => fn () => Campagna::query()->delete(),
// Prima dei territori, e per esteso: le piantine hanno la // Prima dei territori, e per esteso: le piantine hanno la

View File

@@ -9,7 +9,9 @@ use App\Models\Territorio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Richiesta;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
class Assegna extends Component class Assegna extends Component
{ {
@@ -20,6 +22,17 @@ class Assegna extends Component
public ?int $proclamatore_id = null; public ?int $proclamatore_id = null;
public string $assigned_at = ''; public string $assigned_at = '';
/** Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). */
public bool $cartaceo = false;
/**
* La richiesta dal modulo pubblico da cui si è partiti (?richiesta=,
* dalla Home): si mostra sopra il modulo e, fatta l'assegnazione, risulta
* evasa. Locked: la imposta solo mount().
*/
#[Locked]
public ?int $richiestaId = null;
// Optional pre-selection from parent context // Optional pre-selection from parent context
public ?int $preselectedTerritorioId = null; public ?int $preselectedTerritorioId = null;
@@ -37,6 +50,17 @@ class Assegna extends Component
$this->assigned_at = now()->format('Y-m-d'); $this->assigned_at = now()->format('Y-m-d');
$this->elencoNascosto = request()->boolean('dalla_home'); $this->elencoNascosto = request()->boolean('dalla_home');
// Dalla Home, da una richiesta: il proclamatore scelto fra i nomi
// simili arriva già impostato (?proclamatore=), il resto del modulo è
// quello di sempre.
if (($id = request()->integer('proclamatore')) && Proclamatore::attivi()->whereKey($id)->exists()) {
$this->proclamatore_id = $id;
}
if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci')
&& Richiesta::aperte()->whereKey($id)->exists()) {
$this->richiestaId = $id;
}
// ?invia=<id>: riprende l'invio di un link rimasto in sospeso (dalla // ?invia=<id>: riprende l'invio di un link rimasto in sospeso (dalla
// scheda del territorio). // scheda del territorio).
if ($invia = request()->integer('invia')) { if ($invia = request()->integer('invia')) {
@@ -109,6 +133,7 @@ class Assegna extends Component
'created_by' => auth()->id(), 'created_by' => auth()->id(),
// C'è un link da mandare solo se il territorio ha il PDF. // C'è un link da mandare solo se il territorio ha il PDF.
'link_da_inviare' => (bool) $territorio->pdf_path, 'link_da_inviare' => (bool) $territorio->pdf_path,
'cartaceo' => $this->cartaceo,
]); ]);
}); });
@@ -131,6 +156,13 @@ class Assegna extends Component
]) ])
->log('assigned'); ->log('assigned');
// Partita da una richiesta del modulo pubblico: ora è evasa.
if ($this->richiestaId) {
Richiesta::find($this->richiestaId)?->evadi($assegnazione, auth()->user());
$this->richiestaId = null;
}
$this->cartaceo = false;
// Non si va avanti: si apre l'invio del link (vedi in cima). // Non si va avanti: si apre l'invio del link (vedi in cima).
$this->apriInvio($assegnazione->id); $this->apriInvio($assegnazione->id);
} }
@@ -177,6 +209,7 @@ class Assegna extends Component
'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,
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
]); ]);
} }
} }

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Assegnazioni;
use App\Models\Assegnazione;
use Livewire\Component;
/**
* I territori fuori in cartaceo, e a chi: le assegnazioni aperte per cui il
* proclamatore ha chiesto la cartolina (spunta "cartaceo" su Assegna o in "Il
* mio gruppo"). Al rientro escono dall'elenco.
*/
class Cartacei extends Component
{
public function render()
{
return view('livewire.assegnazioni.cartacei', [
'assegnazioni' => Assegnazione::aperte()
->where('cartaceo', true)
->with(['territorio.zona', 'proclamatore'])
->orderBy('assigned_at')
->get(),
]);
}
}

View File

@@ -7,6 +7,7 @@ use App\Livewire\Concerns\RicordaRitorno;
use Livewire\Component; use Livewire\Component;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\Richiesta;
class Rientra extends Component class Rientra extends Component
{ {
@@ -25,6 +26,13 @@ class Rientra extends Component
public bool $counted_in_campaign = false; public bool $counted_in_campaign = false;
/**
* La richiesta di riconsegna dal modulo pubblico da cui si è partiti
* (?richiesta=, dalla Home): registrato il rientro, risulta evasa.
*/
#[Locked]
public ?int $richiestaId = null;
public function mount(Assegnazione $assegnazione) public function mount(Assegnazione $assegnazione)
{ {
$this->autorizza($assegnazione); $this->autorizza($assegnazione);
@@ -34,6 +42,11 @@ class Rientra extends Component
abort(404, 'Assegnazione già rientrata.'); abort(404, 'Assegnazione già rientrata.');
} }
if (($id = request()->integer('richiesta')) && auth()->user()->can('richieste.gestisci')
&& Richiesta::aperte()->whereKey($id)->exists()) {
$this->richiestaId = $id;
}
$this->returned_at = now()->format('Y-m-d'); $this->returned_at = now()->format('Y-m-d');
$this->checkCampaign(); $this->checkCampaign();
} }
@@ -132,6 +145,11 @@ class Rientra extends Component
); );
} }
// Partito da una richiesta di riconsegna (Home): ora è evasa.
if ($this->richiestaId) {
Richiesta::find($this->richiestaId)?->evadi($this->assegnazione, auth()->user());
}
session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni."); session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni.");
return $this->redirect($this->destinazione($this->paginaPredefinita()), navigate: true); return $this->redirect($this->destinazione($this->paginaPredefinita()), navigate: true);
} }
@@ -164,6 +182,7 @@ class Rientra extends Component
'campagna' => $campagna, 'campagna' => $campagna,
'giorniPercorrenza' => $giorniPercorrenza, 'giorniPercorrenza' => $giorniPercorrenza,
'sogliaPercorrenza' => $sogliaPercorrenza, 'sogliaPercorrenza' => $sogliaPercorrenza,
'richiesta' => $this->richiestaId ? Richiesta::find($this->richiestaId) : null,
]); ]);
} }
} }

View File

@@ -36,6 +36,9 @@ class MioGruppo extends Component
public string $dataAssegnazione = ''; public string $dataAssegnazione = '';
/** Il membro ha chiesto la cartolina cartacea (elenco Cartacei). */
public bool $cartaceo = false;
// Filtri del pacchetto, nell'indirizzo: tornando dal rientro si ritrovano. // Filtri del pacchetto, nell'indirizzo: tornando dal rientro si ritrovano.
#[Url(except: '')] #[Url(except: '')]
public string $filtroZona = ''; public string $filtroZona = '';
@@ -84,13 +87,14 @@ class MioGruppo extends Component
$this->territorioDelPacchetto($territorioId); $this->territorioDelPacchetto($territorioId);
$this->territorioDaAssegnare = $territorioId; $this->territorioDaAssegnare = $territorioId;
$this->membroId = null; $this->membroId = null;
$this->cartaceo = false;
$this->dataAssegnazione = now()->format('Y-m-d'); $this->dataAssegnazione = now()->format('Y-m-d');
$this->resetValidation(); $this->resetValidation();
} }
public function annullaAssegnazione(): void public function annullaAssegnazione(): void
{ {
$this->reset(['territorioDaAssegnare', 'membroId']); $this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
$this->resetValidation(); $this->resetValidation();
} }
@@ -115,14 +119,14 @@ class MioGruppo extends Component
} }
try { try {
$assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione)); $assegnazione = app(GruppiServizio::class)->assegna($gruppo, $territorio, $membro, Carbon::parse($this->dataAssegnazione), $this->cartaceo);
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$this->addError('membroId', $e->getMessage()); $this->addError('membroId', $e->getMessage());
return; return;
} }
$this->reset(['territorioDaAssegnare', 'membroId']); $this->reset(['territorioDaAssegnare', 'membroId', 'cartaceo']);
// Come dalla pagina Assegna: si apre l'invio del link. // Come dalla pagina Assegna: si apre l'invio del link.
$this->apriInvio($assegnazione->id); $this->apriInvio($assegnazione->id);

View File

@@ -3,16 +3,58 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component; use Livewire\Component;
use App\Livewire\Concerns\Avvisa;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Campagna; use App\Models\Campagna;
use App\Models\Richiesta;
use App\Models\Setting; use App\Models\Setting;
use App\Services\RichiesteServizio;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
class Home extends Component class Home extends Component
{ {
use Avvisa;
/**
* Le richieste dal modulo pubblico si confermano portandole su Assegna o
* Rientra (link nel riquadro); qui si possono solo scartare.
*/
public function scartaRichiesta(int $id): void
{
$this->authorize('richieste.gestisci');
Richiesta::findOrFail($id)->scarta(auth()->user());
$this->avvisa('Richiesta scartata.');
}
/**
* Le richieste aperte, con quello che serve a confermarle: per un
* territorio nuovo i proclamatori dal nome simile a quello scritto, per
* una riconsegna l'assegnazione aperta di quel territorio.
*/
private function richiesteAperte(): \Illuminate\Support\Collection
{
if (! auth()->user()->can('richieste.gestisci')) {
return collect();
}
$aperte = Richiesta::aperte()->orderBy('created_at')->get();
if ($aperte->isEmpty()) {
return collect();
}
$servizio = app(RichiesteServizio::class);
$proclamatori = Proclamatore::attivi()->get();
return $aperte->map(fn (Richiesta $r) => [
'richiesta' => $r,
'simili' => $r->tipo === Richiesta::NUOVO ? $servizio->nomiSimili($r->nome, $proclamatori) : collect(),
'assegnazione' => $r->tipo === Richiesta::RICONSEGNA ? $servizio->assegnazioneAperta($r->numero_territorio) : null,
]);
}
public function downloadPdfDaAssegnare() public function downloadPdfDaAssegnare()
{ {
$this->authorize('territori.assign'); $this->authorize('territori.assign');
@@ -147,6 +189,7 @@ class Home extends Component
'homeLimit' => $homeLimit, 'homeLimit' => $homeLimit,
'territoriDaAssegnare' => $territoriDaAssegnare, 'territoriDaAssegnare' => $territoriDaAssegnare,
'daRientrare' => $daRientrare, 'daRientrare' => $daRientrare,
'richieste' => $this->richiesteAperte(),
]); ]);
} }
} }

View File

@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Proclamatore; use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Services\ConfigurazionePosta; use App\Services\ConfigurazionePosta;
@@ -23,7 +24,7 @@ use Spatie\Permission\Models\Role;
class Privacy extends Component class Privacy extends Component
{ {
/** Data dell'ultima revisione del testo dell'informativa. */ /** Data dell'ultima revisione del testo dell'informativa. */
public const REVISIONE = '19 ottobre 2026'; public const REVISIONE = '21 ottobre 2026';
/** I permessi spiegati a chi legge l'informativa. */ /** I permessi spiegati a chi legge l'informativa. */
public const PERMESSI = [ public const PERMESSI = [
@@ -36,6 +37,7 @@ class Privacy extends Component
'registro.export' => 'esportazione del registro', 'registro.export' => 'esportazione del registro',
'territori.assign' => 'assegnazione dei territori', 'territori.assign' => 'assegnazione dei territori',
'territori.return' => 'rientro dei territori', 'territori.return' => 'rientro dei territori',
'richieste.gestisci' => 'richieste arrivate dal modulo pubblico (nuovo territorio o riconsegna)',
'audit.view' => 'consultazione del registro delle attività', 'audit.view' => 'consultazione del registro delle attività',
'audit.export' => 'esportazione del registro delle attività', 'audit.export' => 'esportazione del registro delle attività',
]; ];
@@ -78,6 +80,8 @@ class Privacy extends Component
'utenti' => User::count(), 'utenti' => User::count(),
'assegnazioni' => Assegnazione::count(), 'assegnazioni' => Assegnazione::count(),
'assegnazioniAperte' => Assegnazione::aperte()->count(), 'assegnazioniAperte' => Assegnazione::aperte()->count(),
'richieste' => Richiesta::count(),
'richiesteAperte' => Richiesta::aperte()->count(),
'attivita' => Activity::count(), 'attivita' => Activity::count(),
'attivitaDal' => Activity::min('created_at'), 'attivitaDal' => Activity::min('created_at'),
], ],

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Livewire\Richieste;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Services\RichiesteServizio;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked;
use Livewire\Component;
/**
* Il modulo pubblico (/richiesta, senza login): un proclamatore chiede un
* territorio nuovo o annuncia una riconsegna. Non mostra nessun dato già
* presente: chi lo apre vede solo i campi da compilare.
*
* Contro lo spam, tre difese che a chi compila davvero non costano niente:
* - un campo trappola ($sito) nascosto alle persone e riempito dai programmi;
* - un tempo minimo fra apertura e invio (i programmi inviano subito);
* - al massimo MASSIMO_ORA richieste all'ora per indirizzo.
* Nei primi due casi si risponde come se fosse andata bene, senza salvare:
* chi prova non impara cosa l'ha fermato.
*/
class ModuloRichiesta extends Component
{
public const MASSIMO_ORA = 5;
public const SECONDI_MINIMI = 3;
public string $tipo = Richiesta::NUOVO;
public string $nome = '';
public string $numero = '';
public string $note = '';
/** Trappola: nascosto alle persone, i programmi lo riempiono. */
public string $sito = '';
#[Locked]
public int $apertoAlle = 0;
#[Locked]
public bool $inviata = false;
public function mount(): void
{
$this->apertoAlle = now()->timestamp;
}
public function invia(RichiesteServizio $richieste): void
{
if ($this->sito !== '' || now()->timestamp - $this->apertoAlle < self::SECONDI_MINIMI) {
$this->inviata = true;
return;
}
$chiave = 'richiesta-pubblica:' . sha1((string) request()->ip());
if (RateLimiter::tooManyAttempts($chiave, self::MASSIMO_ORA)) {
$this->addError('nome', "Hai già mandato diverse richieste: riprova fra un po'.");
return;
}
$this->validate([
'tipo' => 'required|in:' . Richiesta::NUOVO . ',' . Richiesta::RICONSEGNA,
'nome' => 'required|string|min:3|max:100',
'numero' => 'nullable|required_if:tipo,' . Richiesta::RICONSEGNA . '|string|max:20',
'note' => 'nullable|string|max:1000',
], [
'nome.required' => 'Scrivi nome e cognome.',
'nome.min' => 'Scrivi nome e cognome.',
'numero.required_if' => 'Scrivi il numero del territorio che riconsegni.',
]);
RateLimiter::hit($chiave, 3600);
$richieste->registra($this->tipo, $this->nome, $this->numero, $this->note);
$this->reset(['nome', 'numero', 'note']);
$this->inviata = true;
}
public function nuova(): void
{
$this->reset(['tipo', 'nome', 'numero', 'note', 'inviata']);
$this->apertoAlle = now()->timestamp;
}
public function render()
{
return view('livewire.richieste.modulo-richiesta', [
'congregazione' => Setting::instance()->congregazione_nome ?: 'TerManager2',
])->layout('components.layouts.guest', ['title' => 'Richiesta territorio']);
}
}

View File

@@ -817,6 +817,7 @@ 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,
'cartaceo' => ((int) ($assegnazioneNode->cartaceo ?? 0)) === 1,
'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null, 'gruppo_servizio_id' => $gruppoMap[(string) ($assegnazioneNode->legacy_gruppo_id ?? '')] ?? null,
'gruppo_nome' => trim((string) ($assegnazioneNode->gruppo_nome ?? '')) ?: 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,
@@ -887,7 +888,7 @@ class XmlExchange extends Component
'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']), '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', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']), 'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'cartaceo', 'gruppo_servizio_id', 'gruppo_nome', 'pdf_access_code', 'created_at', 'updated_at']),
]; ];
} }
@@ -1388,6 +1389,7 @@ 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('cartaceo', (string) ((int) ($assegnazione['cartaceo'] ?? 0)));
$node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? '')); $node->addChild('legacy_gruppo_id', (string) ($assegnazione['gruppo_servizio_id'] ?? ''));
$this->addXmlText($node, 'gruppo_nome', (string) ($assegnazione['gruppo_nome'] ?? '')); $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'] ?? ''));

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Mail;
use App\Models\AnnoTeocratico;
use App\Models\User;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
/**
* Il modulo S-13 in PDF per un amministratore (registro:invia-s13). Non va in
* coda (niente ShouldQueue): il PDF contiene i nomi dei proclamatori, che in
* coda starebbero in chiaro.
*/
class ModuloS13Mail extends Mailable
{
public function __construct(
public User $utente,
public AnnoTeocratico $anno,
private string $pdf,
) {}
public function nomeFile(): string
{
return "S-13-registro-assegnazioni-{$this->anno->label}.pdf";
}
public function envelope(): Envelope
{
return new Envelope(subject: "Modulo S-13 — anno di servizio {$this->anno->label}");
}
public function content(): Content
{
return new Content(
text: 'mail.modulo-s13',
with: ['data' => now()->format('d/m/Y')],
);
}
/** @return array<int, Attachment> */
public function attachments(): array
{
return [
Attachment::fromData(fn () => $this->pdf, $this->nomeFile())->withMime('application/pdf'),
];
}
}

View File

@@ -22,6 +22,7 @@ class Assegnazione extends Model
'link_sent', 'link_sent',
'link_da_inviare', 'link_da_inviare',
'link_canale', 'link_canale',
'cartaceo',
'note', 'note',
'created_by', 'created_by',
'returned_by', 'returned_by',
@@ -43,6 +44,8 @@ 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',
// Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei).
'cartaceo' => 'boolean',
'gruppo_servizio_id' => 'integer', 'gruppo_servizio_id' => 'integer',
]; ];
} }

112
app/Models/Richiesta.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
/**
* Una richiesta dal modulo pubblico (/richiesta): un proclamatore chiede un
* territorio nuovo o ne annuncia la riconsegna. Non tocca niente da sola:
* compare nella Home a chi ha `richieste.gestisci` (amministratore e
* assistenti), che la porta sul modulo di assegnazione o di rientro già
* esistenti; fatta l'operazione la richiesta risulta evasa. Si può anche
* scartare. Logica in App\Services\RichiesteServizio.
*
* Nome e note li scrive chi compila, senza account: cifrati come i dati dei
* proclamatori, e fuori dal registro delle attività (che logga solo lo stato).
*/
class Richiesta extends Model
{
use LogsActivity;
public const NUOVO = 'nuovo';
public const RICONSEGNA = 'riconsegna';
public const APERTA = 'aperta';
public const EVASA = 'evasa';
public const SCARTATA = 'scartata';
protected $table = 'richieste';
// Appena creata è aperta anche nell'oggetto, non solo nel database.
protected $attributes = ['stato' => self::APERTA];
// L'arrivo lo registra RichiesteServizio (con il numero del territorio):
// qui solo i cambi di stato, con chi li ha fatti.
protected static $recordEvents = ['updated'];
protected $fillable = ['tipo', 'nome', 'numero_territorio', 'note', 'stato', 'assegnazione_id', 'gestita_da', 'gestita_at'];
protected function casts(): array
{
return [
'nome' => 'encrypted',
'note' => 'encrypted',
'gestita_at' => 'datetime',
'assegnazione_id' => 'integer',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['tipo', 'stato', 'assegnazione_id'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function assegnazione()
{
return $this->belongsTo(Assegnazione::class);
}
public function gestitaDa()
{
return $this->belongsTo(User::class, 'gestita_da');
}
public function scopeAperte($query)
{
return $query->where('stato', self::APERTA);
}
public function isAperta(): bool
{
return $this->stato === self::APERTA;
}
public function getTipoEtichettaAttribute(): string
{
return $this->tipo === self::RICONSEGNA ? 'Riconsegna' : 'Nuovo territorio';
}
/** Chiusa da un'assegnazione o da un rientro fatti a partire da lei. */
public function evadi(?Assegnazione $assegnazione, ?User $chi): void
{
if (! $this->isAperta()) {
return;
}
$this->update([
'stato' => self::EVASA,
'assegnazione_id' => $assegnazione?->id,
'gestita_da' => $chi?->id,
'gestita_at' => now(),
]);
}
public function scarta(?User $chi): void
{
if (! $this->isAperta()) {
return;
}
$this->update([
'stato' => self::SCARTATA,
'gestita_da' => $chi?->id,
'gestita_at' => now(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use App\Models\Richiesta;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Avviso ad amministratori e assistenti: è arrivata una richiesta dal modulo
* pubblico. Volutamente non ShouldQueue: il nome scritto nella richiesta
* finirebbe in chiaro nella coda (Redis, failed_jobs).
*/
class RichiestaRicevuta extends Notification
{
public function __construct(private Richiesta $richiesta) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$r = $this->richiesta;
$messaggio = (new MailMessage)
->subject("Nuova richiesta: {$r->tipo_etichetta} — TerManager2")
->greeting('Ciao ' . $notifiable->name . ',')
->line($r->tipo === Richiesta::RICONSEGNA
? "{$r->nome} annuncia la riconsegna del territorio N° {$r->numero_territorio}."
: "{$r->nome} chiede un territorio nuovo.");
if ($r->note) {
$messaggio->line('Note: ' . $r->note);
}
return $messaggio
->action('Apri la Home di TerManager2', route('dashboard'))
->line('La richiesta si conferma dalla Home, nel riquadro "Richieste".')
->salutation('TerManager2');
}
}

View File

@@ -215,7 +215,7 @@ class GruppiServizio
* nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il * nasce un'assegnazione normale, segnata come proveniente dal gruppo. Il
* territorio resta nel pacchetto finché non rientra. * territorio resta nel pacchetto finché non rientra.
*/ */
public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data): Assegnazione public function assegna(GruppoServizio $gruppo, Territorio $territorio, Proclamatore $membro, Carbon $data, bool $cartaceo = false): Assegnazione
{ {
$this->attivo($gruppo); $this->attivo($gruppo);
@@ -238,7 +238,7 @@ class GruppiServizio
throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.'); throw new InvalidArgumentException('La data di assegnazione non può essere nel futuro.');
} }
$esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data) { $esito = DB::transaction(function () use ($gruppo, $territorio, $membro, $data, $cartaceo) {
$bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first(); $bloccato = Territorio::whereKey($territorio->id)->lockForUpdate()->first();
if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) { if (! $bloccato || $bloccato->gruppo_servizio_id !== $gruppo->id) {
return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo."; return "Il territorio {$territorio->numero} non è più nel pacchetto del gruppo.";
@@ -259,6 +259,7 @@ class GruppiServizio
// Il nome resta anche quando il gruppo, sciolto, non c'è più. // Il nome resta anche quando il gruppo, sciolto, non c'è più.
'gruppo_nome' => $gruppo->nome, 'gruppo_nome' => $gruppo->nome,
'link_da_inviare' => (bool) $territorio->pdf_path, 'link_da_inviare' => (bool) $territorio->pdf_path,
'cartaceo' => $cartaceo,
]); ]);
}); });

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Services;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Territorio;
use App\Models\User;
use App\Notifications\RichiestaRicevuta;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
/**
* Le richieste dal modulo pubblico: registrarle, avvisare chi le gestisce e
* aiutarlo a ricondurle a un proclamatore o a un'assegnazione. La conferma
* passa dai moduli già esistenti (Assegna, Rientra), che chiudono la
* richiesta da cui sono partiti.
*/
class RichiesteServizio
{
public function __construct(private ConfigurazionePosta $posta) {}
/** Registra la richiesta e avvisa per email amministratori e assistenti. */
public function registra(string $tipo, string $nome, ?string $numero, ?string $note): Richiesta
{
$richiesta = Richiesta::create([
'tipo' => $tipo === Richiesta::RICONSEGNA ? Richiesta::RICONSEGNA : Richiesta::NUOVO,
'nome' => trim($nome),
'numero_territorio' => $tipo === Richiesta::RICONSEGNA ? trim((string) $numero) : null,
'note' => trim((string) $note) !== '' ? trim((string) $note) : null,
]);
// Nel registro tipo e numero, mai il nome.
activity()->performedOn($richiesta)
->withProperties(array_filter(['tipo' => $richiesta->tipo, 'territorio' => $richiesta->numero_territorio]))
->log('richiesta_ricevuta');
$this->avvisa($richiesta);
return $richiesta;
}
/**
* Chi gestisce le richieste e ha un'email vera: amministratori e
* assistenti (permesso richieste.gestisci) con l'indirizzo confermato.
*/
public function destinatari(): Collection
{
return User::permission('richieste.gestisci')->get()
->filter(fn (User $u) => $u->email && $u->email_verified_at !== null)
->values();
}
/**
* L'email parte subito e non in coda: contiene il nome scritto nella
* richiesta, che nel database è cifrato e in coda starebbe in chiaro.
* Senza un server di posta funzionante non si manda niente: la richiesta
* resta comunque nella Home.
*/
private function avvisa(Richiesta $richiesta): void
{
if (! $this->posta->funzionante()) {
return;
}
$destinatari = $this->destinatari();
if ($destinatari->isEmpty()) {
return;
}
try {
Notification::send($destinatari, new RichiestaRicevuta($richiesta));
} catch (\Throwable $e) {
report($e);
}
}
/**
* I proclamatori attivi con un nome riconducibile a quello scritto nella
* richiesta, dal più simile: uguale, iniziale, una lettera sbagliata.
* I nomi sono cifrati, quindi il confronto si fa qui, non in SQL.
*
* @return Collection<int, Proclamatore>
*/
public function nomiSimili(string $nome, ?Collection $proclamatori = null, int $quanti = 5): Collection
{
$cercate = self::parole($nome);
if ($cercate === []) {
return collect();
}
return ($proclamatori ?? Proclamatore::attivi()->get())
->map(function (Proclamatore $p) use ($cercate) {
$sue = self::parole($p->cognome . ' ' . $p->nome);
$punti = 0;
foreach ($cercate as $parola) {
$migliore = 0;
foreach ($sue as $sua) {
$migliore = max($migliore, self::somiglianza($parola, $sua));
}
$punti += $migliore;
}
return [$p, $punti];
})
->filter(fn ($coppia) => $coppia[1] >= 2)
->sortBy([[fn ($coppia) => $coppia[1], 'desc'], [fn ($coppia) => mb_strtolower($coppia[0]->nome_completo), 'asc']])
->take($quanti)
->map(fn ($coppia) => $coppia[0])
->values();
}
/** L'assegnazione aperta del territorio indicato in una riconsegna, se c'è. */
public function assegnazioneAperta(?string $numero): ?Assegnazione
{
$numero = trim((string) $numero);
if ($numero === '') {
return null;
}
$territorio = Territorio::query()
->whereRaw('LOWER(numero) = ?', [Str::lower($numero)])
->with('assegnazioneCorrente.proclamatore')
->first();
return $territorio?->assegnazioneCorrente;
}
/** @return string[] parole minuscole, senza accenti né segni */
private static function parole(string $testo): array
{
$pulito = preg_replace('/[^a-z0-9 ]+/', ' ', Str::lower(Str::ascii($testo)));
return array_values(array_filter(explode(' ', (string) $pulito), fn ($p) => strlen($p) >= 2));
}
/** 3 uguale, 2 una inizia con l'altra, 1 una lettera di differenza. */
private static function somiglianza(string $a, string $b): int
{
if ($a === $b) {
return 3;
}
if (min(strlen($a), strlen($b)) >= 3 && (str_starts_with($a, $b) || str_starts_with($b, $a))) {
return 2;
}
if (min(strlen($a), strlen($b)) >= 4 && levenshtein($a, $b) <= 1) {
return 1;
}
return 0;
}
}

View File

@@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* Dal documento delle attività per la versione 2, decise dall'utente il
* 21/10/2026:
*
* - richieste dal modulo pubblico (nuovo territorio o riconsegna), che
* responsabile e assistenti confermano dalla Home: tabella `richieste` e
* permesso `richieste.gestisci` (amministratore e assistente);
* - territorio chiesto in cartaceo: segno sull'assegnazione
* (`assegnazioni.cartaceo`).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('richieste', function (Blueprint $table) {
$table->id();
// 'nuovo' (richiesta di un territorio) | 'riconsegna'
$table->string('tipo', 20);
// Nome e cognome come li ha scritti chi compila: cifrati, come i
// proclamatori (cast `encrypted` nel modello).
$table->text('nome');
$table->string('numero_territorio', 20)->nullable();
$table->text('note')->nullable();
// 'aperta' | 'evasa' | 'scartata'
$table->string('stato', 20)->default('aperta')->index();
$table->foreignId('assegnazione_id')->nullable()->constrained('assegnazioni')->nullOnDelete();
$table->foreignId('gestita_da')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('gestita_at')->nullable();
$table->timestamps();
});
Schema::table('assegnazioni', function (Blueprint $table) {
$table->boolean('cartaceo')->default(false)->after('link_canale');
});
app(PermissionRegistrar::class)->forgetCachedPermissions();
$permesso = Permission::firstOrCreate(['name' => 'richieste.gestisci', 'guard_name' => 'web']);
foreach (['amministratore', 'assistente'] as $ruolo) {
Role::where('name', $ruolo)->where('guard_name', 'web')->first()?->givePermissionTo($permesso);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
public function down(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
Permission::where('name', 'richieste.gestisci')->delete();
app(PermissionRegistrar::class)->forgetCachedPermissions();
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropColumn('cartaceo');
});
Schema::dropIfExists('richieste');
}
};

View File

@@ -26,6 +26,7 @@ class RolesAndPermissionsSeeder extends Seeder
'audit.view', 'audit.view',
'audit.export', 'audit.export',
'gruppo.gestisci', 'gruppo.gestisci',
'richieste.gestisci',
]; ];
foreach ($permissions as $permission) { foreach ($permissions as $permission) {
@@ -56,6 +57,8 @@ class RolesAndPermissionsSeeder extends Seeder
'territori.assign', 'territori.assign',
'territori.return', 'territori.return',
'registro.view', 'registro.view',
// Le richieste dal modulo pubblico, confermate dalla Home.
'richieste.gestisci',
]); ]);
// Operatore: assign and return only // Operatore: assign and return only

View File

@@ -1805,6 +1805,12 @@ html { scroll-behavior: smooth; }
} }
.tm-assegnato-numero { color: #4f46e5; font-size: 14px; font-weight: 700; } .tm-assegnato-numero { color: #4f46e5; font-size: 14px; font-weight: 700; }
.tm-assegnato-numero:hover { text-decoration: underline; } .tm-assegnato-numero:hover { text-decoration: underline; }
/* Campo trappola del modulo pubblico delle richieste: fuori dallo schermo,
non `display:none` (alcuni programmi saltano i campi nascosti così). */
.tm-trappola {
position: absolute; left: -10000px; top: auto;
width: 1px; height: 1px; overflow: hidden;
}
/* Il numero come pulsante ("Il mio gruppo": apre l'anteprima): stesso aspetto. */ /* Il numero come pulsante ("Il mio gruppo": apre l'anteprima): stesso aspetto. */
button.tm-assegnato-numero { button.tm-assegnato-numero {
background: none; border: 0; padding: 0; cursor: pointer; background: none; border: 0; padding: 0; cursor: pointer;
@@ -1817,6 +1823,11 @@ button.tm-assegnato-numero:focus-visible {
margin-left: 6px; padding: 2px 6px; border-radius: 4px; margin-left: 6px; padding: 2px 6px; border-radius: 4px;
background: #f3f4f6; color: #9ca3af; font-size: 12px; background: #f3f4f6; color: #9ca3af; font-size: 12px;
} }
/* Il proclamatore ha chiesto la cartolina cartacea (elenco Cartacei). */
.tm-cartaceo {
margin-left: 4px; padding: 2px 6px; border-radius: 4px;
background: #fffbeb; color: #b45309; font-size: 12px; font-weight: 600;
}
.tm-assegnato-info { color: #6b7280; font-size: 12px; } .tm-assegnato-info { color: #6b7280; font-size: 12px; }
.tm-assegnato-info b { color: #1f2937; font-size: 14px; font-weight: 500; } .tm-assegnato-info b { color: #1f2937; font-size: 14px; font-weight: 500; }
.tm-assegnato-giorni { .tm-assegnato-giorni {

View File

@@ -120,6 +120,11 @@
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Assegnazioni Assegnazioni
</a> </a>
<a href="{{ route('assegnazioni.cartacei') }}"
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('assegnazioni.cartacei') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Cartacei
</a>
@endcan @endcan
@can('campagne.manage') @can('campagne.manage')

View File

@@ -26,6 +26,13 @@
{{-- Form --}} {{-- Form --}}
<div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl"> <div x-show="formOpen" x-cloak class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-6 max-w-3xl">
@if($richiesta)
{{-- Partita da una richiesta del modulo pubblico (Home): assegnando, risulta evasa. --}}
<div class="mb-4 rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900" role="status">
Richiesta di <strong>{{ $richiesta->nome }}</strong> del {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif.
Assegnando il territorio, la richiesta risulta evasa.
</div>
@endif
<form wire:submit="save" class="space-y-4"> <form wire:submit="save" class="space-y-4">
{{-- L'anteprima segue la scelta nel browser (evento "scelta" di {{-- L'anteprima segue la scelta nel browser (evento "scelta" di
<x-scelta>, miniatura nelle opzioni): scegliere un <x-scelta>, miniatura nelle opzioni): scegliere un
@@ -65,6 +72,12 @@
@error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input wire:model="cartaceo" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Ha chiesto la cartolina cartacea
<a href="{{ route('assegnazioni.cartacei') }}" class="text-xs text-indigo-600 hover:underline">(vedi i cartacei fuori)</a>
</label>
<div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center"> <div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
<x-btn variante="primary" type="submit">Assegna</x-btn> <x-btn variante="primary" type="submit">Assegna</x-btn>
@if($ritorno) @if($ritorno)

View File

@@ -0,0 +1,47 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Territori cartacei</h1>
<p class="text-sm text-gray-500 mt-1">
Le cartoline cartacee fuori e a chi: territori assegnati per cui il proclamatore ha chiesto il cartaceo.
Al rientro escono dall'elenco.
</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
@if($assegnazioni->isEmpty())
<p class="px-5 py-8 text-center text-sm text-gray-500">Nessun territorio cartaceo fuori.</p>
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Territorio</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Proclamatore</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Dal</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Giorni</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $a)
<tr wire:key="cartaceo-{{ $a->id }}">
<td data-label="Territorio" class="px-4 py-3">
<a data-ritorno href="{{ route('territori.show', $a->territorio_id) }}" class="font-semibold text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a>
<span class="text-gray-500">{{ $a->territorio?->zona?->nome }}</span>
</td>
<td data-label="Proclamatore" class="px-4 py-3">{{ $a->proclamatore?->nome_completo }}</td>
<td data-label="Dal" class="px-4 py-3 tabular-nums">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td data-label="Giorni" class="px-4 py-3 text-right tabular-nums">{{ $a->giorni }}</td>
<td class="px-4 py-3 text-right cell-actions">
@can('territori.return')
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm" :href="route('assegnazioni.rientra', $a)">Rientro</x-btn>
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>

View File

@@ -45,7 +45,7 @@
(data-ritorno); indirizzo relativo e niente a capo: pesa meno. --}} (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 @if($a->cartaceo)<span class="tm-cartaceo">cartaceo</span>@endif</span>
<span class="tm-assegnato-info"><b>{{ $nome }}</b> · {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span> <span class="tm-assegnato-info"><b>{{ $nome }}</b> · {{ $a->assigned_at->format('d/m/Y') }} · <span class="tm-assegnato-giorni {{ $a->giorni > 120 ? 'tm-g-tardi' : ($a->giorni > 90 ? 'tm-g-presto' : '') }}">{{ $a->giorni }}g</span></span>
</div> </div>
@if($a->link_in_sospeso) @if($a->link_in_sospeso)

View File

@@ -7,6 +7,13 @@
<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>
@if($richiesta)
{{-- Partito da una richiesta di riconsegna (Home): registrando il rientro, risulta evasa. --}}
<div class="mb-4 rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900 max-w-lg" role="status">
Riconsegna annunciata da <strong>{{ $richiesta->nome }}</strong> il {{ $richiesta->created_at->format('d/m/Y') }}@if($richiesta->note): «{{ $richiesta->note }}»@endif.
</div>
@endif
{{-- Assignment summary --}} {{-- Assignment summary --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">

View File

@@ -89,7 +89,7 @@
data-miniatura="{{ $t->miniaturaGruppoUrl() }}" data-miniatura="{{ $t->miniaturaGruppoUrl() }}"
data-pdf="{{ $t->pdf_path ? route('mio-gruppo.pdf', $t) : '' }}" data-pdf="{{ $t->pdf_path ? route('mio-gruppo.pdf', $t) : '' }}"
x-on:click="anteprima = { titolo: $el.dataset.titolo, miniatura: $el.dataset.miniatura, pdf: $el.dataset.pdf }">N° {{ $t->numero }}</button>@else<span class="tm-assegnato-numero">N° {{ $t->numero }}</span>@endif @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif x-on:click="anteprima = { titolo: $el.dataset.titolo, miniatura: $el.dataset.miniatura, pdf: $el.dataset.pdf }">N° {{ $t->numero }}</button>@else<span class="tm-assegnato-numero">N° {{ $t->numero }}</span>@endif @if($t->zona?->nome)<span class="tm-assegnato-zona">{{ $t->zona->nome }}</span>@endif
@if($t->in_priorita)<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>@endif</span> @if($t->in_priorita)<span class="ml-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide" style="background:#fef3c7;color:#92400e">★ Prioritario</span>@endif @if($a?->cartaceo)<span class="tm-cartaceo">cartaceo</span>@endif</span>
@if($a) @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> <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 @else
@@ -126,6 +126,10 @@
class="block w-full rounded-lg border-gray-300 text-base sm:text-sm focus:ring-indigo-500 focus:border-indigo-500"> 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 @error('dataAssegnazione') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input wire:model="cartaceo" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
Ha chiesto la cartolina cartacea
</label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<x-btn variante="primary" wire:click="assegna">Assegna</x-btn> <x-btn variante="primary" wire:click="assegna">Assegna</x-btn>
<x-btn variante="ghost" wire:click="annullaAssegnazione">Annulla</x-btn> <x-btn variante="ghost" wire:click="annullaAssegnazione">Annulla</x-btn>

View File

@@ -78,6 +78,61 @@
@endif @endif
</div> </div>
{{-- Richieste dal modulo pubblico: si confermano portandole sul modulo
di assegnazione o di rientro, che le chiude da solo. --}}
@can('richieste.gestisci')
<section class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6" aria-labelledby="titolo-richieste">
<div class="px-5 py-4 border-b flex flex-wrap items-center gap-3" style="background:linear-gradient(135deg,#eef2ff,#e0e7ff);border-color:#c7d2fe">
<div>
<h3 id="titolo-richieste" class="text-sm font-semibold" style="color:#3730a3">Richieste ({{ $richieste->count() }})</h3>
<p class="text-xs" style="color:#4f46e5">Dal modulo pubblico: <code class="select-all">{{ route('richiesta') }}</code></p>
</div>
</div>
@forelse($richieste as $voce)
@php
$r = $voce['richiesta'];
$a = $voce['assegnazione'];
@endphp
<div class="px-5 py-4 border-b border-gray-100 last:border-b-0" wire:key="richiesta-{{ $r->id }}">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="text-sm text-gray-900">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $r->tipo === \App\Models\Richiesta::RICONSEGNA ? 'bg-amber-50 text-amber-800' : 'bg-indigo-50 text-indigo-700' }}">{{ $r->tipo_etichetta }}</span>
<strong class="ml-1">{{ $r->nome }}</strong>
@if($r->numero_territorio) · territorio N° {{ $r->numero_territorio }} @endif
</p>
<span class="text-xs text-gray-500">{{ $r->created_at->format('d/m/Y H:i') }}</span>
</div>
@if($r->note)
<p class="mt-1 text-sm text-gray-600 italic">«{{ $r->note }}»</p>
@endif
<div class="mt-3 flex flex-wrap items-center gap-2">
@if($r->tipo === \App\Models\Richiesta::NUOVO)
<span class="text-xs text-gray-500">{{ $voce['simili']->isNotEmpty() ? 'Chi è? Assegna a:' : 'Nessun proclamatore con un nome simile.' }}</span>
@foreach($voce['simili'] as $p)
<x-btn data-ritorno variante="secondary" tono="tenue" dimensione="sm"
:href="route('assegnazioni.assegna', ['proclamatore' => $p->id, 'richiesta' => $r->id, 'dalla_home' => 1])">{{ $p->nome_completo }} →</x-btn>
@endforeach
<x-btn data-ritorno variante="ghost" dimensione="sm"
:href="route('assegnazioni.assegna', ['richiesta' => $r->id, 'dalla_home' => 1])">{{ $voce['simili']->isNotEmpty() ? 'Nessuno di questi: apri il modulo' : 'Apri il modulo' }}</x-btn>
@elseif($a)
<span class="text-xs text-gray-500">Assegnato a <strong>{{ $a->proclamatore?->nome_completo }}</strong> dal {{ $a->assigned_at->format('d/m/Y') }}</span>
<x-btn data-ritorno variante="primary" tono="tenue" dimensione="sm"
:href="route('assegnazioni.rientra', ['assegnazione' => $a->id, 'richiesta' => $r->id])">Registra rientro →</x-btn>
@else
<span class="text-xs text-amber-800">Il territorio N° {{ $r->numero_territorio }} non risulta assegnato.</span>
@endif
<x-btn variante="danger" tono="tenue" dimensione="sm" class="ml-auto"
wire:click="scartaRichiesta({{ $r->id }})"
wire:confirm="Scartare la richiesta di {{ $r->nome }}?">Scarta</x-btn>
</div>
</div>
@empty
<p class="px-5 py-4 text-sm text-gray-500">Nessuna richiesta aperta.</p>
@endforelse
</section>
@endcan
{{-- Quick lists --}} {{-- Quick lists --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Da assegnare --}} {{-- Da assegnare --}}

View File

@@ -18,7 +18,8 @@
<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 }}), se sono attivi e l'eventuale gruppo di servizio di cui fanno parte. 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, 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>Assegnazioni</strong>: collegamento tra proclamatore e territorio, con date di assegnazione e rientro, conteggio nelle campagne, codice del link alla piantina, stato del suo invio (inviato o no, e con quale canale) se il proclamatore ha chiesto la cartolina cartacea e, se l'ha data il sorvegliante di un gruppo di servizio, il nome del gruppo.</li>
<li><strong>Richieste dal modulo pubblico</strong>: chi chiede un territorio o ne annuncia la riconsegna scrive il proprio nome, l'eventuale numero del territorio e una nota. Nome e nota sono cifrati nel database ({{ $cifratura }}) e non finiscono nel registro delle attività. Dell'indirizzo IP resta solo un'impronta temporanea, per limitare i tentativi.</li>
<li><strong>Utenti dell'applicazione</strong>: nome, email (che va confermata), password conservata solo come impronta non reversibile, ruolo. Il sorvegliante di un gruppo di servizio entra con un'email fittizia creata dall'applicazione (iniziale del nome e cognome), che non riceve posta e non va confermata. La sua utenza è collegata al suo proclamatore e viene eliminata quando il gruppo si scioglie; anche il gruppo, sciolto, viene eliminato.</li> <li><strong>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>
@@ -29,6 +30,7 @@
<ul class="list-disc list-inside space-y-1"> <ul class="list-disc list-inside space-y-1">
<li>{{ $conteggi['proclamatori'] }} proclamatori, di cui {{ $conteggi['proclamatoriAttivi'] }} attivi; {{ $conteggi['anonimizzati'] }} nel cestino o anonimizzati.</li> <li>{{ $conteggi['proclamatori'] }} proclamatori, di cui {{ $conteggi['proclamatoriAttivi'] }} attivi; {{ $conteggi['anonimizzati'] }} nel cestino o anonimizzati.</li>
<li>{{ $conteggi['assegnazioni'] }} assegnazioni nello storico, di cui {{ $conteggi['assegnazioniAperte'] }} in corso.</li> <li>{{ $conteggi['assegnazioni'] }} assegnazioni nello storico, di cui {{ $conteggi['assegnazioniAperte'] }} in corso.</li>
<li>{{ $conteggi['richieste'] }} richieste dal modulo pubblico, di cui {{ $conteggi['richiesteAperte'] }} da gestire.</li>
<li>{{ $conteggi['utenti'] }} utenti dell'applicazione.</li> <li>{{ $conteggi['utenti'] }} utenti dell'applicazione.</li>
<li>{{ $conteggi['attivita'] }} voci nel registro delle attività@if($conteggi['attivitaDal']), dal {{ \Illuminate\Support\Carbon::parse($conteggi['attivitaDal'])->format('d/m/Y') }}@endif.</li> <li>{{ $conteggi['attivita'] }} voci nel registro delle attività@if($conteggi['attivitaDal']), dal {{ \Illuminate\Support\Carbon::parse($conteggi['attivitaDal'])->format('d/m/Y') }}@endif.</li>
</ul> </ul>
@@ -59,6 +61,7 @@
@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. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email fittizia e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p> <p class="mt-2">Il proclamatore non ha un account: riceve solo il link alla piantina del territorio che gli è assegnato. Fa eccezione il sorvegliante di un gruppo di servizio, che ha un'utenza (email fittizia e password) con cui vede solo il proprio gruppo: i territori del suo pacchetto e i nomi dei membri.</p>
<p class="mt-2">Il modulo pubblico di richiesta non mostra nessun dato: chi lo compila scrive soltanto. Le richieste le vedono e le gestiscono, dalla Home, solo i ruoli con il permesso sulle richieste.</p>
</section> </section>
<section> <section>
@@ -67,6 +70,7 @@
<li><strong>Cifratura</strong>: nome e cognome dei proclamatori sono cifrati con {{ $cifratura }} prima di essere salvati; la chiave non sta nel database.</li> <li><strong>Cifratura</strong>: nome e cognome dei proclamatori sono cifrati con {{ $cifratura }} prima di essere salvati; la chiave non sta nel database.</li>
<li><strong>Accesso</strong>: password personale, conferma dell'email, blocco temporaneo dopo troppi tentativi; la sessione scade dopo {{ $durataSessione }} minuti di inattività.</li> <li><strong>Accesso</strong>: password personale, conferma dell'email, blocco temporaneo dopo troppi tentativi; la sessione scade dopo {{ $durataSessione }} minuti di inattività.</li>
<li><strong>Permessi</strong>: ogni ruolo vede e modifica solo ciò che gli serve (vedi sopra).</li> <li><strong>Permessi</strong>: ogni ruolo vede e modifica solo ciò che gli serve (vedi sopra).</li>
<li><strong>Modulo pubblico</strong>: protetto dallo spam con un campo trappola, un tempo minimo di compilazione e un limite di richieste per indirizzo.</li>
<li><strong>Registro delle attività</strong>: le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</li> <li><strong>Registro delle attività</strong>: le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</li>
<li><strong>Comunicazioni</strong>: l'applicazione risponde all'indirizzo <code>{{ $indirizzo }}</code>@if($https), su connessione cifrata (HTTPS)@else. <span class="text-red-700 font-medium">Attenzione: l'indirizzo non usa HTTPS, le comunicazioni non sono cifrate.</span>@endif.</li> <li><strong>Comunicazioni</strong>: l'applicazione risponde all'indirizzo <code>{{ $indirizzo }}</code>@if($https), su connessione cifrata (HTTPS)@else. <span class="text-red-700 font-medium">Attenzione: l'indirizzo non usa HTTPS, le comunicazioni non sono cifrate.</span>@endif.</li>
<li><strong>Hosting</strong>: i dati risiedono sul server gestito dagli amministratori dell'applicazione, non su servizi cloud di terze parti.</li> <li><strong>Hosting</strong>: i dati risiedono sul server gestito dagli amministratori dell'applicazione, non su servizi cloud di terze parti.</li>
@@ -88,7 +92,7 @@
<li><strong>Condivisione del link</strong>: il messaggio con il nome del proclamatore e il link passa per l'app scelta da chi assegna il territorio (WhatsApp, Telegram, SMS…), dal suo telefono, non dall'applicazione.</li> <li><strong>Condivisione del link</strong>: il messaggio con il nome del proclamatore e il link passa per l'app scelta da chi assegna il territorio (WhatsApp, Telegram, SMS…), dal suo telefono, non dall'applicazione.</li>
<li><strong>Posta</strong>: <li><strong>Posta</strong>:
@if($postaAttiva) @if($postaAttiva)
le email agli utenti dell'applicazione (conferma dell'indirizzo) partono dal server di posta <code>{{ $hostPosta ?? 'configurato' }}</code>. Ai proclamatori non si mandano email. le email agli utenti dell'applicazione partono dal server di posta <code>{{ $hostPosta ?? 'configurato' }}</code>: conferma dell'indirizzo, avviso di una nuova richiesta dal modulo pubblico a chi le gestisce, e il modulo S-13 (registro delle assegnazioni, in PDF) agli amministratori l'ultima domenica del mese. Ai proclamatori non si mandano email.
@else @else
nessun server di posta configurato: l'applicazione non invia email. Ai proclamatori non se ne mandano comunque. nessun server di posta configurato: l'applicazione non invia email. Ai proclamatori non se ne mandano comunque.
@endif @endif
@@ -104,6 +108,7 @@
<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>
<li>Le richieste dal modulo pubblico sono cancellate automaticamente <strong>{{ \App\Console\Commands\PuliziaRichieste::GIORNI }} giorni</strong> dopo essere state gestite.</li>
<li>I backup e gli export XML scaricati dagli amministratori contengono nome e cognome dei proclamatori <strong>in chiaro</strong>: vanno custoditi in un luogo protetto e cancellati quando non servono più.</li> <li>I backup e gli export XML scaricati dagli amministratori contengono nome e cognome dei proclamatori <strong>in chiaro</strong>: vanno custoditi in un luogo protetto e cancellati quando non servono più.</li>
</ul> </ul>
</section> </section>

View File

@@ -0,0 +1,74 @@
<div class="bg-white shadow-xl rounded-2xl p-6 sm:p-8 my-8" style="border-top:4px solid #6366f1">
{{-- Modulo pubblico (ModuloRichiesta): nessun dato già presente si vede
qui, solo i campi da compilare. --}}
<div class="text-center mb-6">
<div class="mx-auto h-14 w-14 rounded-xl flex items-center justify-center text-white font-bold text-xl mb-4 shadow-lg" style="background:linear-gradient(135deg,#4338ca,#6366f1)">T2</div>
<h1 class="text-2xl font-bold text-gray-900">Richiesta territorio</h1>
<p class="text-gray-500 text-sm mt-1">{{ $congregazione }}</p>
</div>
@if($inviata)
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-800" role="status">
<p class="font-semibold">Richiesta inviata.</p>
<p class="mt-1">Il responsabile dei territori la vedrà e ti farà sapere.</p>
</div>
<div class="mt-5 text-center">
<x-btn variante="secondary" wire:click="nuova">Fai un'altra richiesta</x-btn>
</div>
@else
<form wire:submit="invia" class="space-y-5">
<fieldset>
<legend class="block text-sm font-medium text-gray-700 mb-2">Cosa ti serve? *</legend>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<label class="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-3 text-sm cursor-pointer has-[:checked]:border-indigo-400 has-[:checked]:bg-indigo-50">
<input type="radio" wire:model.live="tipo" value="{{ \App\Models\Richiesta::NUOVO }}" class="h-4 w-4 text-indigo-600">
Chiedo un territorio nuovo
</label>
<label class="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-3 text-sm cursor-pointer has-[:checked]:border-indigo-400 has-[:checked]:bg-indigo-50">
<input type="radio" wire:model.live="tipo" value="{{ \App\Models\Richiesta::RICONSEGNA }}" class="h-4 w-4 text-indigo-600">
Riconsegno un territorio
</label>
</div>
</fieldset>
<div>
<label for="nome" class="block text-sm font-medium text-gray-700">Nome e cognome *</label>
<input wire:model="nome" type="text" id="nome" autocomplete="name" maxlength="100"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5">
@error('nome') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@if($tipo === \App\Models\Richiesta::RICONSEGNA)
<div>
<label for="numero" class="block text-sm font-medium text-gray-700">N° del territorio *</label>
<input wire:model="numero" type="text" id="numero" inputmode="numeric" maxlength="20" autocomplete="off"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5">
@error('numero') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
<div>
<label for="note" class="block text-sm font-medium text-gray-700">
{{ $tipo === \App\Models\Richiesta::RICONSEGNA ? 'Note' : 'Esigenze particolari / note' }}
</label>
<textarea wire:model="note" id="note" rows="3" maxlength="1000"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-base sm:text-sm px-4 py-2.5"></textarea>
@error('note') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
{{-- Trappola per i programmi che riempiono ogni campo: nascosta a chi
guarda e ai lettori di schermo. --}}
<div class="tm-trappola" aria-hidden="true">
<label for="sito">Lascia vuoto questo campo</label>
<input wire:model="sito" type="text" id="sito" tabindex="-1" autocomplete="off">
</div>
<x-btn variante="primary" type="submit" class="w-full">Invia la richiesta</x-btn>
<p class="text-xs text-gray-500">
Nome e note servono solo a gestire questa richiesta: li vedono il responsabile dei territori e i suoi
assistenti, e vengono cancellati {{ \App\Console\Commands\PuliziaRichieste::GIORNI }} giorni dopo che la richiesta è stata gestita.
</p>
</form>
@endif
</div>

View File

@@ -0,0 +1,7 @@
Ciao {!! $utente->name !!},
in allegato il modulo S-13 (registro di assegnazione dei territori) dell'anno di servizio {!! $anno->label !!}, aggiornato al {!! $data !!}.
Lo manda TerManager2 l'ultima domenica di ogni mese agli amministratori.
TerManager2

View File

@@ -15,3 +15,15 @@ Schedule::command('audit:cleanup')->daily();
// mai completato o da un job interrotto. Vivono nello stesso volume dei PDF dei // mai completato o da un job interrotto. Vivono nello stesso volume dei PDF dei
// territori, quindi accumularli finisce per riempirlo. // territori, quindi accumularli finisce per riempirlo.
Schedule::command('backup:cleanup')->daily(); Schedule::command('backup:cleanup')->daily();
// Modulo S-13 per email agli amministratori, l'ultima domenica di ogni mese
// alle 8 (documento delle attività per la versione 2, deciso il 21/10/2026):
// una domenica è l'ultima del mese quando la settimana dopo è già nel mese
// seguente.
Schedule::command('registro:invia-s13')
->weeklyOn(0, '08:00')
->when(fn () => now()->addWeek()->month !== now()->month)
->withoutOverlapping();
// Richieste del modulo pubblico già gestite: nome e note non servono più.
Schedule::command('richieste:pulizia')->daily();

View File

@@ -23,6 +23,7 @@ use App\Livewire\Proclamatori\ProclamatoreShow;
use App\Livewire\Proclamatori\ProclamatoreEdit; 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\Cartacei;
use App\Livewire\Assegnazioni\Rientra; use App\Livewire\Assegnazioni\Rientra;
use App\Livewire\Gruppi\GruppiIndex; use App\Livewire\Gruppi\GruppiIndex;
use App\Livewire\Gruppi\GruppoShow; use App\Livewire\Gruppi\GruppoShow;
@@ -68,6 +69,12 @@ Route::get('assegnazioni/pdf/{assignment}/{code}', [AssignmentPdfController::cla
Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file']) Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file'])
->name('assignments.pdf.file'); ->name('assignments.pdf.file');
// Modulo pubblico delle richieste (territorio nuovo, riconsegna): senza login.
// Le difese anti-spam stanno nel componente (ModuloRichiesta).
Route::get('richiesta', App\Livewire\Richieste\ModuloRichiesta::class)
->middleware('throttle:60,1')
->name('richiesta');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Authenticated Routes | Authenticated Routes
@@ -165,6 +172,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
// Assegnazioni // Assegnazioni
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::get('assegnazioni/cartacei', Cartacei::class)->name('assegnazioni.cartacei');
}); });
// Il rientro lo registra anche il sorvegliante di gruppo, ma solo per il // Il rientro lo registra anche il sorvegliante di gruppo, ma solo per il
// suo gruppo: il controllo fine è in Rientra::autorizza(). // suo gruppo: il controllo fine è in Rientra::autorizza().

View File

@@ -306,6 +306,23 @@ class GruppiServizioTest extends TestCase
$this->assertTrue($a->link_da_inviare); $this->assertTrue($a->link_da_inviare);
$this->assertSame($gruppo->id, $territori[0]->fresh()->gruppo_servizio_id, 'Resta nel pacchetto finché non rientra'); $this->assertSame($gruppo->id, $territori[0]->fresh()->gruppo_servizio_id, 'Resta nel pacchetto finché non rientra');
$this->assertSame('assegnato', $territori[0]->fresh()->stato); $this->assertSame('assegnato', $territori[0]->fresh()->stato);
$this->assertFalse($a->cartaceo);
}
public function test_il_sorvegliante_segna_la_cartolina_cartacea(): void
{
['sorvegliante' => $s, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
$this->flushSession();
Livewire::actingAs($s)->test(MioGruppo::class)
->call('apriAssegnazione', $territori[0]->id)
->set('membroId', $verdi->id)
->set('cartaceo', true)
->call('assegna')
->assertHasNoErrors()
->assertSet('cartaceo', false);
$this->assertTrue(Assegnazione::sole()->cartaceo);
} }
public function test_il_sorvegliante_puo_tenere_un_territorio_per_se(): void public function test_il_sorvegliante_puo_tenere_un_territorio_per_se(): void
@@ -783,11 +800,12 @@ class GruppiServizioTest extends TestCase
public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void
{ {
['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo(); ['gruppo' => $gruppo, 'verdi' => $verdi, 'territori' => $territori] = $this->gruppo();
app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now()); app(GruppiServizio::class)->assegna($gruppo, $territori[0], $verdi, now(), cartaceo: true);
$xml = (new XmlExchange())->currentXmlBackup(); $xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml)); $this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$this->assertTrue(Assegnazione::sole()->cartaceo, 'Il segno del cartaceo passa per l\'XML');
$g = GruppoServizio::sole(); $g = GruppoServizio::sole();
$this->assertSame('Zisa', $g->nome); $this->assertSame('Zisa', $g->nome);
$this->assertSame('Rossi Mario', $g->sorvegliante->nome_completo); $this->assertSame('Rossi Mario', $g->sorvegliante->nome_completo);

View File

@@ -0,0 +1,393 @@
<?php
namespace Tests\Feature;
use App\Console\Commands\PuliziaRichieste;
use App\Livewire\Assegnazioni\Assegna;
use App\Livewire\Assegnazioni\Rientra;
use App\Livewire\Home;
use App\Livewire\Richieste\ModuloRichiesta;
use App\Mail\ModuloS13Mail;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use App\Models\Proclamatore;
use App\Models\Richiesta;
use App\Models\Setting;
use App\Models\Territorio;
use App\Models\User;
use App\Notifications\RichiestaRicevuta;
use App\Services\RichiesteServizio;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
/**
* Dal documento delle attività per la versione 2 (21 ottobre 2026):
* - il modulo pubblico di richiesta (nuovo territorio o riconsegna), che
* amministratore e assistenti confermano dalla Home portandolo sui moduli
* di assegnazione e di rientro già esistenti;
* - il segno "cartaceo" sull'assegnazione e l'elenco dei cartacei fuori;
* - il modulo S-13 per email agli amministratori l'ultima domenica del mese.
*/
class RichiesteTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
$this->seed(RolesAndPermissionsSeeder::class);
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
AnnoTeocratico::create(['label' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-08-31']);
$this->travelTo(Carbon::parse('2026-10-21 10:00'));
$this->admin = $this->utente('Admin', 'admin@example.test', 'amministratore');
RateLimiter::clear('richiesta-pubblica:' . sha1('127.0.0.1'));
}
private function utente(string $nome, string $email, string $ruolo): User
{
$utente = User::create(['name' => $nome, 'email' => $email, 'password' => 'password-di-prova']);
$utente->assignRole($ruolo);
return $utente;
}
private function proclamatore(string $cognome, string $nome): Proclamatore
{
return Proclamatore::create(['nome' => $nome, 'cognome' => $cognome, 'attivo' => true]);
}
private function territorio(string $numero): Territorio
{
return Territorio::create(['numero' => $numero, 'attivo' => true]);
}
private function assegna(Territorio $t, Proclamatore $p, string $dal, bool $cartaceo = false): Assegnazione
{
return Assegnazione::create([
'territorio_id' => $t->id,
'proclamatore_id' => $p->id,
'anno_teocratico_id' => AnnoTeocratico::perData(Carbon::parse($dal))->id,
'assigned_at' => $dal,
'cartaceo' => $cartaceo,
]);
}
/** Il modulo compilato da una persona: aperto, qualche secondo, inviato. */
private function compila(array $campi): \Livewire\Features\SupportTesting\Testable
{
$modulo = Livewire::test(ModuloRichiesta::class);
foreach ($campi as $campo => $valore) {
$modulo->set($campo, $valore);
}
$this->travel(ModuloRichiesta::SECONDI_MINIMI + 2)->seconds();
return $modulo->call('invia');
}
private function postaFunzionante(): void
{
Setting::instance()->update(['mail_host' => 'smtp.esempio.it']);
Setting::flushCache();
}
// ─── Il modulo pubblico ─────────────────────────────────────
public function test_il_modulo_si_apre_senza_login_e_non_mostra_dati(): void
{
$this->proclamatore('Rossi', 'Mario');
auth()->logout();
$this->get(route('richiesta'))
->assertOk()
->assertSee('Invia la richiesta')
->assertDontSee('Rossi');
}
public function test_una_richiesta_si_salva_cifrata_e_avvisa_chi_la_gestisce(): void
{
Notification::fake();
$this->postaFunzionante();
$assistente = $this->utente('Assistente', 'assistente@example.test', 'assistente');
$operatore = $this->utente('Operatore', 'operatore@example.test', 'operatore');
$nonConfermato = $this->utente('Altro', 'altro@example.test', 'amministratore');
$nonConfermato->forceFill(['email_verified_at' => null])->save();
auth()->logout();
$this->compila(['tipo' => Richiesta::NUOVO, 'nome' => 'Mario Rossi', 'note' => 'Vicino a casa'])
->assertHasNoErrors()
->assertSet('inviata', true);
$r = Richiesta::sole();
$this->assertSame('Mario Rossi', $r->nome);
$this->assertSame(Richiesta::APERTA, $r->stato);
$this->assertNull($r->numero_territorio);
$grezzo = DB::table('richieste')->value('nome');
$this->assertStringNotContainsString('Rossi', $grezzo, 'Il nome nel database è cifrato');
$this->assertStringNotContainsString('Rossi', Activity::all()->toJson(), 'Il nome non va nel registro');
Notification::assertSentTo([$this->admin, $assistente], RichiestaRicevuta::class);
Notification::assertNotSentTo([$operatore, $nonConfermato], RichiestaRicevuta::class);
}
public function test_senza_posta_la_richiesta_resta_comunque(): void
{
Notification::fake();
auth()->logout();
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Mario Rossi', 'numero' => '12'])->assertHasNoErrors();
$this->assertSame('12', Richiesta::sole()->numero_territorio);
Notification::assertNothingSent();
}
public function test_la_riconsegna_vuole_il_numero_del_territorio(): void
{
$this->compila(['tipo' => Richiesta::RICONSEGNA, 'nome' => 'Mario Rossi'])->assertHasErrors('numero');
$this->compila(['tipo' => Richiesta::NUOVO, 'nome' => 'Ma'])->assertHasErrors('nome');
$this->assertSame(0, Richiesta::count());
}
public function test_lo_spam_non_si_salva_e_non_se_ne_accorge(): void
{
// Il campo trappola riempito.
$this->compila(['nome' => 'Spam Bot', 'sito' => 'https://spam.example'])->assertSet('inviata', true);
// Inviato subito dopo l'apertura.
Livewire::test(ModuloRichiesta::class)->set('nome', 'Spam Bot')->call('invia')->assertSet('inviata', true);
$this->assertSame(0, Richiesta::count());
}
public function test_al_massimo_cinque_richieste_all_ora_dallo_stesso_indirizzo(): void
{
for ($i = 1; $i <= ModuloRichiesta::MASSIMO_ORA; $i++) {
$this->compila(['nome' => "Mario Rossi {$i}"])->assertHasNoErrors();
}
$this->compila(['nome' => 'Mario Rossi 6'])->assertHasErrors('nome');
$this->assertSame(ModuloRichiesta::MASSIMO_ORA, Richiesta::count());
}
// ─── I nomi simili ──────────────────────────────────────────
public function test_i_nomi_simili_trovano_il_proclamatore_anche_scritto_male(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$rossini = $this->proclamatore('Rossini', 'Maria');
$this->proclamatore('Bianchi', 'Luca');
$servizio = app(RichiesteServizio::class);
$this->assertSame([$rossi->id, $rossini->id], $servizio->nomiSimili('mario rossi')->pluck('id')->all());
$this->assertSame($rossi->id, $servizio->nomiSimili('Rosi Mario')->first()?->id, 'Una lettera in meno');
$this->assertSame($rossi->id, $servizio->nomiSimili('Màrio Rossi')->first()?->id, 'Accenti');
$this->assertTrue($servizio->nomiSimili('Giuseppe Verdi')->isEmpty());
}
// ─── La Home ────────────────────────────────────────────────
public function test_la_home_mostra_le_richieste_con_i_nomi_simili_e_i_collegamenti(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$nuova = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rosi', null, 'Anche cartaceo');
$t = $this->territorio('12');
$a = $this->assegna($t, $this->proclamatore('Verdi', 'Anna'), '2026-10-01');
$riconsegna = app(RichiesteServizio::class)->registra(Richiesta::RICONSEGNA, 'Anna Verdi', '12', null);
app(RichiesteServizio::class)->registra(Richiesta::RICONSEGNA, 'Luca Neri', '99', null);
Livewire::actingAs($this->admin)->test(Home::class)
->assertSee('Richieste (3)')
->assertSee('Mario Rosi')
->assertSee('Anche cartaceo')
->assertSee(route('assegnazioni.assegna', ['proclamatore' => $rossi->id, 'richiesta' => $nuova->id, 'dalla_home' => 1]))
->assertSee('Nessuno di questi: apri il modulo')
->assertSee(route('assegnazioni.rientra', ['assegnazione' => $a->id, 'richiesta' => $riconsegna->id]))
->assertSee('Il territorio N° 99 non risulta assegnato.');
}
public function test_solo_chi_ha_il_permesso_vede_e_scarta_le_richieste(): void
{
$r = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rossi', null, null);
$operatore = $this->utente('Operatore', 'operatore@example.test', 'operatore');
Livewire::actingAs($operatore)->test(Home::class)
->assertDontSee('Mario Rossi')
->call('scartaRichiesta', $r->id)
->assertForbidden();
$this->assertTrue($r->fresh()->isAperta());
$assistente = $this->utente('Assistente', 'assistente@example.test', 'assistente');
Livewire::actingAs($assistente)->test(Home::class)
->assertSee('Mario Rossi')
->call('scartaRichiesta', $r->id)
->assertDontSee('Mario Rossi');
$r->refresh();
$this->assertSame(Richiesta::SCARTATA, $r->stato);
$this->assertSame($assistente->id, $r->gestita_da);
}
// ─── Dalla richiesta ai moduli esistenti ────────────────────
public function test_dalla_richiesta_il_modulo_di_assegnazione_e_precompilato_e_la_chiude(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$t = $this->territorio('7');
$r = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rossi', null, null);
Livewire::withQueryParams(['proclamatore' => $rossi->id, 'richiesta' => $r->id, 'dalla_home' => 1])
->actingAs($this->admin)
->test(Assegna::class)
->assertSet('proclamatore_id', $rossi->id)
->assertSet('richiestaId', $r->id)
->assertSee('Mario Rossi')
->set('territorio_id', $t->id)
->set('cartaceo', true)
->call('save')
->assertHasNoErrors();
$a = Assegnazione::sole();
$this->assertTrue($a->cartaceo);
$r->refresh();
$this->assertSame(Richiesta::EVASA, $r->stato);
$this->assertSame($a->id, $r->assegnazione_id);
$this->assertSame($this->admin->id, $r->gestita_da);
}
public function test_senza_permesso_o_con_una_richiesta_chiusa_il_modulo_la_ignora(): void
{
$r = app(RichiesteServizio::class)->registra(Richiesta::NUOVO, 'Mario Rossi', null, null);
$operatore = $this->utente('Operatore', 'operatore@example.test', 'operatore');
Livewire::withQueryParams(['richiesta' => $r->id])->actingAs($operatore)
->test(Assegna::class)->assertSet('richiestaId', null);
$r->scarta($this->admin);
Livewire::withQueryParams(['richiesta' => $r->id])->actingAs($this->admin)
->test(Assegna::class)->assertSet('richiestaId', null);
}
public function test_dalla_riconsegna_il_rientro_la_chiude(): void
{
$t = $this->territorio('12');
$a = $this->assegna($t, $this->proclamatore('Verdi', 'Anna'), '2026-10-01');
$r = app(RichiesteServizio::class)->registra(Richiesta::RICONSEGNA, 'Anna Verdi', '12', null);
Livewire::withQueryParams(['richiesta' => $r->id])->actingAs($this->admin)
->test(Rientra::class, ['assegnazione' => $a])
->assertSee('Riconsegna annunciata da')
->call('save')
->assertHasNoErrors();
$this->assertNotNull($a->fresh()->returned_at);
$r->refresh();
$this->assertSame(Richiesta::EVASA, $r->stato);
$this->assertSame($a->id, $r->assegnazione_id);
}
public function test_le_richieste_gestite_si_cancellano_dopo_novanta_giorni(): void
{
$servizio = app(RichiesteServizio::class);
$vecchia = $servizio->registra(Richiesta::NUOVO, 'Vecchia Gestita', null, null);
$vecchia->scarta($this->admin);
$aperta = $servizio->registra(Richiesta::NUOVO, 'Vecchia Aperta', null, null);
$this->travel(PuliziaRichieste::GIORNI - 1)->days();
$recente = $servizio->registra(Richiesta::NUOVO, 'Recente', null, null);
$recente->scarta($this->admin);
$this->travel(2)->days();
$this->artisan('richieste:pulizia')->assertSuccessful();
$this->assertModelMissing($vecchia);
$this->assertModelExists($aperta);
$this->assertModelExists($recente);
}
// ─── Cartacei ───────────────────────────────────────────────
public function test_l_elenco_dei_cartacei_mostra_quelli_fuori_e_a_chi(): void
{
$rossi = $this->proclamatore('Rossi', 'Mario');
$this->assegna($this->territorio('21'), $rossi, '2026-10-01', cartaceo: true);
$this->assegna($this->territorio('22'), $rossi, '2026-10-01');
$rientrato = $this->assegna($this->territorio('23'), $rossi, '2026-09-01', cartaceo: true);
$rientrato->update(['returned_at' => '2026-10-01']);
$this->actingAs($this->admin)->get(route('assegnazioni.cartacei'))
->assertOk()
->assertSee('N° 21')
->assertSee('Rossi Mario')
->assertDontSee('N° 22')
->assertDontSee('N° 23');
// Nell'elenco degli assegnati, il segno solo dove serve.
$html = $this->actingAs($this->admin)->get(route('assegnazioni.assegna'))->getContent();
$this->assertStringContainsString('assegnazioni.elenco-assegnati', $html);
Livewire::withoutLazyLoading()->actingAs($this->admin)->test(\App\Livewire\Assegnazioni\ElencoAssegnati::class)
->assertSeeHtml('<span class="tm-cartaceo">cartaceo</span>');
$sorvegliante = $this->utente('Sorvegliante', 'sorvegliante@example.test', 'sorvegliante');
$this->actingAs($sorvegliante)->get(route('assegnazioni.cartacei'))->assertForbidden();
}
// ─── Modulo S-13 per email ──────────────────────────────────
public function test_l_s13_va_agli_amministratori_con_l_email_confermata(): void
{
Mail::fake();
$this->postaFunzionante();
$assistente = $this->utente('Assistente', 'assistente@example.test', 'assistente');
$nonConfermato = $this->utente('Altro', 'altro@example.test', 'amministratore');
$nonConfermato->forceFill(['email_verified_at' => null])->save();
$this->artisan('registro:invia-s13')->assertSuccessful();
Mail::assertSent(ModuloS13Mail::class, fn (ModuloS13Mail $m) => $m->hasTo('admin@example.test')
&& collect($m->attachments())->contains(fn ($a) => $a->as === 'S-13-registro-assegnazioni-2026-2027.pdf' && $a->mime === 'application/pdf'));
Mail::assertSent(ModuloS13Mail::class, 1);
Mail::assertNotSent(ModuloS13Mail::class, fn (ModuloS13Mail $m) => $m->hasTo($assistente->email) || $m->hasTo($nonConfermato->email));
$this->assertSame(1, Activity::where('description', 's13_inviato')->sole()->properties['destinatari']);
}
public function test_senza_posta_l_s13_non_parte(): void
{
Mail::fake();
$this->artisan('registro:invia-s13')->assertSuccessful();
Mail::assertNothingSent();
}
public function test_l_s13_parte_solo_l_ultima_domenica_del_mese(): void
{
$evento = collect(app(Schedule::class)->events())
->first(fn ($e) => str_contains((string) $e->command, 'registro:invia-s13'));
$this->assertNotNull($evento);
$parte = function (string $quando) use ($evento) {
$this->travelTo(Carbon::parse($quando, config('app.timezone')));
return $evento->isDue($this->app) && $evento->filtersPass($this->app);
};
$this->assertTrue($parte('2026-10-25 08:00'), 'Ultima domenica di ottobre');
$this->assertFalse($parte('2026-10-18 08:00'), 'Penultima domenica');
$this->assertFalse($parte('2026-10-24 08:00'), 'Sabato');
$this->assertTrue($parte('2026-11-29 08:00'), 'Ultima domenica di novembre');
$this->assertTrue($parte('2027-01-31 08:00'), 'Domenica 31');
}
}