Primo commit

This commit is contained in:
Francesco Picone
2026-04-05 19:26:04 +02:00
commit 701f479b7f
135 changed files with 21445 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Spatie\Activitylog\Models\Activity;
use App\Models\Setting;
class AuditCleanup extends Command
{
protected $signature = 'audit:cleanup';
protected $description = 'Delete audit log entries older than the configured retention period';
public function handle(): int
{
$retentionDays = Setting::instance()->audit_retention_days ?? 365;
$cutoff = now()->subDays($retentionDays);
$deleted = Activity::where('created_at', '<', $cutoff)->delete();
$this->info("Deleted {$deleted} audit entries older than {$retentionDays} days.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Models\Setting;
class SetupRequired
{
public function handle(Request $request, Closure $next): Response
{
if ($request->is('setup*') || $request->is('login') || $request->is('logout')) {
return $next($request);
}
if (!Setting::isSetupComplete()) {
return redirect()->route('setup.index');
}
return $next($request);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Livewire\Assegnazioni;
use Livewire\Component;
use App\Models\Territorio;
use App\Models\Proclamatore;
use App\Models\Assegnazione;
use App\Models\AnnoTeocratico;
class Assegna extends Component
{
public ?int $territorio_id = null;
public ?int $proclamatore_id = null;
public string $assigned_at = '';
// Optional pre-selection from parent context
public ?int $preselectedTerritorioId = null;
public function mount(?int $territorioId = null)
{
$this->preselectedTerritorioId = $territorioId;
$this->territorio_id = $territorioId;
$this->assigned_at = now()->format('Y-m-d');
}
protected function rules(): array
{
return [
'territorio_id' => 'required|exists:territori,id',
'proclamatore_id' => 'required|exists:proclamatori,id',
'assigned_at' => 'required|date|before_or_equal:today',
];
}
public function save()
{
$this->validate();
$territorio = Territorio::findOrFail($this->territorio_id);
// Check territory is available (not currently assigned)
if ($territorio->assegnazioneCorrente) {
session()->flash('error', "Il territorio {$territorio->numero} è già assegnato a {$territorio->assegnazioneCorrente->proclamatore->nome_completo}.");
return;
}
// Check territory is active
if (!$territorio->attivo) {
session()->flash('error', "Il territorio {$territorio->numero} è inattivo.");
return;
}
$proclamatore = Proclamatore::findOrFail($this->proclamatore_id);
if (!$proclamatore->attivo) {
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} è inattivo.");
return;
}
$assignedDate = \Carbon\Carbon::parse($this->assigned_at);
$annoTeocratico = AnnoTeocratico::perData($assignedDate);
$assegnazione = Assegnazione::create([
'territorio_id' => $this->territorio_id,
'proclamatore_id' => $this->proclamatore_id,
'anno_teocratico_id' => $annoTeocratico->id,
'assigned_at' => $assignedDate,
'created_by' => auth()->id(),
]);
activity()->causedBy(auth()->user())
->performedOn($assegnazione)
->withProperties([
'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo,
])
->log('assigned');
session()->flash('success', "Territorio {$territorio->numero} assegnato a {$proclamatore->nome_completo}.");
return $this->redirect(route('territori.show', $territorio), navigate: true);
}
public function render()
{
$territoriDisponibili = Territorio::where('attivo', true)
->whereDoesntHave('assegnazioni', fn($q) => $q->aperte())
->orderBy('numero')
->get();
$proclamatoriAttivi = Proclamatore::attivi()
->get()
->sortBy(fn($p) => mb_strtolower($p->cognome . ' ' . $p->nome));
return view('livewire.assegnazioni.assegna', [
'territoriDisponibili' => $territoriDisponibili,
'proclamatoriAttivi' => $proclamatoriAttivi,
]);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Livewire\Assegnazioni;
use Livewire\Component;
use App\Models\Assegnazione;
use App\Models\Campagna;
class Rientra extends Component
{
public Assegnazione $assegnazione;
public string $returned_at = '';
public bool $showCampaignPrompt = false;
public ?int $campagna_id = null;
public bool $counted_in_campaign = false;
public function mount(Assegnazione $assegnazione)
{
$this->assegnazione = $assegnazione->load(['territorio', 'proclamatore']);
if ($assegnazione->returned_at) {
abort(404, 'Assegnazione già rientrata.');
}
$this->returned_at = now()->format('Y-m-d');
$this->checkCampaign();
}
public function updatedReturnedAt()
{
$this->checkCampaign();
}
protected function checkCampaign()
{
if (!$this->returned_at) {
$this->showCampaignPrompt = false;
return;
}
$returnDate = \Carbon\Carbon::parse($this->returned_at);
$campagna = $this->assegnazione->campagnaApplicabile($returnDate);
if ($campagna) {
$this->showCampaignPrompt = true;
$this->campagna_id = $campagna->id;
} else {
$this->showCampaignPrompt = false;
$this->campagna_id = null;
$this->counted_in_campaign = false;
}
}
protected function rules(): array
{
return [
'returned_at' => 'required|date|after_or_equal:' . $this->assegnazione->assigned_at->format('Y-m-d') . '|before_or_equal:today',
];
}
public function save()
{
$this->validate();
$returnDate = \Carbon\Carbon::parse($this->returned_at);
$this->assegnazione->update([
'returned_at' => $returnDate,
'returned_by' => auth()->id(),
'campaign_id' => $this->counted_in_campaign ? $this->campagna_id : null,
'counted_in_campaign' => $this->counted_in_campaign,
]);
$territorio = $this->assegnazione->territorio;
$proclamatore = $this->assegnazione->proclamatore;
activity()->causedBy(auth()->user())
->performedOn($this->assegnazione)
->withProperties([
'territorio' => $territorio->numero,
'proclamatore' => $proclamatore->nome_completo,
'giorni' => $this->assegnazione->giorni,
'campagna' => $this->counted_in_campaign,
])
->log('returned');
session()->flash('success', "Territorio {$territorio->numero} rientrato da {$proclamatore->nome_completo} dopo {$this->assegnazione->giorni} giorni.");
return $this->redirect(route('territori.show', $territorio), navigate: true);
}
public function render()
{
$campagna = $this->campagna_id ? Campagna::find($this->campagna_id) : null;
return view('livewire.assegnazioni.rientra', [
'campagna' => $campagna,
]);
}
}

57
app/Livewire/AuditLog.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Spatie\Activitylog\Models\Activity;
class AuditLog extends Component
{
use WithPagination;
public string $search = '';
public string $filterEvent = '';
public string $filterCauser = '';
protected $queryString = [
'search' => ['except' => ''],
'filterEvent' => ['except' => ''],
'filterCauser' => ['except' => ''],
];
public function updatingSearch()
{
$this->resetPage();
}
public function render()
{
$query = Activity::with('causer')->latest();
if ($this->search) {
$query->where(function ($q) {
$q->where('description', 'like', "%{$this->search}%")
->orWhere('subject_type', 'like', "%{$this->search}%")
->orWhere('properties', 'like', "%{$this->search}%");
});
}
if ($this->filterEvent) {
$query->where('description', $this->filterEvent);
}
if ($this->filterCauser) {
$query->where('causer_id', $this->filterCauser);
}
$events = Activity::select('description')->distinct()->pluck('description');
$users = \App\Models\User::orderBy('name')->get();
return view('livewire.audit-log', [
'activities' => $query->paginate(30),
'events' => $events,
'users' => $users,
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Component;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
class Login extends Component
{
public string $email = '';
public string $password = '';
public bool $remember = false;
protected function rules(): array
{
return [
'email' => 'required|email',
'password' => 'required|min:6',
];
}
public function login()
{
$this->validate();
$throttleKey = Str::transliterate(Str::lower($this->email) . '|' . request()->ip());
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
$seconds = RateLimiter::availableIn($throttleKey);
$this->addError('email', "Troppi tentativi. Riprova tra {$seconds} secondi.");
return;
}
if (!Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
RateLimiter::hit($throttleKey);
$this->addError('email', 'Credenziali non valide.');
return;
}
RateLimiter::clear($throttleKey);
session()->regenerate();
activity()->causedBy(auth()->user())->log('login');
return redirect()->intended(route('dashboard'));
}
public function render()
{
return view('livewire.auth.login')
->layout('components.layouts.guest');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Livewire\Campagne;
use Livewire\Component;
use App\Models\Campagna;
class CampagnaCreate extends Component
{
public string $descrizione = '';
public string $start_date = '';
public string $end_date = '';
protected function rules(): array
{
return [
'descrizione' => 'required|string|max:255',
'start_date' => 'required|date',
'end_date' => 'required|date|after:start_date',
];
}
public function save()
{
$this->validate();
$campagna = Campagna::create([
'descrizione' => $this->descrizione,
'start_date' => $this->start_date,
'end_date' => $this->end_date,
]);
session()->flash('success', "Campagna '{$campagna->descrizione}' creata.");
return $this->redirect(route('campagne.index'), navigate: true);
}
public function render()
{
return view('livewire.campagne.campagna-form', [
'titolo' => 'Nuova Campagna',
'btnLabel' => 'Crea Campagna',
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Campagne;
use Livewire\Component;
use App\Models\Campagna;
class CampagnaEdit extends Component
{
public Campagna $campagna;
public string $descrizione = '';
public string $start_date = '';
public string $end_date = '';
public function mount(Campagna $campagna)
{
$this->campagna = $campagna;
$this->descrizione = $campagna->descrizione;
$this->start_date = $campagna->start_date->format('Y-m-d');
$this->end_date = $campagna->end_date->format('Y-m-d');
}
protected function rules(): array
{
return [
'descrizione' => 'required|string|max:255',
'start_date' => 'required|date',
'end_date' => 'required|date|after:start_date',
];
}
public function save()
{
$this->validate();
$this->campagna->update([
'descrizione' => $this->descrizione,
'start_date' => $this->start_date,
'end_date' => $this->end_date,
]);
session()->flash('success', "Campagna aggiornata.");
return $this->redirect(route('campagne.index'), navigate: true);
}
public function render()
{
return view('livewire.campagne.campagna-form', [
'titolo' => "Modifica: {$this->campagna->descrizione}",
'btnLabel' => 'Salva Modifiche',
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Campagne;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Campagna;
class CampagnaIndex extends Component
{
use WithPagination;
public function deleteCampagna(int $id)
{
$campagna = Campagna::findOrFail($id);
$campagna->delete();
session()->flash('success', "Campagna '{$campagna->descrizione}' eliminata.");
}
public function render()
{
return view('livewire.campagne.campagna-index', [
'campagne' => Campagna::orderByDesc('start_date')->paginate(15),
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Campagne;
use Livewire\Component;
use App\Models\Campagna;
use App\Models\Assegnazione;
use App\Models\Territorio;
class CampagnaShow extends Component
{
public Campagna $campagna;
public function mount(Campagna $campagna)
{
$this->campagna = $campagna;
}
public function render()
{
// All assignments with returned_at in campaign range that were counted
$conteggiate = Assegnazione::where('campagna_id', $this->campagna->id)
->where('counted_in_campaign', true)
->with(['territorio', 'proclamatore'])
->orderBy('returned_at')
->get();
// All assignments that were active during this campaign range
$assegnateNelRange = Assegnazione::where('assigned_at', '<=', $this->campagna->end_date)
->where(function ($q) {
$q->whereNull('returned_at')
->orWhere('returned_at', '>=', $this->campagna->start_date);
})
->count();
return view('livewire.campagne.campagna-show', [
'conteggiate' => $conteggiate,
'assegnateNelRange' => $assegnateNelRange,
]);
}
}

81
app/Livewire/Home.php Normal file
View File

@@ -0,0 +1,81 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Territorio;
use App\Models\Proclamatore;
use App\Models\Assegnazione;
use App\Models\AnnoTeocratico;
use App\Models\Campagna;
use App\Models\Setting;
class Home extends Component
{
public function render()
{
$settings = Setting::instance();
$annoCorrente = AnnoTeocratico::corrente();
$campagnaAttiva = Campagna::attiva();
// Territory counts
$totTerritoriAttivi = Territorio::where('attivo', true)->count();
$totAssegnati = Territorio::assegnato()->count();
$totInReparto = Territorio::inReparto()->count();
// Coverage: returned territories per current theocratic year
$territoriPercorsi = 0;
if ($annoCorrente) {
$territoriPercorsi = Assegnazione::where('anno_teocratico_id', $annoCorrente->id)
->whereNotNull('returned_at')
->distinct('territorio_id')
->count('territorio_id');
}
// Monthly average
$mediaPercorrenzaMensile = 0;
if ($annoCorrente && $annoCorrente->mesi_trascorsi > 0) {
$mediaPercorrenzaMensile = round($territoriPercorsi / $annoCorrente->mesi_trascorsi, 1);
}
// Campaign stats
$campagnaStats = null;
if ($campagnaAttiva) {
$campagnaStats = [
'descrizione' => $campagnaAttiva->descrizione,
'percentuale' => $campagnaAttiva->percentuale_percorrenza,
'fine' => $campagnaAttiva->end_date->format('d/m/Y'),
];
}
// Quick lists
$daAssegnare = Territorio::daAssegnare()
->with('zona', 'tipologia', 'ultimaAssegnazione')
->take(10)
->get();
$prioritari = Territorio::prioritari()
->with('zona', 'tipologia', 'ultimaAssegnazione')
->take(10)
->get();
$daRientrare = Territorio::daRientrare()
->with(['zona', 'assegnazioneCorrente.proclamatore'])
->take(10)
->get();
return view('livewire.home', [
'congregazione' => $settings->congregazione_nome ?? 'TerManager2',
'annoCorrente' => $annoCorrente,
'totTerritoriAttivi' => $totTerritoriAttivi,
'totAssegnati' => $totAssegnati,
'totInReparto' => $totInReparto,
'territoriPercorsi' => $territoriPercorsi,
'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile,
'campagnaStats' => $campagnaStats,
'daAssegnare' => $daAssegnare,
'prioritari' => $prioritari,
'daRientrare' => $daRientrare,
]);
}
}

19
app/Livewire/Privacy.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Setting;
class Privacy extends Component
{
public function render()
{
$settings = Setting::instance();
return view('livewire.privacy', [
'congregazione' => $settings->congregazione_nome ?? 'Congregazione',
'auditRetention' => $settings->audit_retention_days ?? 365,
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Livewire\Proclamatori;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Proclamatore;
class ProclamatoreCestino extends Component
{
use WithPagination;
public function restore(int $id)
{
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
$proclamatore->restore();
activity()->causedBy(auth()->user())
->performedOn($proclamatore)
->log('restored');
session()->flash('success', "Proclamatore ripristinato.");
}
public function forceDelete(int $id)
{
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
if ($proclamatore->assegnazioni()->exists()) {
session()->flash('error', 'Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
return;
}
activity()->causedBy(auth()->user())
->log('force_deleted_proclamatore');
$proclamatore->forceDelete();
session()->flash('success', 'Proclamatore eliminato definitivamente.');
}
public function render()
{
return view('livewire.proclamatori.proclamatore-cestino', [
'proclamatori' => Proclamatore::onlyTrashed()->orderByDesc('deleted_at')->paginate(20),
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Proclamatori;
use Livewire\Component;
use App\Models\Proclamatore;
class ProclamatoreCreate extends Component
{
public string $nome = '';
public string $cognome = '';
public bool $attivo = true;
protected function rules(): array
{
return [
'nome' => 'required|string|max:100',
'cognome' => 'required|string|max:100',
'attivo' => 'boolean',
];
}
public function save()
{
$this->validate();
$proclamatore = Proclamatore::create([
'nome' => $this->nome,
'cognome' => $this->cognome,
'attivo' => $this->attivo,
]);
activity()->causedBy(auth()->user())
->performedOn($proclamatore)
->log('created');
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} creato.");
return $this->redirect(route('proclamatori.index'), navigate: true);
}
public function render()
{
return view('livewire.proclamatori.proclamatore-form', [
'titolo' => 'Nuovo Proclamatore',
'btnLabel' => 'Crea Proclamatore',
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Proclamatori;
use Livewire\Component;
use App\Models\Proclamatore;
class ProclamatoreEdit extends Component
{
public Proclamatore $proclamatore;
public string $nome = '';
public string $cognome = '';
public bool $attivo = true;
public function mount(Proclamatore $proclamatore)
{
$this->proclamatore = $proclamatore;
$this->nome = $proclamatore->nome;
$this->cognome = $proclamatore->cognome;
$this->attivo = $proclamatore->attivo;
}
protected function rules(): array
{
return [
'nome' => 'required|string|max:100',
'cognome' => 'required|string|max:100',
'attivo' => 'boolean',
];
}
public function save()
{
$this->validate();
$this->proclamatore->update([
'nome' => $this->nome,
'cognome' => $this->cognome,
'attivo' => $this->attivo,
]);
session()->flash('success', "Proclamatore {$this->proclamatore->nome_completo} aggiornato.");
return $this->redirect(route('proclamatori.index'), navigate: true);
}
public function render()
{
return view('livewire.proclamatori.proclamatore-form', [
'titolo' => "Modifica {$this->proclamatore->nome_completo}",
'btnLabel' => 'Salva Modifiche',
]);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Livewire\Proclamatori;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Proclamatore;
class ProclamatoreIndex extends Component
{
use WithPagination;
public string $search = '';
public string $filtroStato = '';
public string $sortField = 'cognome';
public string $sortDirection = 'asc';
protected $queryString = [
'search' => ['except' => ''],
'filtroStato' => ['except' => ''],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingFiltroStato()
{
$this->resetPage();
}
public function sortBy(string $field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function toggleActive(int $id)
{
$proclamatore = Proclamatore::findOrFail($id);
$proclamatore->update(['attivo' => !$proclamatore->attivo]);
}
public function deleteProclamatore(int $id)
{
$proclamatore = Proclamatore::findOrFail($id);
if ($proclamatore->assegnazioni()->aperte()->exists()) {
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
return;
}
$proclamatore->delete();
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
}
public function anonimizza(int $id)
{
$proclamatore = Proclamatore::findOrFail($id);
$proclamatore->anonimizza();
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR).");
}
public function render()
{
$query = Proclamatore::query();
if ($this->search !== '') {
// In-memory filter because nome/cognome are encrypted
$all = $query->get();
$filtered = $all->filter(function ($p) {
return str_contains(
mb_strtolower($p->nome . ' ' . $p->cognome),
mb_strtolower($this->search)
);
});
if ($this->filtroStato === 'attivo') {
$filtered = $filtered->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') {
$filtered = $filtered->where('attivo', false);
}
// Sort in-memory
$filtered = $filtered->sortBy(function ($p) {
return match ($this->sortField) {
'nome' => mb_strtolower($p->nome),
'cognome' => mb_strtolower($p->cognome),
default => mb_strtolower($p->cognome),
};
}, SORT_REGULAR, $this->sortDirection === 'desc');
// Manual pagination
$page = $this->getPage();
$perPage = 20;
$items = $filtered->slice(($page - 1) * $perPage, $perPage)->values();
$proclamatori = new \Illuminate\Pagination\LengthAwarePaginator(
$items, $filtered->count(), $perPage, $page,
['path' => request()->url()]
);
} else {
if ($this->filtroStato === 'attivo') {
$query->where('attivo', true);
} elseif ($this->filtroStato === 'inattivo') {
$query->where('attivo', false);
}
// cognome/nome encrypted, so sort in-memory
$all = $query->get()->sortBy(function ($p) {
return match ($this->sortField) {
'nome' => mb_strtolower($p->nome),
'cognome' => mb_strtolower($p->cognome),
default => mb_strtolower($p->cognome),
};
}, SORT_REGULAR, $this->sortDirection === 'desc');
$page = $this->getPage();
$perPage = 20;
$items = $all->slice(($page - 1) * $perPage, $perPage)->values();
$proclamatori = new \Illuminate\Pagination\LengthAwarePaginator(
$items, $all->count(), $perPage, $page,
['path' => request()->url()]
);
}
return view('livewire.proclamatori.proclamatore-index', [
'proclamatori' => $proclamatori,
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Proclamatori;
use Livewire\Component;
use App\Models\Proclamatore;
use App\Models\Assegnazione;
class ProclamatoreShow extends Component
{
public Proclamatore $proclamatore;
public function mount(Proclamatore $proclamatore)
{
$this->proclamatore = $proclamatore;
}
public function render()
{
$assegnazioni = Assegnazione::where('proclamatore_id', $this->proclamatore->id)
->with(['territorio', 'annoTeocratico', 'campagna'])
->orderByDesc('assigned_at')
->get()
->groupBy(fn($a) => $a->annoTeocratico->label);
$stats = [
'totale_assegnazioni' => Assegnazione::where('proclamatore_id', $this->proclamatore->id)->count(),
'attualmente_assegnati' => Assegnazione::where('proclamatore_id', $this->proclamatore->id)->aperte()->count(),
'media_giorni' => round(
Assegnazione::where('proclamatore_id', $this->proclamatore->id)
->chiuse()
->get()
->avg(fn($a) => $a->giorni) ?? 0
),
];
return view('livewire.proclamatori.proclamatore-show', [
'assegnazioniPerAnno' => $assegnazioni,
'stats' => $stats,
]);
}
}

101
app/Livewire/Registro.php Normal file
View File

@@ -0,0 +1,101 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Assegnazione;
use App\Models\AnnoTeocratico;
use App\Models\Zona;
use App\Models\Tipologia;
class Registro extends Component
{
use WithPagination;
public string $search = '';
public string $filtroAnno = '';
public string $filtroZona = '';
public string $filtroTipologia = '';
public string $filtroStato = ''; // aperte, chiuse
public string $sortField = 'assigned_at';
public string $sortDirection = 'desc';
protected $queryString = [
'search' => ['except' => ''],
'filtroAnno' => ['except' => ''],
'filtroZona' => ['except' => ''],
'filtroTipologia' => ['except' => ''],
'filtroStato' => ['except' => ''],
];
public function updatingSearch()
{
$this->resetPage();
}
public function sortBy(string $field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'desc';
}
}
public function render()
{
$query = Assegnazione::with(['territorio.zona', '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));
}
$query->orderBy($this->sortField, $this->sortDirection);
// 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(),
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Component;
use App\Models\Setting;
class SettingsEdit extends Component
{
public string $congregazione_nome = '';
public int $giorni_giacenza_prioritari = 180;
public int $giorni_per_smarrito = 120;
public int $audit_retention_days = 365;
public function mount()
{
$settings = Setting::instance();
$this->congregazione_nome = $settings->congregazione_nome ?? '';
$this->giorni_giacenza_prioritari = $settings->giorni_giacenza_prioritari ?? 180;
$this->giorni_per_smarrito = $settings->giorni_per_smarrito ?? 120;
$this->audit_retention_days = $settings->audit_retention_days ?? 365;
}
protected function rules(): array
{
return [
'congregazione_nome' => 'required|string|max:255',
'giorni_giacenza_prioritari' => 'required|integer|min:1|max:730',
'giorni_per_smarrito' => 'required|integer|min:30|max:365',
'audit_retention_days' => 'required|integer|min:30|max:3650',
];
}
public function save()
{
$this->validate();
$settings = Setting::instance();
$settings->update([
'congregazione_nome' => $this->congregazione_nome,
'giorni_giacenza_prioritari' => $this->giorni_giacenza_prioritari,
'giorni_per_smarrito' => $this->giorni_per_smarrito,
'audit_retention_days' => $this->audit_retention_days,
]);
session()->flash('success', 'Impostazioni aggiornate.');
}
public function render()
{
return view('livewire.settings.settings-edit');
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Component;
use App\Models\Tipologia;
class TipologieIndex extends Component
{
public string $nuovaTipologia = '';
public ?int $editingId = null;
public string $editingNome = '';
public function addTipologia()
{
$this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']);
Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]);
$this->nuovaTipologia = '';
session()->flash('success', 'Tipologia aggiunta.');
}
public function startEdit(int $id)
{
$tipo = Tipologia::findOrFail($id);
$this->editingId = $id;
$this->editingNome = $tipo->nome;
}
public function saveEdit()
{
$this->validate(['editingNome' => "required|string|max:100|unique:tipologie,nome,{$this->editingId}"]);
$tipo = Tipologia::findOrFail($this->editingId);
$tipo->update(['nome' => $this->editingNome]);
$this->editingId = null;
$this->editingNome = '';
session()->flash('success', 'Tipologia aggiornata.');
}
public function cancelEdit()
{
$this->editingId = null;
$this->editingNome = '';
}
public function toggleActive(int $id)
{
$tipo = Tipologia::findOrFail($id);
$tipo->update(['attivo' => !$tipo->attivo]);
}
public function deleteTipologia(int $id)
{
$tipo = Tipologia::findOrFail($id);
if ($tipo->territori()->exists()) {
session()->flash('error', "Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
return;
}
$tipo->delete();
session()->flash('success', "Tipologia '{$tipo->nome}' eliminata.");
}
public function render()
{
return view('livewire.settings.tipologie-index', [
'tipologie' => Tipologia::orderBy('nome')->get(),
]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Component;
use App\Models\Zona;
class ZoneIndex extends Component
{
public string $nuovaZona = '';
public ?int $editingId = null;
public string $editingNome = '';
public function addZona()
{
$this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']);
Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]);
$this->nuovaZona = '';
session()->flash('success', 'Zona aggiunta.');
}
public function startEdit(int $id)
{
$zona = Zona::findOrFail($id);
$this->editingId = $id;
$this->editingNome = $zona->nome;
}
public function saveEdit()
{
$this->validate(['editingNome' => "required|string|max:100|unique:zone,nome,{$this->editingId}"]);
$zona = Zona::findOrFail($this->editingId);
$zona->update(['nome' => $this->editingNome]);
$this->editingId = null;
$this->editingNome = '';
session()->flash('success', 'Zona aggiornata.');
}
public function cancelEdit()
{
$this->editingId = null;
$this->editingNome = '';
}
public function toggleActive(int $id)
{
$zona = Zona::findOrFail($id);
$zona->update(['attivo' => !$zona->attivo]);
}
public function deleteZona(int $id)
{
$zona = Zona::findOrFail($id);
if ($zona->territori()->exists()) {
session()->flash('error', "Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
return;
}
$zona->delete();
session()->flash('success', "Zona '{$zona->nome}' eliminata.");
}
public function render()
{
return view('livewire.settings.zone-index', [
'zone' => Zona::orderBy('nome')->get(),
]);
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Livewire\Setup;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class Wizard extends Component
{
use WithFileUploads;
public int $step = 1;
// Step 1
public string $congregazione_nome = '';
public $logo;
// Step 2
public int $giorni_giacenza_da_assegnare = 120;
public int $giorni_giacenza_prioritari = 180;
public int $giorni_per_smarrito = 120;
public int $home_limit_list = 10;
// Step 3 (admin creation if no users)
public string $admin_name = '';
public string $admin_email = '';
public string $admin_password = '';
public string $admin_password_confirmation = '';
public bool $needsAdmin = false;
public function mount()
{
if (Setting::isSetupComplete()) {
return redirect()->route('dashboard');
}
$this->needsAdmin = User::count() <= 1;
$setting = Setting::first();
if ($setting) {
$this->congregazione_nome = $setting->congregazione_nome ?? '';
$this->giorni_giacenza_da_assegnare = $setting->giorni_giacenza_da_assegnare;
$this->giorni_giacenza_prioritari = $setting->giorni_giacenza_prioritari;
$this->giorni_per_smarrito = $setting->giorni_per_smarrito;
$this->home_limit_list = $setting->home_limit_list;
}
}
public function nextStep()
{
if ($this->step === 1) {
$this->validate([
'congregazione_nome' => 'required|string|max:255',
'logo' => 'nullable|image|max:2048',
]);
}
if ($this->step === 2) {
$this->validate([
'giorni_giacenza_da_assegnare' => 'required|integer|min:1|max:999',
'giorni_giacenza_prioritari' => 'required|integer|min:1|max:999',
'giorni_per_smarrito' => 'required|integer|min:1|max:999',
'home_limit_list' => 'required|integer|min:1|max:100',
]);
}
$this->step++;
}
public function previousStep()
{
$this->step = max(1, $this->step - 1);
}
public function finish()
{
if ($this->needsAdmin) {
$this->validate([
'admin_name' => 'required|string|max:255',
'admin_email' => 'required|email|unique:users,email',
'admin_password' => 'required|min:8|confirmed',
]);
}
$setting = Setting::instance();
$setting->congregazione_nome = $this->congregazione_nome;
$setting->giorni_giacenza_da_assegnare = $this->giorni_giacenza_da_assegnare;
$setting->giorni_giacenza_prioritari = $this->giorni_giacenza_prioritari;
$setting->giorni_per_smarrito = $this->giorni_per_smarrito;
$setting->home_limit_list = $this->home_limit_list;
$setting->setup_completed = true;
if ($this->logo) {
$path = $this->logo->store('logos', 'public');
$setting->logo_path = $path;
}
$setting->save();
if ($this->needsAdmin && $this->admin_email) {
$admin = User::create([
'name' => $this->admin_name,
'email' => $this->admin_email,
'password' => Hash::make($this->admin_password),
]);
$admin->assignRole('amministratore');
}
session()->flash('success', 'Setup completato con successo!');
return redirect()->route('dashboard');
}
public function render()
{
return view('livewire.setup.wizard')
->layout('components.layouts.guest', ['title' => 'Setup iniziale']);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Territori;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Territorio;
class TerritorioCestino extends Component
{
use WithPagination;
public function restore(int $id)
{
$territorio = Territorio::onlyTrashed()->findOrFail($id);
$territorio->restore();
activity()->causedBy(auth()->user())
->performedOn($territorio)
->log('restored');
session()->flash('success', "Territorio {$territorio->numero} ripristinato.");
}
public function forceDelete(int $id)
{
$territorio = Territorio::onlyTrashed()->findOrFail($id);
if ($territorio->assegnazioni()->exists()) {
session()->flash('error', 'Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
return;
}
activity()->causedBy(auth()->user())
->withProperties(['numero' => $territorio->numero])
->log('force_deleted_territorio');
$territorio->forceDelete();
session()->flash('success', 'Territorio eliminato definitivamente.');
}
public function render()
{
return view('livewire.territori.territorio-cestino', [
'territori' => Territorio::onlyTrashed()->orderByDesc('deleted_at')->paginate(20),
]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Livewire\Territori;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\Territorio;
use App\Models\Zona;
use App\Models\Tipologia;
class TerritorioCreate extends Component
{
use WithFileUploads;
public string $numero = '';
public ?int $zona_id = null;
public ?int $tipologia_id = null;
public string $note = '';
public string $confini = '';
public $pdf;
public bool $prioritario = false;
protected function rules(): array
{
return [
'numero' => 'required|string|max:20|unique:territori,numero',
'zona_id' => 'nullable|exists:zone,id',
'tipologia_id' => 'nullable|exists:tipologie,id',
'note' => 'nullable|string|max:5000',
'confini' => 'nullable|string|max:5000',
'pdf' => 'nullable|file|mimes:pdf|max:10240',
'prioritario' => 'boolean',
];
}
public function save()
{
$this->validate();
$data = [
'numero' => $this->numero,
'zona_id' => $this->zona_id,
'tipologia_id' => $this->tipologia_id,
'note' => $this->note ?: null,
'confini' => $this->confini ?: null,
'prioritario' => $this->prioritario,
'attivo' => true,
];
if ($this->pdf) {
$data['pdf_path'] = $this->pdf->store('territori-pdf', 'public');
}
$territorio = Territorio::create($data);
activity()->causedBy(auth()->user())
->performedOn($territorio)
->withProperties(['numero' => $territorio->numero])
->log('created');
session()->flash('success', "Territorio {$territorio->numero} creato.");
return redirect()->route('territori.index');
}
public function render()
{
return view('livewire.territori.territorio-form', [
'zone' => Zona::attive()->get(),
'tipologie' => Tipologia::attive()->get(),
'isEdit' => false,
'title' => 'Nuovo Territorio',
]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Livewire\Territori;
use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\Territorio;
use App\Models\Zona;
use App\Models\Tipologia;
use Illuminate\Support\Facades\Storage;
class TerritorioEdit extends Component
{
use WithFileUploads;
public Territorio $territorio;
public string $numero = '';
public ?int $zona_id = null;
public ?int $tipologia_id = null;
public string $note = '';
public string $confini = '';
public $pdf;
public bool $prioritario = false;
public function mount(Territorio $territorio)
{
$this->territorio = $territorio;
$this->numero = $territorio->numero;
$this->zona_id = $territorio->zona_id;
$this->tipologia_id = $territorio->tipologia_id;
$this->note = $territorio->note ?? '';
$this->confini = $territorio->confini ?? '';
$this->prioritario = $territorio->prioritario;
}
protected function rules(): array
{
return [
'numero' => 'required|string|max:20|unique:territori,numero,' . $this->territorio->id,
'zona_id' => 'nullable|exists:zone,id',
'tipologia_id' => 'nullable|exists:tipologie,id',
'note' => 'nullable|string|max:5000',
'confini' => 'nullable|string|max:5000',
'pdf' => 'nullable|file|mimes:pdf|max:10240',
'prioritario' => 'boolean',
];
}
public function save()
{
$this->validate();
$data = [
'numero' => $this->numero,
'zona_id' => $this->zona_id,
'tipologia_id' => $this->tipologia_id,
'note' => $this->note ?: null,
'confini' => $this->confini ?: null,
'prioritario' => $this->prioritario,
];
if ($this->pdf) {
// Remove old PDF
if ($this->territorio->pdf_path) {
Storage::disk('public')->delete($this->territorio->pdf_path);
}
$data['pdf_path'] = $this->pdf->store('territori-pdf', 'public');
}
$this->territorio->update($data);
session()->flash('success', "Territorio {$this->territorio->numero} aggiornato.");
return redirect()->route('territori.show', $this->territorio);
}
public function removePdf()
{
if ($this->territorio->pdf_path) {
Storage::disk('public')->delete($this->territorio->pdf_path);
$this->territorio->update(['pdf_path' => null]);
activity()->causedBy(auth()->user())
->performedOn($this->territorio)
->log('removed_pdf');
}
}
public function render()
{
return view('livewire.territori.territorio-form', [
'zone' => Zona::attive()->get(),
'tipologie' => Tipologia::attive()->get(),
'isEdit' => true,
'title' => "Modifica Territorio {$this->territorio->numero}",
]);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Livewire\Territori;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Territorio;
use App\Models\Zona;
use App\Models\Tipologia;
class TerritorioIndex extends Component
{
use WithPagination;
public string $search = '';
public string $filterZona = '';
public string $filterTipologia = '';
public string $filterStato = '';
public string $sortField = 'numero';
public string $sortDirection = 'asc';
protected $queryString = [
'search' => ['except' => ''],
'filterZona' => ['except' => ''],
'filterTipologia' => ['except' => ''],
'filterStato' => ['except' => ''],
];
public function updatingSearch()
{
$this->resetPage();
}
public function sortBy(string $field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function toggleActive(Territorio $territorio)
{
$territorio->update(['attivo' => !$territorio->attivo]);
activity()->causedBy(auth()->user())
->performedOn($territorio)
->withProperties(['attivo' => $territorio->attivo])
->log($territorio->attivo ? 'activated' : 'deactivated');
}
public function togglePriority(Territorio $territorio)
{
$territorio->update(['prioritario' => !$territorio->prioritario]);
activity()->causedBy(auth()->user())
->performedOn($territorio)
->withProperties(['prioritario' => $territorio->prioritario])
->log($territorio->prioritario ? 'set_priority' : 'unset_priority');
}
public function deleteTerritorio(Territorio $territorio)
{
activity()->causedBy(auth()->user())
->performedOn($territorio)
->log('soft_deleted');
$territorio->delete();
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino.");
}
public function render()
{
$query = Territorio::with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']);
if ($this->search) {
$query->where('numero', 'like', "%{$this->search}%");
}
if ($this->filterZona) {
$query->where('zona_id', $this->filterZona);
}
if ($this->filterTipologia) {
$query->where('tipologia_id', $this->filterTipologia);
}
if ($this->filterStato) {
match ($this->filterStato) {
'in_reparto' => $query->inReparto(),
'assegnato' => $query->assegnato(),
'da_rientrare' => $query->daRientrare(),
'inattivo' => $query->where('attivo', false),
default => null,
};
}
$query->orderBy($this->sortField, $this->sortDirection);
return view('livewire.territori.territorio-index', [
'territori' => $query->paginate(20),
'zone' => Zona::attive()->get(),
'tipologie' => Tipologia::attive()->get(),
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Territori;
use Livewire\Component;
use App\Models\Territorio;
use App\Models\Assegnazione;
use App\Models\AnnoTeocratico;
class TerritorioShow extends Component
{
public Territorio $territorio;
public function mount(Territorio $territorio)
{
$this->territorio = $territorio->load(['zona', 'tipologia']);
}
public function render()
{
$assegnazioni = Assegnazione::where('territorio_id', $this->territorio->id)
->with(['proclamatore', 'annoTeocratico', 'campagna', 'creatoDa', 'rientratoDa'])
->orderByDesc('assigned_at')
->get()
->groupBy(fn($a) => $a->annoTeocratico->label);
return view('livewire.territori.territorio-show', [
'assegnazioniPerAnno' => $assegnazioni,
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
class AnnoTeocratico extends Model
{
protected $table = 'anni_teocratici';
protected $fillable = ['label', 'start_date', 'end_date'];
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
];
}
/**
* Get or create the theocratic year for a given date.
*/
public static function perData(Carbon $date = null): static
{
$date = $date ?? now();
if ($date->month >= 9) {
$startYear = $date->year;
$endYear = $date->year + 1;
} else {
$startYear = $date->year - 1;
$endYear = $date->year;
}
$label = "{$startYear}-{$endYear}";
return static::firstOrCreate(
['label' => $label],
[
'start_date' => Carbon::create($startYear, 9, 1),
'end_date' => Carbon::create($endYear, 8, 31),
]
);
}
/**
* Get the current theocratic year.
*/
public static function corrente(): static
{
return static::perData(now());
}
/**
* Number of months elapsed since start of this theocratic year.
*/
public function getMesiTrascorsiAttribute(): int
{
$start = $this->start_date;
$end = now()->lt($this->end_date) ? now() : $this->end_date;
return max(1, $start->diffInMonths($end));
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'anno_teocratico_id');
}
}

113
app/Models/Assegnazione.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
class Assegnazione extends Model
{
protected $table = 'assegnazioni';
protected $fillable = [
'territorio_id',
'proclamatore_id',
'anno_teocratico_id',
'assigned_at',
'returned_at',
'counted_in_campaign',
'campaign_id',
'note',
'created_by',
'returned_by',
];
protected function casts(): array
{
return [
'assigned_at' => 'date',
'returned_at' => 'date',
'counted_in_campaign' => 'boolean',
];
}
// ─── Relationships ─────────────────────────────────────────
public function territorio()
{
return $this->belongsTo(Territorio::class, 'territorio_id')->withTrashed();
}
public function proclamatore()
{
return $this->belongsTo(Proclamatore::class, 'proclamatore_id')->withTrashed();
}
public function annoTeocratico()
{
return $this->belongsTo(AnnoTeocratico::class, 'anno_teocratico_id');
}
public function campagna()
{
return $this->belongsTo(Campagna::class, 'campaign_id');
}
public function creatoDa()
{
return $this->belongsTo(User::class, 'created_by');
}
public function rientratoDa()
{
return $this->belongsTo(User::class, 'returned_by');
}
// ─── Computed ───────────────────────────────────────────────
/**
* Number of days between assignment and return (or today if still open).
*/
public function getGiorniAttribute(): int
{
$end = $this->returned_at ?? now();
return Carbon::parse($this->assigned_at)->diffInDays($end);
}
public function getIsApertaAttribute(): bool
{
return is_null($this->returned_at);
}
// ─── Scopes ─────────────────────────────────────────────────
public function scopeAperte($query)
{
return $query->whereNull('returned_at');
}
public function scopeChiuse($query)
{
return $query->whereNotNull('returned_at');
}
public function scopePerAnnoTeocratico($query, $annoId)
{
return $query->where('anno_teocratico_id', $annoId);
}
// ─── Business Logic ─────────────────────────────────────────
/**
* Check if a campaign prompt should be shown when returning this assignment.
* Returns the matching campaign or null.
*/
public function campagnaApplicabile(?\Carbon\Carbon $returnDate = null): ?Campagna
{
$returnDate = $returnDate ?? now();
return Campagna::where('start_date', '<=', $returnDate)
->where('end_date', '>=', $this->assigned_at)
->first();
}
}

94
app/Models/Campagna.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Campagna extends Model
{
use LogsActivity;
protected $table = 'campagne';
protected $fillable = ['start_date', 'end_date', 'descrizione'];
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['start_date', 'end_date', 'descrizione'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
/**
* Is this campaign currently active?
*/
public function getIsAttivaAttribute(): bool
{
$today = now()->toDateString();
return $this->start_date->toDateString() <= $today
&& $this->end_date->toDateString() >= $today;
}
/**
* Find the currently active campaign (if any).
*/
public static function attiva(): ?static
{
return static::where('start_date', '<=', now())
->where('end_date', '>=', now())
->first();
}
/**
* Assignments counted for this campaign.
*/
public function assegnazioniConteggiate()
{
return $this->hasMany(Assegnazione::class, 'campaign_id')
->where('counted_in_campaign', true);
}
/**
* All assignments with assigned_at in this campaign's range.
*/
public function assegnazioniNelRange()
{
return Assegnazione::where('assigned_at', '>=', $this->start_date)
->where('assigned_at', '<=', $this->end_date);
}
/**
* Campaign coverage percentage.
* Numerator: assignments counted for campaign
* Denominator: ALL assignments with assigned_at in campaign range (returned or not)
*/
public function getPercentualePercorrenzaAttribute(): float
{
$totaleNelRange = $this->assegnazioniNelRange()->count();
if ($totaleNelRange === 0) {
return 0.0;
}
$conteggiate = $this->assegnazioniConteggiate()->count();
return round(($conteggiate / $totaleNelRange) * 100, 1);
}
public function scopeCompletate($query)
{
return $query->where('end_date', '<', now())->orderByDesc('end_date');
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Proclamatore extends Model
{
use SoftDeletes, LogsActivity;
protected $table = 'proclamatori';
protected $fillable = ['nome', 'cognome', 'attivo'];
protected function casts(): array
{
return [
'nome' => 'encrypted',
'cognome' => 'encrypted',
'attivo' => 'boolean',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['attivo']) // Do NOT log nome/cognome in audit (encrypted, GDPR)
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
/**
* Full name (decrypted, only visible in PHP/UI).
*/
public function getNomeCompletoAttribute(): string
{
return trim($this->cognome . ' ' . $this->nome);
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'proclamatore_id');
}
public function assegnazioniAperte()
{
return $this->hasMany(Assegnazione::class, 'proclamatore_id')
->whereNull('returned_at');
}
public function scopeAttivi($query)
{
return $query->where('attivo', true);
}
/**
* Anonymize this proclamatore (GDPR right to be forgotten).
*/
public function anonimizza(): void
{
$this->nome = 'Anonimo';
$this->cognome = 'Proclamatore #' . $this->id;
$this->attivo = false;
$this->save();
$this->delete(); // soft delete
}
}

56
app/Models/Setting.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = [
'congregazione_nome',
'logo_path',
'giorni_giacenza_da_assegnare',
'giorni_giacenza_prioritari',
'giorni_per_smarrito',
'home_limit_list',
'audit_retention_days',
'setup_completed',
];
protected function casts(): array
{
return [
'setup_completed' => 'boolean',
'giorni_giacenza_da_assegnare' => 'integer',
'giorni_giacenza_prioritari' => 'integer',
'giorni_per_smarrito' => 'integer',
'home_limit_list' => 'integer',
'audit_retention_days' => 'integer',
];
}
/**
* Get the singleton settings instance (first row).
*/
public static function instance(): static
{
return static::firstOrCreate([], [
'giorni_giacenza_da_assegnare' => 120,
'giorni_giacenza_prioritari' => 180,
'giorni_per_smarrito' => 120,
'home_limit_list' => 10,
'audit_retention_days' => 730,
]);
}
public static function isSetupComplete(): bool
{
$setting = static::first();
return $setting && $setting->setup_completed;
}
public static function getValue(string $key, mixed $default = null): mixed
{
return static::instance()->{$key} ?? $default;
}
}

219
app/Models/Territorio.php Normal file
View File

@@ -0,0 +1,219 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Territorio extends Model
{
use SoftDeletes, LogsActivity;
protected $table = 'territori';
protected $fillable = [
'numero',
'zona_id',
'tipologia_id',
'note',
'confini',
'pdf_path',
'attivo',
'prioritario',
];
protected function casts(): array
{
return [
'attivo' => 'boolean',
'prioritario' => 'boolean',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['numero', 'zona_id', 'tipologia_id', 'attivo', 'prioritario', 'pdf_path'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
// ─── Relationships ─────────────────────────────────────────
public function zona()
{
return $this->belongsTo(Zona::class, 'zona_id');
}
public function tipologia()
{
return $this->belongsTo(Tipologia::class, 'tipologia_id');
}
public function assegnazioni()
{
return $this->hasMany(Assegnazione::class, 'territorio_id');
}
public function assegnazioneCorrente()
{
return $this->hasOne(Assegnazione::class, 'territorio_id')
->whereNull('returned_at')
->latestOfMany('assigned_at');
}
public function ultimaAssegnazione()
{
return $this->hasOne(Assegnazione::class, 'territorio_id')
->latestOfMany('assigned_at');
}
// ─── Computed State ─────────────────────────────────────────
public function getStatoAttribute(): string
{
if (!$this->attivo) {
return 'inattivo';
}
$corrente = $this->assegnazioneCorrente;
if ($corrente) {
$giorniAssegnato = Carbon::parse($corrente->assigned_at)->diffInDays(now());
$sogliaSmarrito = Setting::getValue('giorni_per_smarrito', 120);
if ($giorniAssegnato > $sogliaSmarrito) {
return 'da_rientrare';
}
return 'assegnato';
}
return 'in_reparto';
}
public function getAssegnatarioAttribute(): ?Proclamatore
{
return $this->assegnazioneCorrente?->proclamatore;
}
/**
* Days since last return (or creation if never assigned).
*/
public function getGiorniGiacenzaAttribute(): int
{
$ultima = $this->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) {
return Carbon::parse($ultima->returned_at)->diffInDays(now());
}
if (!$ultima) {
return $this->created_at->diffInDays(now());
}
// Currently assigned, no giacenza concept
return 0;
}
/**
* Is this territory "prioritario"?
* Manual flag OR giacenza exceeds threshold (threshold always wins).
*/
public function getIsPrioritarioAttribute(): bool
{
if (!$this->attivo) {
return false;
}
if ($this->prioritario) {
return true;
}
// Threshold-based priority (only when in reparto)
if ($this->stato === 'in_reparto') {
$soglia = Setting::getValue('giorni_giacenza_prioritari', 180);
return $this->giorni_giacenza > $soglia;
}
return false;
}
// ─── Scopes ─────────────────────────────────────────────────
public function scopeAttivi($query)
{
return $query->where('attivo', true);
}
public function scopeInReparto($query)
{
return $query->attivi()
->whereDoesntHave('assegnazioni', function ($q) {
$q->whereNull('returned_at');
});
}
public function scopeAssegnato($query)
{
return $query->attivi()
->whereHas('assegnazioni', function ($q) {
$q->whereNull('returned_at');
});
}
public function scopeDaRientrare($query)
{
$soglia = Setting::getValue('giorni_per_smarrito', 120);
return $query->attivi()
->whereHas('assegnazioni', function ($q) use ($soglia) {
$q->whereNull('returned_at')
->where('assigned_at', '<=', now()->subDays($soglia));
});
}
public function scopeDaAssegnare($query)
{
$soglia = Setting::getValue('giorni_giacenza_da_assegnare', 120);
return $query->inReparto()
->where(function ($q) use ($soglia) {
// Territories whose last assignment returned > soglia days ago
$q->whereHas('assegnazioni', function ($sub) use ($soglia) {
$sub->whereNotNull('returned_at')
->where('returned_at', '<=', now()->subDays($soglia))
->whereRaw('id = (SELECT MAX(a2.id) FROM assegnazioni a2 WHERE a2.territorio_id = assegnazioni.territorio_id)');
})
// Or territories never assigned, created > soglia days ago
->orWhere(function ($sub) use ($soglia) {
$sub->doesntHave('assegnazioni')
->where('created_at', '<=', now()->subDays($soglia));
});
});
}
public function scopePrioritari($query)
{
$soglia = Setting::getValue('giorni_giacenza_prioritari', 180);
return $query->inReparto()
->where(function ($q) use ($soglia) {
// Manual priority flag
$q->where('prioritario', true)
// OR threshold-based
->orWhere(function ($sub) use ($soglia) {
$sub->whereHas('assegnazioni', function ($a) use ($soglia) {
$a->whereNotNull('returned_at')
->where('returned_at', '<=', now()->subDays($soglia))
->whereRaw('id = (SELECT MAX(a2.id) FROM assegnazioni a2 WHERE a2.territorio_id = assegnazioni.territorio_id)');
})
->orWhere(function ($never) use ($soglia) {
$never->doesntHave('assegnazioni')
->where('created_at', '<=', now()->subDays($soglia));
});
});
});
}
}

41
app/Models/Tipologia.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Tipologia extends Model
{
use LogsActivity;
protected $table = 'tipologie';
protected $fillable = ['nome', 'attivo'];
protected function casts(): array
{
return [
'attivo' => 'boolean',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['nome', 'attivo'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function territori()
{
return $this->hasMany(Territorio::class, 'tipologia_id');
}
public function scopeAttive($query)
{
return $query->where('attivo', true);
}
}

42
app/Models/User.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasRoles, LogsActivity;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'email'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
}

41
app/Models/Zona.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class Zona extends Model
{
use LogsActivity;
protected $table = 'zone';
protected $fillable = ['nome', 'attivo'];
protected function casts(): array
{
return [
'attivo' => 'boolean',
];
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['nome', 'attivo'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
public function territori()
{
return $this->hasMany(Territorio::class, 'zona_id');
}
public function scopeAttive($query)
{
return $query->where('attivo', true);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Artisan;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
// Auto-generate APP_KEY if missing
if (empty(config('app.key'))) {
Artisan::call('key:generate', ['--force' => true]);
}
Gate::before(function ($user, $ability) {
return $user->hasRole('amministratore') ? true : null;
});
}
}