Files
termanager2/app/Livewire/Registro.php

471 lines
18 KiB
PHP

<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Assegnazione;
use App\Models\AnnoTeocratico;
use App\Models\Campagna;
use App\Models\Proclamatore;
use App\Models\Territorio;
use App\Models\Zona;
use App\Models\Tipologia;
use Barryvdh\DomPDF\Facade\Pdf;
class Registro extends Component
{
use WithPagination;
/** Blocchi-territorio per foglio, come nel modulo cartaceo S-13. */
private const S13_RIGHE_PER_PAGINA = 21;
/** Gruppi "Assegnato a" disponibili su ogni riga del modulo S-13. */
private const S13_ASSEGNAZIONI_PER_RIGA = 4;
public string $search = '';
public string $filtroAnno = '';
public string $filtroZona = '';
public string $filtroTipologia = '';
public string $filtroStato = ''; // aperte, chiuse
public string $sortField = 'territorio_numero';
public string $sortDirection = 'asc';
// ─── Modal create/edit ──────────────────────────────────────
public bool $showModal = false;
public ?int $editingId = null;
public string $form_territorio_id = '';
public string $form_proclamatore_id = '';
public string $form_anno_id = '';
public string $form_assigned_at = '';
public string $form_returned_at = '';
public bool $form_counted_in_campaign = false;
public string $form_campaign_id = '';
public string $form_note = '';
// ─── Delete confirm ─────────────────────────────────────────
public bool $showDeleteConfirm = false;
public ?int $deleteId = null;
// ─── Modal modulo S-13 ──────────────────────────────────────
public bool $showS13Modal = false;
public string $s13Anno = '';
protected $queryString = [
'search' => ['except' => ''],
'filtroAnno' => ['except' => ''],
'filtroZona' => ['except' => ''],
'filtroTipologia' => ['except' => ''],
'filtroStato' => ['except' => ''],
];
public function updatingSearch()
{
$this->resetPage();
}
/**
* Columns that may be sorted on.
*
* $sortField reaches orderBy() directly, and it is a public Livewire
* property, so the client controls it. Laravel's grammar prevents SQL
* injection, but an unknown column still raises "Column not found" and
* returns a 500 — trivially triggerable from the browser console.
*/
private const SORTABLE_FIELDS = [
'territorio_numero',
'assigned_at',
'returned_at',
];
public function sortBy(string $field)
{
if (! in_array($field, self::SORTABLE_FIELDS, true)) {
return;
}
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'desc';
}
}
// ─── Admin CRUD ─────────────────────────────────────────────
public function openCreate(): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$this->editingId = null;
$this->form_territorio_id = '';
$this->form_proclamatore_id = '';
$this->form_anno_id = '';
$this->form_assigned_at = now()->format('Y-m-d');
$this->form_returned_at = '';
$this->form_counted_in_campaign = false;
$this->form_campaign_id = '';
$this->form_note = '';
$this->resetValidation();
$this->showModal = true;
}
public function openEdit(int $id): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$a = Assegnazione::findOrFail($id);
$this->editingId = $id;
$this->form_territorio_id = (string) $a->territorio_id;
$this->form_proclamatore_id = (string) $a->proclamatore_id;
$this->form_anno_id = (string) $a->anno_teocratico_id;
$this->form_assigned_at = $a->assigned_at?->format('Y-m-d') ?? '';
$this->form_returned_at = $a->returned_at?->format('Y-m-d') ?? '';
$this->form_counted_in_campaign = (bool) $a->counted_in_campaign;
$this->form_campaign_id = (string) ($a->campaign_id ?? '');
$this->form_note = $a->note ?? '';
$this->resetValidation();
$this->showModal = true;
}
public function save(): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$this->validate([
'form_territorio_id' => 'required|exists:territori,id',
'form_proclamatore_id' => 'required|exists:proclamatori,id',
'form_anno_id' => 'required|exists:anni_teocratici,id',
'form_assigned_at' => 'required|date',
'form_returned_at' => 'nullable|date|after_or_equal:form_assigned_at',
'form_campaign_id' => 'nullable|exists:campagne,id',
]);
$data = [
'territorio_id' => $this->form_territorio_id,
'proclamatore_id' => $this->form_proclamatore_id,
'anno_teocratico_id' => $this->form_anno_id,
'assigned_at' => $this->form_assigned_at,
'returned_at' => $this->form_returned_at ?: null,
'counted_in_campaign' => $this->form_counted_in_campaign,
'campaign_id' => $this->form_campaign_id ?: null,
'note' => $this->form_note ?: null,
];
if ($this->editingId) {
Assegnazione::findOrFail($this->editingId)->update($data);
} else {
$data['created_by'] = auth()->id();
Assegnazione::create($data);
}
$this->showModal = false;
}
public function askDelete(int $id): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$this->deleteId = $id;
$this->showDeleteConfirm = true;
}
public function deleteConfirmed(): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
if ($this->deleteId) {
Assegnazione::findOrFail($this->deleteId)->delete();
}
$this->deleteId = null;
$this->showDeleteConfirm = false;
}
// ─── Modulo S-13 ────────────────────────────────────────────
/**
* Chiede quale anno di servizio stampare. L'anno del filtro è solo la
* proposta iniziale: il modulo finisce in archivio e viene firmato, quindi
* la scelta va confermata invece di essere ereditata da uno stato della
* pagina che l'utente può non avere presente.
*/
public function openS13(): void
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$this->s13Anno = $this->filtroAnno !== ''
? $this->filtroAnno
: (string) AnnoTeocratico::corrente()->id;
$this->resetValidation();
$this->showS13Modal = true;
}
/**
* Modulo ufficiale S-13-I ("Registrazione delle assegnazioni del
* territorio") compilato con le assegnazioni dell'anno di servizio scelto
* nella modale.
*
* Solo amministratore, come il resto del CRUD di questa pagina: il modulo
* espone in un unico file l'anagrafica completa di chi ha avuto quale
* territorio, dati personali che il ruolo assegnatario non deve poter
* esportare in blocco.
*/
public function downloadS13()
{
abort_if(!auth()->user()->can('settings.manage'), 403);
$this->validate(
['s13Anno' => 'required|exists:anni_teocratici,id'],
[
's13Anno.required' => "Seleziona l'anno di servizio da stampare.",
's13Anno.exists' => "L'anno di servizio selezionato non esiste.",
]
);
$anno = AnnoTeocratico::findOrFail($this->s13Anno);
$this->showS13Modal = false;
$pdf = Pdf::loadView('pdf.registro-s13', [
'annoLabel' => $anno->label,
'pagine' => $this->impaginaS13($this->righeS13($anno)),
])->setPaper('a4', 'portrait');
return response()->streamDownload(
fn () => print($pdf->output()),
'S-13-registro-assegnazioni-' . $anno->label . '.pdf'
);
}
/**
* Impagina le righe in fogli da 21 blocchi, completando l'ultimo con
* blocchi vuoti: il foglio stampato resta identico al modulo cartaceo e le
* righe libere restano disponibili per la compilazione a mano.
*
* Senza righe da stampare si restituisce comunque un foglio vuoto, così il
* modulo scaricato è un S-13 in bianco anziché un PDF senza pagine.
*
* @param \Illuminate\Support\Collection<int, array> $righe
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, array>>
*/
private function impaginaS13(\Illuminate\Support\Collection $righe): \Illuminate\Support\Collection
{
$vuota = [
'numero' => '',
'riporto' => ['nome' => '', 'data' => ''],
'assegnazioni' => $this->completaCelleS13([]),
];
$pagine = $righe->isEmpty()
? collect([collect()])
: $righe->chunk(self::S13_RIGHE_PER_PAGINA)->map->values()->values();
return $pagine->map(fn ($pagina) => collect(
array_pad($pagina->all(), self::S13_RIGHE_PER_PAGINA, $vuota)
));
}
/**
* Righe del modulo: un blocco per ogni territorio attivo con almeno
* un'assegnazione (chiusa o in corso) nell'anno indicato, in ordine di
* numero crescente.
*
* @return \Illuminate\Support\Collection<int, array>
*/
private function righeS13(AnnoTeocratico $anno): \Illuminate\Support\Collection
{
// territorio() è definita withTrashed(), quindi la condizione sui
// cestinati va scritta a mano: senza, i territori eliminati
// rientrerebbero nel modulo.
$assegnazioni = Assegnazione::with(['proclamatore', 'territorio'])
->where('anno_teocratico_id', $anno->id)
->whereHas('territorio', fn ($q) => $q
->where('attivo', true)
->whereNull('territori.deleted_at'))
->orderBy('assigned_at')
->orderBy('id')
->get()
->groupBy('territorio_id');
$rientriPrecedenti = $this->ultimiRientriPrimaDi(
$assegnazioni->keys()->all(),
$anno
);
return $assegnazioni
->map(function ($gruppo, $territorioId) use ($rientriPrecedenti) {
$gruppo = $gruppo->values();
// I quattro gruppi "Assegnato a" ospitano le ultime quattro
// assegnazioni, in ordine cronologico da sinistra.
$ultime = $gruppo->slice(-self::S13_ASSEGNAZIONI_PER_RIGA)->values();
$celle = $ultime->map(fn (Assegnazione $a) => [
'nome' => $a->proclamatore?->nome_completo ?? '',
'assegnato_il' => $a->assigned_at?->format('d/m/Y') ?? '',
'restituito_il' => $a->returned_at?->format('d/m/Y') ?? '',
])->all();
return [
'numero' => $gruppo->first()->territorio?->numero ?? '',
'riporto' => $this->riportoS13($gruppo, $rientriPrecedenti->get($territorioId)),
'assegnazioni' => $this->completaCelleS13($celle),
];
})
->sort(fn (array $a, array $b) => strnatcasecmp((string) $a['numero'], (string) $b['numero']))
->values();
}
/**
* Contenuto della colonna "Restituito l'ultima volta il", che funziona da
* quinta casella della riga.
*
* Quando le assegnazioni dell'anno superano le quattro che i gruppi
* "Assegnato a" possono ospitare, la colonna accoglie quella
* immediatamente precedente alle quattro mostrate — nome e data di
* rientro — così la riga copre cinque passaggi invece di quattro. In quel
* caso il riporto dell'anno precedente non ha più senso: la continuità
* con il passato ce l'ha già l'assegnazione in colonna.
*
* Con quattro o meno assegnazioni nell'anno la colonna torna al suo uso
* da modulo: l'ultima data di rientro dell'anno di servizio precedente,
* senza nome.
*
* @param \Illuminate\Support\Collection<int, Assegnazione> $gruppo
* @return array{nome: string, data: string}
*/
private function riportoS13(\Illuminate\Support\Collection $gruppo, ?string $rientroAnnoPrecedente): array
{
$eccedenti = $gruppo->count() - self::S13_ASSEGNAZIONI_PER_RIGA;
if ($eccedenti > 0) {
$precedente = $gruppo->get($eccedenti - 1);
return [
'nome' => $precedente->proclamatore?->nome_completo ?? '',
'data' => $precedente->returned_at?->format('d/m/Y') ?? '',
];
}
return [
'nome' => '',
'data' => $rientroAnnoPrecedente
? \Carbon\Carbon::parse($rientroAnnoPrecedente)->format('d/m/Y')
: '',
];
}
/**
* Ultima data di rientro di ogni territorio *precedente* all'inizio
* dell'anno di servizio: è il dato che la colonna "Restituito l'ultima
* volta il" del modulo chiede di riportare quando si apre un foglio nuovo.
*
* @param array<int, int> $territorioIds
* @return \Illuminate\Support\Collection<int, string>
*/
private function ultimiRientriPrimaDi(array $territorioIds, AnnoTeocratico $anno): \Illuminate\Support\Collection
{
if ($territorioIds === []) {
return collect();
}
return Assegnazione::whereIn('territorio_id', $territorioIds)
->whereNotNull('returned_at')
->whereDate('returned_at', '<', $anno->start_date)
->groupBy('territorio_id')
->selectRaw('territorio_id, MAX(returned_at) as ultimo_rientro')
->pluck('ultimo_rientro', 'territorio_id');
}
/**
* Porta le celle a quattro riempiendo con voci vuote: le colonne
* inutilizzate restano stampate e a disposizione della compilazione a mano.
*
* @param array<int, array> $celle
* @return array<int, array>
*/
private function completaCelleS13(array $celle): array
{
$vuota = ['nome' => '', 'assegnato_il' => '', 'restituito_il' => ''];
return array_pad($celle, self::S13_ASSEGNAZIONI_PER_RIGA, $vuota);
}
public function mount(): void
{
if ($this->filtroAnno === '') {
$annoCorrente = AnnoTeocratico::corrente();
$this->filtroAnno = (string) $annoCorrente->id;
}
}
public function render()
{
$query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
if ($this->filtroAnno) {
$query->where('anno_teocratico_id', $this->filtroAnno);
}
if ($this->filtroStato === 'aperte') {
$query->aperte();
} elseif ($this->filtroStato === 'chiuse') {
$query->chiuse();
}
if ($this->filtroZona) {
$query->whereHas('territorio', fn($q) => $q->where('zona_id', $this->filtroZona));
}
if ($this->filtroTipologia) {
$query->whereHas('territorio', fn($q) => $q->where('tipologia_id', $this->filtroTipologia));
}
// Re-check here too: sortBy() guards the action, but the property can
// also be set directly in a Livewire update payload.
$sortField = in_array($this->sortField, self::SORTABLE_FIELDS, true)
? $this->sortField
: 'territorio_numero';
$dir = $this->sortDirection === 'asc' ? 'ASC' : 'DESC';
if ($sortField === 'territorio_numero') {
$query->orderByRaw(
"CAST((SELECT numero FROM territori WHERE territori.id = assegnazioni.territorio_id) AS UNSIGNED) $dir"
);
} else {
$query->orderBy($sortField, $dir);
}
// In-memory search for encrypted proclamatore fields / territorio numero
if ($this->search !== '') {
$all = $query->get();
$filtered = $all->filter(function ($a) {
$haystack = mb_strtolower(
($a->territorio?->numero ?? '') . ' ' .
($a->proclamatore?->nome ?? '') . ' ' .
($a->proclamatore?->cognome ?? '')
);
return str_contains($haystack, mb_strtolower($this->search));
});
$page = $this->getPage();
$perPage = 25;
$items = $filtered->slice(($page - 1) * $perPage, $perPage)->values();
$assegnazioni = new \Illuminate\Pagination\LengthAwarePaginator(
$items, $filtered->count(), $perPage, $page,
['path' => request()->url()]
);
} else {
$assegnazioni = $query->paginate(25);
}
return view('livewire.registro', [
'assegnazioni' => $assegnazioni,
'anni' => AnnoTeocratico::orderByDesc('start_date')->get(),
'zone' => Zona::attive()->orderBy('nome')->get(),
'tipologie' => Tipologia::orderBy('nome')->get(),
'territori' => Territorio::attivi()->orderBy('numero')->get(),
'proclamatori' => Proclamatore::attivi()->orderBy('cognome')->orderBy('nome')->get(),
'campagne' => Campagna::orderByDesc('created_at')->get(),
]);
}
}