['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 $righe * @return \Illuminate\Support\Collection> */ private function impaginaS13(\Illuminate\Support\Collection $righe): \Illuminate\Support\Collection { $vuota = [ 'numero' => '', 'rientro_precedente' => '', '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 */ 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) { // Se un territorio è girato più di quattro volte nell'anno il // modulo non ha spazio per tutte: si stampano le ultime // quattro, mantenendo l'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(); $rientro = $rientriPrecedenti->get($territorioId); return [ 'numero' => $gruppo->first()->territorio?->numero ?? '', 'rientro_precedente' => $rientro ? \Carbon\Carbon::parse($rientro)->format('d/m/Y') : '', 'assegnazioni' => $this->completaCelleS13($celle), ]; }) ->sort(fn (array $a, array $b) => strnatcasecmp((string) $a['numero'], (string) $b['numero'])) ->values(); } /** * 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 $territorioIds * @return \Illuminate\Support\Collection */ 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 $celle * @return 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(), ]); } }