++ invio del link dopo l'assegnazione; nuovo territorio dal designer

- Dopo l'assegnazione si apre una finestra con i canali (copia link
  sempre; WhatsApp ed email solo se il proclamatore ha numero /
  indirizzo, l'email solo con la posta configurata) e la domanda "Hai
  inviato il link?". Si va avanti solo dopo la risposta.
- L'email la manda l'app con notifyNow, mai in coda: l'indirizzo è
  cifrato nel database e non deve finire in chiaro nei job.
- Procedura interrotta o "non ancora": assegnazioni.link_da_inviare
  resta vero e "Link ancora da inviare" compare nell'elenco Assegna e
  nella scheda territorio, con "Invia link" per riprendere.
- Colonne link_da_inviare e link_canale (anche nell'XML); log con
  canale e proclamatore_id, senza dati personali.
- "Nuovo territorio" apre il designer delle piantine già in disegno, con
  la prima piantina pronta a creare il territorio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-19 14:36:44 +02:00
parent f8911afe64
commit f8539b58bd
14 changed files with 619 additions and 120 deletions

View File

@@ -106,11 +106,9 @@ Richieste dell'utente, con le sue scelte:
nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre nell'export/import XML, cancellati dall'anonimizzazione. Il numero apre
WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si WhatsApp (`<x-whatsapp>`, `Proclamatore::whatsappUrl()`: senza prefisso si
considera italiano). considera italiano).
- **Dopo l'assegnazione** si resta sulla pagina Assegna: in cima un riquadro - **Dopo l'assegnazione** — *rivisto il 19 ottobre 2026, vedi "Invio del link
per ogni assegnazione appena fatta con il link del PDF, "Invia su WhatsApp" dopo l'assegnazione" qui sotto*. Il riquadro in sessione
(messaggio già scritto con il link) e la scelta "Sì, inviato / Non ancora", (`Assegna::DA_INVIARE`) non esiste più.
che imposta `link_sent`. Resta finché non si sceglie
(`Assegna::DA_INVIARE` in sessione).
- **Conferma email obbligatoria** per tutti gli utenti, anche quelli già - **Conferma email obbligatoria** per tutti gli utenti, anche quelli già
esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`). esistenti (la migrazione `2026_10_13_000010` azzera `email_verified_at`).
L'admin crea l'utente con la password come prima; parte l'email di conferma L'admin crea l'utente con la password come prima; parte l'email di conferma
@@ -129,6 +127,41 @@ email di prova e di conferma arrivate, link aperto senza login, account
sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione sbloccato; riquadro dopo l'assegnazione con WhatsApp e scelta. Configurazione
e dati di prova rimossi dopo. e dati di prova rimossi dopo.
### Invio del link dopo l'assegnazione, nuovo territorio dal designer (19 ottobre 2026)
Richieste dell'utente:
- **Dopo l'assegnazione non si va avanti** finché non si è deciso del link: si
apre una finestra che blocca la pagina (`Assegna::apriInvio`) con i canali
- *Copia link*: sempre;
- *WhatsApp*: solo se il proclamatore ha un numero utilizzabile;
- *Email*: solo se ha un indirizzo **e** la posta funziona
(`ConfigurazionePosta::funzionante`). La manda l'app con
`Notification::route('mail', …)->notifyNow(new LinkTerritorio)`: **mai in
coda**, perché il job salverebbe in chiaro su Redis/`failed_jobs`
l'indirizzo che nel database è cifrato.
Poi "Hai inviato il link?": *Sì* vuole un canale usato, *No* è sempre
possibile. Solo dopo la risposta si ricarica la pagina.
- **Se la procedura non si conclude** (scheda chiusa) o si risponde *No*, resta
`assegnazioni.link_da_inviare = true` e compare "Link ancora da inviare"
nell'elenco della pagina Assegna (con "Invia link", che riapre la finestra)
e nella scheda del territorio (che rimanda a `assegnazioni/assegna?invia=<id>`).
L'avviso sparisce quando il link risulta inviato, anche dal pulsante
"Non inviato / Link inviato" dell'elenco.
- Colonne nuove (migrazione `2026_10_19_000010`): `link_da_inviare` (le
assegnazioni già esistenti restano false: niente avvisi a posteriori su
link di cui non si sa niente) e `link_canale` (`copia`/`whatsapp`/`email`,
`Assegnazione::CANALI_LINK`). Entrambe nell'XML.
- Nel log `link_inviato` / `link_non_inviato` con canale e `proclamatore_id`:
mai nome, numero o email. Email e telefono restano solo, cifrati, nel
proclamatore.
- **"Nuovo territorio"** (elenco territori) apre il designer delle piantine
con `?nuovo=1` (`PiantineEditor::$creaTerritorio`): la mappa parte già in
disegno e la prima piantina disegnata si apre con "nuovo territorio" scelto.
L'editor è solo dell'amministratore: chi ha `territori.manage` ma non
`settings.manage` resta sul form `territori.create`, che esiste ancora.
### Revisione profonda: blocchi 2 e 3 chiusi ### Revisione profonda: blocchi 2 e 3 chiusi
| Problema | Correzione | | Problema | Correzione |

View File

@@ -4,16 +4,35 @@ namespace App\Livewire\Assegnazioni;
use Livewire\Component; use Livewire\Component;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
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\Notifications\LinkTerritorio;
use App\Services\ConfigurazionePosta;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
class Assegna extends Component class Assegna extends Component
{ {
/** Chiave di sessione delle assegnazioni appena fatte, in attesa della scelta sul link. */ // ─── Invio del link ─────────────────────────────────────────
public const DA_INVIARE = 'assegnazioni_da_inviare'; //
// Dopo l'assegnazione non si torna subito alla pagina: si sceglie il
// canale (copia link sempre; WhatsApp ed email solo se il proclamatore ha
// numero / indirizzo), si dice se il link è partito, e solo allora si va
// avanti. Se la procedura si interrompe, l'assegnazione resta con
// link_da_inviare e lo si segnala dove compare.
/** L'assegnazione di cui si sta mandando il link. */
#[Locked]
public ?int $invioId = null;
/** Il canale usato in questo invio (Assegnazione::CANALI_LINK). */
#[Locked]
public ?string $canaleUsato = null;
public string $erroreInvio = '';
public ?int $territorio_id = null; public ?int $territorio_id = null;
public ?int $proclamatore_id = null; public ?int $proclamatore_id = null;
@@ -29,6 +48,12 @@ class Assegna extends Component
$this->preselectedTerritorioId = $territorioId; $this->preselectedTerritorioId = $territorioId;
$this->territorio_id = $territorioId; $this->territorio_id = $territorioId;
$this->assigned_at = now()->format('Y-m-d'); $this->assigned_at = now()->format('Y-m-d');
// ?invia=<id>: riprende l'invio di un link rimasto in sospeso (dalla
// scheda del territorio).
if ($invia = request()->integer('invia')) {
$this->apriInvio($invia);
}
} }
protected function rules(): array protected function rules(): array
@@ -88,6 +113,8 @@ class Assegna extends Component
'anno_teocratico_id' => $annoTeocratico->id, 'anno_teocratico_id' => $annoTeocratico->id,
'assigned_at' => $assignedDate, 'assigned_at' => $assignedDate,
'created_by' => auth()->id(), 'created_by' => auth()->id(),
// C'è un link da mandare solo se il territorio ha il PDF.
'link_da_inviare' => (bool) $territorio->pdf_path,
]); ]);
}); });
@@ -110,34 +137,123 @@ class Assegna extends Component
]) ])
->log('assigned'); ->log('assigned');
// Si resta qui: in cima compare il riquadro con il link da mandare al // Non si va avanti: si apre l'invio del link (vedi in cima).
// proclamatore (WhatsApp) e la scelta "inviato / non ancora". La lista $this->apriInvio($assegnazione->id);
// sta in sessione, così il riquadro resta finché non si sceglie, anche
// ricaricando la pagina o assegnando altri territori nel frattempo.
$daInviare = session(self::DA_INVIARE, []);
$daInviare[] = $assegnazione->id;
session([self::DA_INVIARE => array_values(array_unique($daInviare))]);
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}.");
return $this->redirect(route('assegnazioni.assegna'), navigate: true);
} }
/** La scelta nel riquadro: il link è stato mandato, oppure non ancora. */ /** Apre l'invio del link: subito dopo l'assegnazione, o da un'assegnazione rimasta in sospeso. */
public function esitoInvio(int $assegnazioneId, bool $inviato): void public function apriInvio(int $assegnazioneId): void
{ {
$this->authorize('territori.assign'); $this->authorize('territori.assign');
$assegnazione = Assegnazione::find($assegnazioneId); $assegnazione = Assegnazione::aperte()->find($assegnazioneId);
$assegnazione?->forceFill(['link_sent' => $inviato])->saveQuietly(); if (! $assegnazione) {
return;
}
session([self::DA_INVIARE => array_values(array_diff(session(self::DA_INVIARE, []), [$assegnazioneId]))]); $this->invioId = $assegnazione->id;
$this->canaleUsato = null;
$this->erroreInvio = '';
}
/** Copia link o WhatsApp: l'invio vero lo fa chi usa l'app, qui si annota il canale. */
public function canale(string $canale): void
{
$assegnazione = $this->assegnazioneInInvio();
if (! $assegnazione || ! in_array($canale, ['copia', 'whatsapp'], true)) {
return;
}
if ($canale === 'whatsapp' && ! $assegnazione->proclamatore?->whatsappUrl()) {
return;
}
$this->canaleUsato = $canale;
$this->erroreInvio = '';
}
/** Email: la manda l'applicazione, subito (vedi LinkTerritorio). */
public function inviaEmail(): void
{
$assegnazione = $this->assegnazioneInInvio();
$email = trim((string) $assegnazione?->proclamatore?->email);
$link = $assegnazione?->shortPdfUrl();
if (! $assegnazione || $email === '' || ! $link || ! app(ConfigurazionePosta::class)->funzionante()) {
return;
}
try {
Notification::route('mail', $email)->notifyNow(new LinkTerritorio($assegnazione, $link));
} catch (\Throwable $e) {
report($e);
$this->erroreInvio = "L'email non è partita: controlla il server di posta, oppure usa un altro canale.";
return;
}
$this->canaleUsato = 'email';
$this->erroreInvio = '';
}
/**
* La risposta a "Hai inviato il link?": la si registra e solo adesso si
* va avanti. "Sì" vuole un canale usato; "No" lascia il link da inviare.
*/
public function concludiInvio(bool $inviato)
{
$assegnazione = $this->assegnazioneInInvio();
if (! $assegnazione) {
$this->invioId = null;
return null;
}
if ($inviato && $this->canaleUsato === null) {
$this->erroreInvio = 'Scegli prima come mandare il link.';
return null;
}
$assegnazione->registraInvioLink($inviato, $this->canaleUsato);
// Nel log il canale e l'id del proclamatore, mai nome, numero o email.
activity()->causedBy(auth()->user())
->performedOn($assegnazione)
->withProperties([
'territorio' => $assegnazione->territorio?->numero,
'proclamatore_id' => $assegnazione->proclamatore_id,
'canale' => $inviato ? $this->canaleUsato : null,
])
->log($inviato ? 'link_inviato' : 'link_non_inviato');
$numero = $assegnazione->territorio?->numero;
$nome = $assegnazione->proclamatore?->nome_completo;
session()->flash('success', $inviato
? "Territorio {$numero} assegnato a {$nome}: link inviato."
: "Territorio {$numero} assegnato a {$nome}. Il link è ancora da inviare: lo trovi segnalato nell'elenco.");
$this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
return $this->redirect(route('assegnazioni.assegna'), navigate: true);
}
private function assegnazioneInInvio(): ?Assegnazione
{
if (! $this->invioId) {
return null;
}
$this->authorize('territori.assign');
return Assegnazione::with(['territorio', 'proclamatore'])->find($this->invioId);
} }
public function toggleLinkSent(int $assegnazioneId): void public function toggleLinkSent(int $assegnazioneId): void
{ {
$this->authorize('territori.assign'); $this->authorize('territori.assign');
$assegnazione = Assegnazione::findOrFail($assegnazioneId); $assegnazione = Assegnazione::findOrFail($assegnazioneId);
$assegnazione->forceFill(['link_sent' => ! $assegnazione->link_sent])->saveQuietly(); $assegnazione->registraInvioLink(! $assegnazione->link_sent, $assegnazione->link_canale);
} }
#[Computed] #[Computed]
@@ -198,19 +314,16 @@ class Assegna extends Component
// Il link scade alla soglia rientro (giorni_per_smarrito). // Il link scade alla soglia rientro (giorni_per_smarrito).
$sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120)); $sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
// I riquadri in evidenza: solo le assegnazioni ancora aperte (una // L'invio in corso; se nel frattempo l'assegnazione è rientrata o
// rientrata o cancellata nel frattempo non ha più un link da mandare). // sparita non c'è più niente da mandare.
$idDaInviare = session(self::DA_INVIARE, []); $invio = $this->invioId ? Assegnazione::aperte()->with(['territorio', 'proclamatore'])->find($this->invioId) : null;
$daInviare = $idDaInviare === [] ? collect() : Assegnazione::aperte() if ($this->invioId && ! $invio) {
->whereIn('id', $idDaInviare) $this->reset(['invioId', 'canaleUsato', 'erroreInvio']);
->with(['territorio', 'proclamatore'])
->get();
if ($daInviare->count() !== count($idDaInviare)) {
session([self::DA_INVIARE => $daInviare->pluck('id')->all()]);
} }
return view('livewire.assegnazioni.assegna', [ return view('livewire.assegnazioni.assegna', [
'daInviare' => $daInviare, 'invio' => $invio,
'postaAttiva' => $invio ? app(ConfigurazionePosta::class)->funzionante() : false,
'territoriDisponibili' => $territoriDisponibili, 'territoriDisponibili' => $territoriDisponibili,
'proclamatoriAttivi' => $proclamatoriAttivi, 'proclamatoriAttivi' => $proclamatoriAttivi,
'assegnazioniAperte' => $assegnazioniAperte, 'assegnazioniAperte' => $assegnazioniAperte,

View File

@@ -66,10 +66,20 @@ class PiantineEditor extends Component
public string $nuovaTipologia = ''; public string $nuovaTipologia = '';
/**
* Arrivati da "Nuovo territorio" (?nuovo=1): la mappa parte già in
* disegno, e la prima piantina disegnata si apre con "nuovo territorio"
* scelto, pronta da compilare e creare.
*/
#[Locked]
public bool $creaTerritorio = false;
public function mount(?int $mappa = null): void public function mount(?int $mappa = null): void
{ {
if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) { if ($mappa !== null && TerritorioMappa::whereKey($mappa)->exists()) {
$this->carica(TerritorioMappa::with('territorio')->find($mappa)); $this->carica(TerritorioMappa::with('territorio')->find($mappa));
} elseif (request()->boolean('nuovo')) {
$this->creaTerritorio = true;
} }
} }
@@ -154,6 +164,11 @@ class PiantineEditor extends Component
$mappa = TerritorioMappa::create(['geojson' => $geojson]); $mappa = TerritorioMappa::create(['geojson' => $geojson]);
$this->carica($mappa); $this->carica($mappa);
activity()->causedBy(auth()->user())->log('piantina_creata'); activity()->causedBy(auth()->user())->log('piantina_creata');
if ($this->creaTerritorio) {
$this->creaTerritorio = false;
$this->territorioScelto = 'nuovo';
}
} }
$this->salvato($mappa, seleziona: true); $this->salvato($mappa, seleziona: true);

View File

@@ -768,6 +768,8 @@ class XmlExchange extends Component
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1, 'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null, 'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1, 'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
'link_da_inviare' => ((int) ($assegnazioneNode->link_da_inviare ?? 0)) === 1,
'link_canale' => in_array((string) ($assegnazioneNode->link_canale ?? ''), array_keys(Assegnazione::CANALI_LINK), true) ? (string) $assegnazioneNode->link_canale : null,
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null, 'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
'note' => (string) ($assegnazioneNode->note ?? ''), 'note' => (string) ($assegnazioneNode->note ?? ''),
'created_by' => $createdBy, 'created_by' => $createdBy,
@@ -832,7 +834,7 @@ class XmlExchange extends Component
'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']), 'territori' => $this->righeEsportate(Territorio::query()->withTrashed()->orderBy('id'), ['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at']),
'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']), 'anni_teocratici' => $this->righeEsportate(AnnoTeocratico::query()->orderBy('id'), ['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at']),
'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']), 'campagne' => $this->righeEsportate(Campagna::query()->orderBy('id'), ['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at']),
'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at']), 'assegnazioni' => $this->righeEsportate(Assegnazione::query()->orderBy('id'), ['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'link_da_inviare', 'link_canale', 'pdf_access_code', 'created_at', 'updated_at']),
]; ];
} }
@@ -1321,6 +1323,8 @@ class XmlExchange extends Component
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? '')); $node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0))); $node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0))); $node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
$node->addChild('link_da_inviare', (string) ((int) ($assegnazione['link_da_inviare'] ?? 0)));
$this->addXmlText($node, 'link_canale', (string) ($assegnazione['link_canale'] ?? ''));
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? '')); $this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? '')); $this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
$this->addTimestampNodes($node, $assegnazione); $this->addTimestampNodes($node, $assegnazione);

View File

@@ -20,6 +20,8 @@ class Assegnazione extends Model
'campaign_id', 'campaign_id',
'pdf_access_code', 'pdf_access_code',
'link_sent', 'link_sent',
'link_da_inviare',
'link_canale',
'note', 'note',
'created_by', 'created_by',
'returned_by', 'returned_by',
@@ -32,6 +34,7 @@ class Assegnazione extends Model
'returned_at' => 'date', 'returned_at' => 'date',
'counted_in_campaign' => 'boolean', 'counted_in_campaign' => 'boolean',
'link_sent' => 'boolean', 'link_sent' => 'boolean',
'link_da_inviare' => 'boolean',
]; ];
} }
@@ -211,9 +214,43 @@ class Assegnazione extends Model
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]); return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]);
} }
public function markLinkSent(): void public function markLinkSent(?string $canale = null): void
{ {
$this->forceFill(['link_sent' => true])->saveQuietly(); $this->registraInvioLink(true, $canale);
}
/** Canali con cui si manda il link al proclamatore. */
public const CANALI_LINK = ['copia' => 'Copia link', 'whatsapp' => 'WhatsApp', 'email' => 'Email'];
/**
* Esito dell'invio del link: inviato (con il canale usato) oppure no. Un
* link non inviato resta "da inviare" e lo si segnala sull'assegnazione,
* a meno che non ci sia proprio un link da mandare (territorio senza PDF).
*/
public function registraInvioLink(bool $inviato, ?string $canale = null): void
{
$this->forceFill([
'link_sent' => $inviato,
'link_da_inviare' => ! $inviato && $this->shortPdfUrl() !== null,
'link_canale' => $inviato ? $canale : $this->link_canale,
])->saveQuietly();
}
/** Il link va ancora mandato: l'avviso da mostrare sull'assegnazione. */
public function getLinkInSospesoAttribute(): bool
{
return $this->link_da_inviare && ! $this->link_sent && $this->is_aperta;
}
/** Il messaggio con il link, uguale per WhatsApp ed email. */
public function messaggioLink(): ?string
{
$link = $this->shortPdfUrl();
if ($link === null) {
return null;
}
return "Ciao {$this->proclamatore?->nome}, ti è stato assegnato il territorio N° {$this->territorio?->numero}. Ecco la piantina: {$link}";
} }
// ─── Scopes ───────────────────────────────────────────────── // ─── Scopes ─────────────────────────────────────────────────

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Notifications;
use App\Models\Assegnazione;
use App\Models\Setting;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Il link della piantina, mandato per email al proclamatore a cui è stato
* assegnato il territorio.
*
* Si invia con notifyNow(), mai in coda: in coda il job salverebbe in chiaro
* (Redis, failed_jobs) l'indirizzo che nel database è cifrato.
*/
class LinkTerritorio extends Notification
{
public function __construct(
private readonly Assegnazione $assegnazione,
private readonly string $link,
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$numero = $this->assegnazione->territorio?->numero;
$congregazione = trim((string) Setting::getValue('congregazione_nome', ''));
// Stessa regola di AssignmentPdfController::linkScaduto().
$scadenza = $this->assegnazione->assigned_at->copy()
->addDays(max(1, (int) Setting::getValue('giorni_per_smarrito', 120)));
return (new MailMessage)
->subject("Territorio N° {$numero} — piantina")
->greeting('Ciao ' . $this->assegnazione->proclamatore?->nome . ',')
->line("ti è stato assegnato il territorio N° {$numero}.")
->action('Apri la piantina', $this->link)
->line("Il link funziona finché il territorio è assegnato a te, al massimo fino al {$scadenza->format('d/m/Y')}.")
->salutation($congregazione !== '' ? $congregazione : 'TerManager2');
}
}

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Invio del link dopo l'assegnazione.
*
* - link_da_inviare: l'assegnazione aspetta che il link venga mandato al
* proclamatore. Nasce vero con l'assegnazione fatta dalla pagina Assegna e
* torna falso quando il link risulta inviato. Le assegnazioni già esistenti
* restano false: non si segnalano a posteriori centinaia di link di cui non
* si sa niente.
* - link_canale: come è stato mandato l'ultima volta (copia, whatsapp, email).
* Non è un dato personale: email e telefono restano solo, cifrati, nel
* proclamatore.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->boolean('link_da_inviare')->default(false)->after('link_sent');
$table->string('link_canale', 16)->nullable()->after('link_da_inviare');
});
}
public function down(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropColumn(['link_da_inviare', 'link_canale']);
});
}
};

View File

@@ -598,6 +598,14 @@ html { scroll-behavior: smooth; }
background: var(--tm-accento-tenue); background: var(--tm-accento-tenue);
box-shadow: var(--tm-ombra-2); box-shadow: var(--tm-ombra-2);
} }
/* Lo stesso riquadro come finestra sopra la pagina (invio del link dopo
l'assegnazione): sfondo pieno, perché sotto c'è il velo scuro. */
.tm-da-inviare-finestra {
width: 100%;
max-width: 34rem;
margin-bottom: 0;
background: var(--tm-superficie, #fff);
}
.tm-da-inviare-titolo { .tm-da-inviare-titolo {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;

View File

@@ -312,6 +312,9 @@ function initPiantine() {
} else { } else {
mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato mappa.setView([41.9, 12.5], 6); // Italia: nessun dato ancora caricato
} }
// Arrivati da "Nuovo territorio": si comincia subito a disegnare.
if (componente().creaTerritorio) avviaDisegno(false);
} }
mappa.setView([41.9, 12.5], 6); mappa.setView([41.9, 12.5], 6);

View File

@@ -8,54 +8,92 @@
<p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p> <p class="text-sm text-gray-500 mt-1">Questa funzione consente un'assegnazione arbitraria, indipendente dalle tre schede della Home.</p>
</div> </div>
{{-- Appena assegnato: il link da mandare al proclamatore, in evidenza, e {{-- Invio del link (Assegna::apriInvio): blocca la pagina finché non si
la scelta "inviato / non ancora". Resta finché non si sceglie risponde "inviato / non ancora". Se si chiude la scheda prima,
(Assegna::DA_INVIARE in sessione). --}} l'assegnazione resta segnalata con "link ancora da inviare". --}}
@foreach($daInviare as $nuova) @if($invio)
@php @php
$link = $nuova->shortPdfUrl(); $link = $invio->shortPdfUrl();
$messaggio = $link $messaggio = $invio->messaggioLink();
? "Ciao {$nuova->proclamatore->nome}, ti è stato assegnato il territorio N° {$nuova->territorio->numero}. Ecco la piantina: {$link}" $whatsapp = $messaggio ? $invio->proclamatore?->whatsappUrl($messaggio) : null;
: null; $email = trim((string) $invio->proclamatore?->email);
$whatsapp = $messaggio ? $nuova->proclamatore->whatsappUrl($messaggio) : null;
@endphp @endphp
<section class="tm-da-inviare" wire:key="da-inviare-{{ $nuova->id }}" aria-labelledby="da-inviare-{{ $nuova->id }}"> <div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-gray-900/50 p-4 sm:items-center"
<h2 id="da-inviare-{{ $nuova->id }}" class="tm-da-inviare-titolo"> role="dialog" aria-modal="true" aria-labelledby="invio-titolo" wire:key="invio-{{ $invio->id }}">
Territorio N° {{ $nuova->territorio->numero }} assegnato a {{ $nuova->proclamatore->nome_completo }} <section class="tm-da-inviare tm-da-inviare-finestra">
<h2 id="invio-titolo" class="tm-da-inviare-titolo">
Territorio N° {{ $invio->territorio?->numero }} assegnato a {{ $invio->proclamatore?->nome_completo }}
</h2> </h2>
@if($link) @if($link)
<div x-data="{ copiato: false }" class="tm-da-inviare-link"> <p class="text-sm text-gray-700 mb-2">Come vuoi mandare il link della piantina?</p>
<code x-ref="link" x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
<x-btn variante="secondary" tono="tenue" dimensione="sm" <div class="tm-da-inviare-link">
x-on:click="navigator.clipboard.writeText($refs.link.textContent).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) })"> <code id="invio-link" x-data x-on:click="window.getSelection().selectAllChildren($el)">{{ $link }}</code>
</div>
<div class="tm-da-inviare-azioni" x-data="{ copiato: false }">
{{-- Copia link: sempre disponibile. Il link si legge dal <code> qui
sopra: dentro <x-btn> le direttive Blade (@js) non si compilano. --}}
<x-btn :variante="$canaleUsato === 'copia' ? 'primary' : 'secondary'"
x-on:click="navigator.clipboard?.writeText(document.getElementById('invio-link').textContent.trim()).then(() => { copiato = true; setTimeout(() => copiato = false, 2000) }).catch(() => {}); $wire.canale('copia')">
<span x-show="! copiato">Copia link</span> <span x-show="! copiato">Copia link</span>
<span x-show="copiato" x-cloak>Copiato</span> <span x-show="copiato" x-cloak>Copiato</span>
</x-btn> </x-btn>
</div>
<div class="tm-da-inviare-azioni"> {{-- WhatsApp: solo con un numero di telefono nel profilo --}}
@if($whatsapp) @if($whatsapp)
<a href="{{ $whatsapp }}" target="_blank" rel="noopener" class="tm-whatsapp tm-whatsapp-grande"> <a href="{{ $whatsapp }}" target="_blank" rel="noopener" wire:click="canale('whatsapp')"
class="tm-whatsapp tm-whatsapp-grande">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
<span>Invia su WhatsApp</span> <span>WhatsApp</span>
<span class="tm-whatsapp-numero">{{ $nuova->proclamatore->telefono }}</span>
</a> </a>
@else @else
<p class="text-sm text-gray-600"> <x-btn variante="secondary" :disabled="true" title="Nel profilo del proclamatore non c'è un numero di telefono">WhatsApp</x-btn>
{{ $nuova->proclamatore->nome_completo }} non ha un numero di telefono: copia il link e mandalo come preferisci. @endif
@can('settings.manage')
<a href="{{ route('proclamatori.edit', $nuova->proclamatore) }}" class="text-indigo-700 underline">Aggiungi il numero</a> {{-- Email: solo con un indirizzo nel profilo e la posta configurata --}}
@endcan @if($email !== '' && $postaAttiva)
</p> <x-btn :variante="$canaleUsato === 'email' ? 'primary' : 'secondary'"
wire:click="inviaEmail" wire:loading.attr="disabled" wire:target="inviaEmail">
<span wire:loading.remove wire:target="inviaEmail">Email</span>
<span wire:loading wire:target="inviaEmail">Invio…</span>
</x-btn>
@else
<x-btn variante="secondary" :disabled="true"
:title="$email === '' ? 'Nel profilo del proclamatore non c\'è un indirizzo email' : 'Il server di posta non è configurato'">Email</x-btn>
@endif @endif
</div> </div>
<ul class="mt-1 text-xs text-gray-500 space-y-0.5">
@unless($whatsapp)
<li>WhatsApp non disponibile: nel profilo non c'è un numero di telefono.</li>
@endunless
@if($email === '')
<li>Email non disponibile: nel profilo non c'è un indirizzo email.</li>
@elseif(! $postaAttiva)
<li>Email non disponibile: il server di posta non è configurato.</li>
@endif
@if((! $whatsapp || $email === '') && auth()->user()->can('settings.manage') && $invio->proclamatore)
<li><a href="{{ route('proclamatori.edit', $invio->proclamatore) }}" class="text-indigo-700 underline">Aggiungi i contatti al proclamatore</a></li>
@endif
</ul>
@if($canaleUsato === 'email')
<p class="mt-2 text-sm text-green-700">Email inviata.</p>
@elseif($canaleUsato)
<p class="mt-2 text-sm text-gray-700">Canale scelto: {{ \App\Models\Assegnazione::CANALI_LINK[$canaleUsato] }}.</p>
@endif
@if($erroreInvio)
<p class="mt-2 text-sm text-red-600" role="alert">{{ $erroreInvio }}</p>
@endif
<div class="tm-da-inviare-scelta"> <div class="tm-da-inviare-scelta">
<p>Il link è stato inviato?</p> <p>Hai inviato il link?</p>
<div> <div>
<x-btn variante="primary" wire:click="esitoInvio({{ $nuova->id }}, true)">Sì, inviato</x-btn> <x-btn variante="primary" wire:click="concludiInvio(true)" :disabled="$canaleUsato === null"
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Non ancora</x-btn> :title="$canaleUsato === null ? 'Scegli prima come mandare il link' : null">Sì, inviato</x-btn>
<x-btn variante="secondary" wire:click="concludiInvio(false)">No, non ancora</x-btn>
</div> </div>
</div> </div>
@else @else
@@ -64,12 +102,13 @@
</p> </p>
<div class="tm-da-inviare-scelta"> <div class="tm-da-inviare-scelta">
<div> <div>
<x-btn variante="secondary" wire:click="esitoInvio({{ $nuova->id }}, false)">Ho capito</x-btn> <x-btn variante="secondary" wire:click="concludiInvio(false)">Ho capito</x-btn>
</div> </div>
</div> </div>
@endif @endif
</section> </section>
@endforeach </div>
@endif
<div x-data="{ formOpen: true }"> <div x-data="{ formOpen: true }">
@@ -203,6 +242,15 @@
</div> </div>
</div> </div>
{{-- Procedura di invio interrotta, o risposto "non ancora" --}}
@if($a->link_in_sospeso)
<div class="mt-3 flex flex-wrap items-center gap-2 rounded-md px-3 py-1.5 text-xs font-semibold bg-amber-50 border border-amber-200 text-amber-800" role="status">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
Link ancora da inviare
<x-btn variante="secondary" tono="tenue" dimensione="sm" wire:click="apriInvio({{ $a->id }})">Invia link</x-btn>
</div>
@endif
{{-- Avviso link scaduto (solo per non loggati) --}} {{-- Avviso link scaduto (solo per non loggati) --}}
@if($linkScaduto) @if($linkScaduto)
<div class="mt-3 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold bg-red-50 border border-red-200 text-red-700"> <div class="mt-3 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold bg-red-50 border border-red-200 text-red-700">

View File

@@ -6,7 +6,11 @@
</div> </div>
@can('territori.manage') @can('territori.manage')
<div class="mt-4 sm:mt-0 flex gap-2"> <div class="mt-4 sm:mt-0 flex gap-2">
<x-btn variante="primary" :href="route('territori.create')"> {{-- Un territorio nuovo nasce dal designer delle piantine: si
disegna il confine e lo si crea da lì (PiantineEditor, ?nuovo=1).
L'editor è dell'amministratore: chi ha solo territori.manage
resta sul form. --}}
<x-btn variante="primary" :href="auth()->user()->can('settings.manage') ? route('piantine.editor', ['nuovo' => 1]) : route('territori.create')">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Nuovo territorio Nuovo territorio
</x-btn> </x-btn>

View File

@@ -36,6 +36,16 @@
</div> </div>
</div> </div>
{{-- L'invio del link dopo l'assegnazione non si è concluso --}}
@if($territorio->assegnazioneCorrente?->link_in_sospeso)
<div class="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800" role="status">
<span class="font-semibold">Il link della piantina è ancora da inviare a {{ $territorio->assegnazioneCorrente->proclamatore?->nome_completo }}.</span>
@can('territori.assign')
<x-btn variante="secondary" dimensione="sm" :href="route('assegnazioni.assegna', ['invia' => $territorio->assegnazioneCorrente->id])">Invia link</x-btn>
@endcan
</div>
@endif
{{-- Info card --}} {{-- Info card --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">

View File

@@ -11,9 +11,11 @@ use App\Models\Proclamatore;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\User; use App\Models\User;
use App\Notifications\LinkTerritorio;
use Database\Seeders\RolesAndPermissionsSeeder; use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
@@ -154,57 +156,176 @@ class ContattiProclamatoriTest extends TestCase
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => "territori-pdf/t{$numero}.pdf"]); return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => "territori-pdf/t{$numero}.pdf"]);
} }
public function test_dopo_l_assegnazione_compare_il_link_da_mandare_su_whatsapp(): void public function test_dopo_l_assegnazione_non_si_va_avanti_ma_si_sceglie_il_canale(): void
{ {
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'telefono' => '333 1234567']); $mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'telefono' => '333 1234567']);
$territorio = $this->territorioConPdf();
$this->assegna($mario, $territorio) $pagina = $this->assegna($mario, $this->territorioConPdf())
->assertHasNoErrors() ->assertHasNoErrors()
->assertRedirect(route('assegnazioni.assegna')); ->assertNoRedirect();
$assegnazione = Assegnazione::sole(); $assegnazione = Assegnazione::sole();
$this->assertSame([$assegnazione->id], session(Assegna::DA_INVIARE)); $this->assertTrue($assegnazione->link_da_inviare);
$this->assertFalse($assegnazione->link_sent);
$link = $assegnazione->fresh()->shortPdfUrl(); $link = $assegnazione->shortPdfUrl();
Livewire::actingAs($this->admin)->test(Assegna::class) $pagina->assertSet('invioId', $assegnazione->id)
->assertSee('Territorio N° 7 assegnato a Rossi Mario') ->assertSee('Territorio N° 7 assegnato a Rossi Mario')
->assertSee('Come vuoi mandare il link della piantina?')
->assertSee($link) ->assertSee($link)
->assertSee('https://wa.me/393331234567?text=', false) ->assertSee('https://wa.me/393331234567?text=', false)
->assertSee(rawurlencode($link), false) ->assertSee(rawurlencode($link), false)
->assertSee('Il link è stato inviato?'); ->assertSee('Hai inviato il link?');
} }
public function test_la_scelta_registra_l_invio_e_toglie_il_riquadro(): void public function test_si_dice_inviato_solo_dopo_aver_scelto_un_canale(): void
{ {
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'telefono' => '3331234567']); $mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true, 'telefono' => '3331234567']);
$anna = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => true]);
$this->assegna($mario, $this->territorioConPdf('7'));
$this->assegna($anna, $this->territorioConPdf('8'));
[$prima, $seconda] = Assegnazione::orderBy('id')->get()->all(); $this->assegna($mario, $this->territorioConPdf())
->call('concludiInvio', true)
->assertNoRedirect()
->assertSet('erroreInvio', 'Scegli prima come mandare il link.')
->call('canale', 'whatsapp')
->call('concludiInvio', true)
->assertRedirect(route('assegnazioni.assegna'));
Livewire::actingAs($this->admin)->test(Assegna::class) $assegnazione = Assegnazione::sole();
->assertSee('Territorio N° 7 assegnato') $this->assertTrue($assegnazione->link_sent);
->assertSee('Territorio N° 8 assegnato') $this->assertFalse($assegnazione->link_da_inviare);
->call('esitoInvio', $prima->id, true) $this->assertSame('whatsapp', $assegnazione->link_canale);
->assertDontSee('Territorio N° 7 assegnato')
->assertSee('Territorio N° 8 assegnato')
->call('esitoInvio', $seconda->id, false)
->assertDontSee('Territorio N° 8 assegnato');
$this->assertTrue((bool) $prima->fresh()->link_sent); $log = \Spatie\Activitylog\Models\Activity::where('description', 'link_inviato')->sole();
$this->assertFalse((bool) $seconda->fresh()->link_sent); $this->assertSame('whatsapp', $log->properties['canale']);
$this->assertSame([], session(Assegna::DA_INVIARE)); $this->assertSame($mario->id, $log->properties['proclamatore_id']);
$this->assertStringNotContainsString('333', json_encode($log->properties));
} }
public function test_senza_telefono_il_riquadro_lo_dice(): void public function test_senza_contatti_resta_solo_copia_link(): void
{ {
$anna = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => true]); $anna = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => true]);
$this->assegna($anna, $this->territorioConPdf());
Livewire::actingAs($this->admin)->test(Assegna::class) $this->assegna($anna, $this->territorioConPdf())
->assertSee('non ha un numero di telefono') ->assertSee('Copia link')
->assertDontSee('wa.me', false); ->assertSee('nel profilo non c\'è un numero di telefono')
->assertSee('nel profilo non c\'è un indirizzo email')
->assertDontSee('wa.me', false)
// WhatsApp senza numero non si può segnare come canale usato.
->call('canale', 'whatsapp')
->assertSet('canaleUsato', null)
->call('canale', 'copia')
->assertSet('canaleUsato', 'copia');
} }
public function test_l_email_parte_solo_con_indirizzo_e_posta_configurata(): void
{
Notification::fake();
$anna = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => true, 'email' => 'anna@example.test']);
// Posta non configurata: il canale c'è ma è spento, e non parte nulla.
$pagina = $this->assegna($anna, $this->territorioConPdf())
->assertSee('il server di posta non è configurato')
->call('inviaEmail')
->assertSet('canaleUsato', null);
Notification::assertNothingSent();
// Posta configurata dall'app: l'email parte, subito e non in coda.
Setting::instance()->update(['mail_host' => 'smtp.example.test', 'mail_port' => 587, 'mail_from_address' => 'noreply@example.test']);
$pagina->call('inviaEmail')->assertSet('canaleUsato', 'email');
Notification::assertSentOnDemand(LinkTerritorio::class, function ($notifica, $canali, $destinatario) {
return $destinatario->routes['mail'] === 'anna@example.test';
});
}
public function test_se_non_si_conclude_l_invio_l_assegnazione_lo_segnala(): void
{
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
$this->assegna($mario, $this->territorioConPdf());
$assegnazione = Assegnazione::sole();
// La pagina viene chiusa senza rispondere: l'invio è perso, l'avviso resta.
Livewire::actingAs($this->admin)->test(Assegna::class)
->assertSet('invioId', null)
->assertSee('Link ancora da inviare')
->call('apriInvio', $assegnazione->id)
->assertSee('Hai inviato il link?')
->call('concludiInvio', false)
->assertRedirect(route('assegnazioni.assegna'));
// "No, non ancora": la procedura è finita, ma il link resta da inviare.
$this->assertTrue($assegnazione->fresh()->link_in_sospeso);
$this->get(route('territori.show', $assegnazione->territorio_id))
->assertSee('Il link della piantina è ancora da inviare');
// Segnato come inviato dall'elenco: l'avviso sparisce.
Livewire::actingAs($this->admin)->test(Assegna::class)
->call('toggleLinkSent', $assegnazione->id)
->assertDontSee('Link ancora da inviare');
$this->assertFalse($assegnazione->fresh()->link_in_sospeso);
}
public function test_dalla_scheda_territorio_si_riprende_l_invio(): void
{
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
$this->assegna($mario, $this->territorioConPdf());
$assegnazione = Assegnazione::sole();
$this->actingAs($this->admin)
->get(route('assegnazioni.assegna', ['invia' => $assegnazione->id]))
->assertOk()
->assertSee('Hai inviato il link?');
}
public function test_senza_pdf_non_c_e_un_link_da_inviare(): void
{
$anna = Proclamatore::create(['nome' => 'Anna', 'cognome' => 'Verdi', 'attivo' => true]);
$territorio = Territorio::create(['numero' => '9', 'attivo' => true]);
$this->assegna($anna, $territorio)
->assertSee('non ha un PDF')
->call('concludiInvio', false);
$this->assertFalse(Assegnazione::sole()->link_in_sospeso);
}
public function test_le_assegnazioni_gia_esistenti_non_vengono_segnalate(): void
{
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
$territorio = $this->territorioConPdf();
$vecchia = Assegnazione::create([
'territorio_id' => $territorio->id,
'proclamatore_id' => $mario->id,
'anno_teocratico_id' => AnnoTeocratico::sole()->id,
'assigned_at' => now(),
'created_by' => $this->admin->id,
]);
$this->assertFalse($vecchia->fresh()->link_in_sospeso);
}
public function test_l_xml_porta_con_se_lo_stato_dell_invio(): void
{
$this->actingAs($this->admin);
$mario = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
$this->assegna($mario, $this->territorioConPdf())->call('canale', 'copia')->call('concludiInvio', true);
$xml = (new XmlExchange())->currentXmlBackup();
$this->assertArrayNotHasKey('error', (new XmlExchange())->importXmlFromContent($xml));
$assegnazione = Assegnazione::sole();
$this->assertTrue($assegnazione->link_sent);
$this->assertFalse($assegnazione->link_da_inviare);
$this->assertSame('copia', $assegnazione->link_canale);
}
// ─── Nuovo territorio dal designer ──────────────────────────
public function test_nuovo_territorio_porta_al_designer_delle_piantine(): void
{
$this->actingAs($this->admin)
->get(route('territori.index'))
->assertSee(route('piantine.editor', ['nuovo' => 1]), false);
}
} }

View File

@@ -62,6 +62,29 @@ class PiantineEditorTest extends TestCase
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk(); $this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
} }
public function test_da_nuovo_territorio_la_prima_piantina_disegnata_crea_un_territorio(): void
{
Livewire::withQueryParams(['nuovo' => 1])
->actingAs($this->admin())
->test(PiantineEditor::class)
->assertSet('creaTerritorio', true)
->dispatch('geometria-modificata', geometry: self::GEOMETRIA, nuova: true)
->assertSet('territorioScelto', 'nuovo')
->assertSet('creaTerritorio', false)
// Solo la prima: la seconda piantina disegnata è una bozza qualunque.
->dispatch('geometria-modificata', geometry: self::ALTRA_GEOMETRIA, nuova: true)
->assertSet('territorioScelto', '');
}
public function test_senza_nuovo_l_editor_non_prepara_un_territorio(): void
{
Livewire::actingAs($this->admin())
->test(PiantineEditor::class)
->assertSet('creaTerritorio', false)
->dispatch('geometria-modificata', geometry: self::GEOMETRIA, nuova: true)
->assertSet('territorioScelto', '');
}
public function test_la_pagina_si_apre_su_una_piantina_esistente(): void public function test_la_pagina_si_apre_su_una_piantina_esistente(): void
{ {
$mappa = TerritorioMappa::create(['nome' => 'Zona porto', 'geojson' => json_encode(self::GEOMETRIA)]); $mappa = TerritorioMappa::create(['nome' => 'Zona porto', 'geojson' => json_encode(self::GEOMETRIA)]);