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

38
.env.example Normal file
View File

@@ -0,0 +1,38 @@
APP_NAME=TerManager2
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=Europe/Rome
APP_URL=http://localhost:8080
APP_PORT=8080
DB_CONNECTION=mysql
DB_HOST=mariadb
DB_PORT=3306
DB_DATABASE=termanager2
DB_USERNAME=termanager2
DB_PASSWORD=secret
DB_ROOT_PASSWORD=rootsecret
REDIS_HOST=redis
REDIS_PASSWORD=redissecret
REDIS_PORT=6379
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="noreply@termanager2.local"
MAIL_FROM_NAME="${APP_NAME}"
MAILPIT_UI_PORT=8025
USER_ID=1000
GROUP_ID=1000

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
/vendor/
/node_modules/
/.env
/storage/*.key
/public/hot
/public/storage
/.phpunit.result.cache
/.idea/
/.vscode/
*.swp
*.swo
docker-compose.override.yml
db_data/
redis_data/

448
README.md Normal file
View File

@@ -0,0 +1,448 @@
# TerManager2
Applicazione web per la **gestione dell'assegnazione e rientro di territori** (cartoline), statistiche di percorrenza e campagne speciali per congregazioni.
---
## Indice
- [Panoramica](#panoramica)
- [Stack tecnologico](#stack-tecnologico)
- [Requisiti di sistema](#requisiti-di-sistema)
- [Installazione rapida](#installazione-rapida)
- [Configurazione](#configurazione)
- [Struttura del progetto](#struttura-del-progetto)
- [Funzionalità principali](#funzionalità-principali)
- [Ruoli e permessi (RBAC)](#ruoli-e-permessi-rbac)
- [Sicurezza e GDPR](#sicurezza-e-gdpr)
- [Modello dati](#modello-dati)
- [Regole di business](#regole-di-business)
- [Pagine dell'applicazione](#pagine-dellapplicazione)
- [Comandi utili](#comandi-utili)
- [Produzione](#produzione)
- [Licenza](#licenza)
---
## Panoramica
**TerManager2** gestisce:
- **Territori** (cartoline) con PDF allegato, stato dinamico (in reparto / assegnato / da rientrare / prioritario / inattivo), zona e tipologia.
- **Proclamatori** (assegnatari) con dati personali **cifrati a livello di colonna** nel database (AES-256-CBC tramite `APP_KEY`).
- **Assegnazioni** con data assegnazione/rientro, anno teocratico, conteggio opzionale in campagna.
- **Campagne** (periodi speciali) con percentuali di percorrenza in tempo reale.
- **Dashboard** con media mensile di percorrenza, campagna attiva, e tre liste rapide (da assegnare, prioritari, da rientrare).
- **Registro** delle assegnazioni filtrabile per anno teocratico, zona, stato.
- **Audit Log** completo di tutte le azioni utente con diff before/after.
---
## Stack tecnologico
| Componente | Tecnologia |
|------------------|-----------------------------------------------|
| **Framework** | Laravel 11.x |
| **PHP** | 8.3-FPM |
| **UI** | Livewire 3.5 + TailwindCSS 4.0 + Alpine.js |
| **Database** | MariaDB 11 |
| **Cache/Session**| Redis 7 (Alpine) |
| **Web Server** | Nginx 1.25 (Alpine) |
| **Auth** | Laravel Breeze 2.0 |
| **RBAC** | spatie/laravel-permission 6.4 |
| **Audit Trail** | spatie/laravel-activitylog 4.8 |
| **Asset Build** | Vite 6.0 + @tailwindcss/vite |
| **Node.js** | 20 LTS (nel container PHP) |
| **Mail (dev)** | Mailpit |
| **Container** | Docker Compose (5 servizi) |
---
## Requisiti di sistema
- **Docker** >= 24.0 e **Docker Compose** >= 2.20
- Oppure, senza Docker:
- PHP >= 8.3 con estensioni: pdo_mysql, mbstring, gd, intl, zip, bcmath, redis, opcache
- Composer >= 2.7
- Node.js >= 20 LTS + npm
- MariaDB >= 11 (o MySQL 8)
- Redis >= 7
---
## Installazione
### Prerequisiti
- **Docker** e **Docker Compose** (v2) installati
- Porte libere: `8080` (app), `3306` (MariaDB), `6379` (Redis), `8025` (Mailpit)
### Procedura
```bash
# 1. Clona il repository
git clone <repository-url> termanager2
cd termanager2
# 2. Configura l'ambiente
cp .env.example .env
# 3. Imposta i permessi dei file (UID 1000 = appuser nel container)
chown -R 1000:1000 .
mkdir -p storage/app/public storage/framework/{cache/data,sessions,views} storage/logs bootstrap/cache
chmod -R 775 storage bootstrap/cache
# 4. Avvia i container (build al primo avvio)
docker compose up -d --build
# 5. Installa le dipendenze PHP
docker compose exec app composer install
# 6. Installa le dipendenze Node.js
docker compose exec app npm install
# 7. Pubblica gli asset dei pacchetti Spatie
docker compose exec app php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
docker compose exec app php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations"
# 8. Esegui le migrazioni e i seeder
docker compose exec app php artisan migrate --seed
# 9. Crea il symbolic link per lo storage pubblico
docker compose exec app php artisan storage:link
# 10. Compila gli asset frontend
docker compose exec app npm run build
```
> **Nota**: La `APP_KEY` viene generata automaticamente al primo avvio se assente nel `.env`.
Al termine l'applicazione sarà disponibile su: **http://localhost:8080**
### Reset completo
Per ripartire da zero (elimina database e volumi):
```bash
docker compose down -v --remove-orphans
docker compose up -d --build
# Ripetere i passaggi dal punto 4 in poi
```
---
## Configurazione
### Variabili d'ambiente principali (`.env`)
| Variabile | Default | Descrizione |
|-----------------------|----------------------|------------------------------------------|
| `APP_KEY` | (generata) | Chiave AES-256 per cifratura. **Mai condividere** |
| `APP_PORT` | `8080` | Porta host per l'applicazione |
| `DB_DATABASE` | `termanager2` | Nome database MariaDB |
| `DB_USERNAME` | `termanager2` | Utente database |
| `DB_PASSWORD` | `secret` | Password database |
| `DB_ROOT_PASSWORD` | `rootsecret` | Password root MariaDB |
| `REDIS_PASSWORD` | `redissecret` | Password Redis |
| `MAIL_PORT` | `1025` | Porta SMTP Mailpit |
| `MAILPIT_UI_PORT` | `8025` | UI Mailpit per debug email |
| `USER_ID` / `GROUP_ID`| `1000` | UID/GID container (match con host) |
### Primo avvio — Wizard
Al primo accesso l'applicazione forza il **wizard di configurazione** in 3 step:
1. **Nome congregazione** (visualizzato in header)
2. **Soglie**: mesi priorità (default 4), giorni rientro (default 120), retention audit log (default 365 gg)
3. **Creazione utente admin** (email + password)
### Utenti di sviluppo (DevSeeder)
| Ruolo | Email | Password |
|----------------|-------------------------|------------|
| Amministratore | admin@termanager2.local | `password` |
| Assistente | assistente@termanager2.local | `password` |
| Operatore | operatore@termanager2.local | `password` |
> **Attenzione**: cambiare le password prima di qualsiasi uso in produzione.
---
## Struttura del progetto
```
TerManager2/
├── app/
│ ├── Console/Commands/ # AuditCleanup (pulizia log schedulata)
│ ├── Http/Middleware/ # SetupRequired (wizard forzato)
│ ├── Livewire/
│ │ ├── Assegnazioni/ # Assegna, Rientra
│ │ ├── Auth/ # Login
│ │ ├── Campagne/ # Index, Create, Edit, Show
│ │ ├── Proclamatori/ # Index, Create, Edit, Show, Cestino
│ │ ├── Settings/ # SettingsEdit, ZoneIndex, TipologieIndex
│ │ ├── Setup/ # Wizard (3 step)
│ │ ├── Territori/ # Index, Create, Edit, Show, Cestino
│ │ ├── AuditLog.php # Log attività (filtri, diff)
│ │ ├── Home.php # Dashboard
│ │ └── Registro.php # Registro assegnazioni
│ ├── Models/ # 8 modelli Eloquent
│ └── Providers/ # AppServiceProvider (Gate admin)
├── bootstrap/ # app.php (middleware), providers.php
├── config/ # app, auth, database, session
├── database/
│ ├── migrations/ # 10 migrazioni ordinate
│ └── seeders/ # Roles, Dev, Database seeders
├── docker/
│ ├── nginx/default.conf # Vhost con security headers
│ └── php/
│ ├── Dockerfile # PHP 8.3-FPM + estensioni + Composer + Node 20
│ └── php.ini # 64M upload, opcache, Europe/Rome
├── public/index.php # Entry point Laravel
├── resources/
│ ├── css/app.css # TailwindCSS 4 import
│ ├── js/ # app.js, bootstrap.js (Axios)
│ └── views/
│ ├── components/layouts/ # app.blade.php (sidebar), guest.blade.php
│ └── livewire/ # Tutte le viste dei componenti
├── routes/
│ ├── web.php # Tutte le rotte con permessi
│ └── console.php # Schedule audit:cleanup daily
├── .env.example # Template configurazione
├── artisan # CLI Laravel
├── composer.json # Dipendenze PHP
├── docker-compose.yml # 5 servizi (app, nginx, mariadb, redis, mailpit)
├── package.json # Dipendenze Node (Vite, TailwindCSS)
└── vite.config.js # Vite + Laravel plugin + TailwindCSS plugin
```
---
## Funzionalità principali
### Dashboard (Home)
- Anno teocratico corrente (auto-creato se mancante, Set→Ago)
- Media mensile di percorrenza (territori rientrati / mesi trascorsi)
- Campagna attiva con barra di progresso percentuale
- Tre liste rapide con azione diretta:
- **Da assegnare**: territori in reparto, ordinati per priorità
- **Prioritari**: flag manuale O soglia automatica (la soglia vince sempre)
- **Da rientrare**: assegnazioni oltre la soglia giorni configurata
### Territori
- CRUD completo con upload/sostituzione PDF
- Filtri per zona, tipologia, stato (in_reparto, assegnato, da_rientrare, inattivo)
- Badge di stato colorati + indicatore priorità (Manuale/Automatico)
- Dettaglio con PDF viewer inline e storico assegnazioni per anno teocratico
- Toggle attivo/inattivo e prioritario
- Soft delete con cestino (ripristino o eliminazione definitiva)
### Proclamatori
- CRUD con campi nome/cognome **cifrati nel DB** (AES-256-CBC)
- Ricerca in-memory (necessaria per campi cifrati, ottimizzata per < 200 record)
- Conteggio territori attualmente assegnati
- Toggle attivo/inattivo
- **Anonimizzazione GDPR** (sostituzione dati con placeholder irreversibile)
- Soft delete con cestino
### Assegnazione / Rientro
- Selezione territorio disponibile + proclamatore attivo
- Data assegnazione con auto-detection anno teocratico
- Validazione: territorio non già assegnato, entrambi attivi
- Rientro con calcolo automatico giorni
- **Prompt retroattivo campagna**: se l'assegnazione ricade nel range di una campagna, viene chiesto se conteggiare il rientro
### Campagne
- CRUD con date inizio/fine e descrizione
- Stato automatico (Attiva/Futura/Conclusa)
- Percentuale percorrenza in tempo reale (conteggiati / totali assegnati nel range)
- Dettaglio con lista territorio-per-territorio dei conteggiati
### Registro Assegnazioni
- Filtri: anno teocratico, zona, stato (aperte/chiuse), ricerca testo
- Colonne: territorio, proclamatore, date, giorni, campagna
### Audit Log
- Log automatico su tutti i modelli (spatie/laravel-activitylog)
- Log manuale per eventi di flusso (assign, return, login, logout)
- Filtri per utente, tipo evento, ricerca testo
- Dettaglio con diff before/after (properties JSON espanso)
- Pulizia automatica schedulata (`audit:cleanup` — retention configurabile)
### Impostazioni
- Nome congregazione
- Soglia mesi priorità automatica
- Soglia giorni per "da rientrare"
- Retention giorni audit log
### Zone e Tipologie
- CRUD inline (aggiungi, rinomina, attiva/disattiva, elimina)
- Protezione: non eliminabile se ha territori associati
---
## Ruoli e permessi (RBAC)
Tre ruoli con permessi granulari gestiti da **spatie/laravel-permission**:
| Permesso | Amministratore | Assistente | Operatore |
|---------------------|:--------------:|:----------:|:---------:|
| `settings.manage` | ✅ | | |
| `territori.manage` | ✅ | | |
| `proclamatori.manage`| ✅ | ✅ | |
| `campagne.manage` | ✅ | ✅ | |
| `territori.assign` | ✅ | ✅ | ✅ |
| `territori.return` | ✅ | ✅ | ✅ |
| `registro.view` | ✅ | ✅ | |
| `registro.export` | ✅ | | |
| `audit.view` | ✅ | | |
| `audit.export` | ✅ | | |
L'amministratore ha un bypass completo via `Gate::before` nel `AppServiceProvider`.
Il menu sidebar mostra solo le voci per cui l'utente ha permesso.
---
## Sicurezza e GDPR
### Sicurezza
- **Cifratura a riposo**: nome e cognome proclamatori cifrati con `encrypted` cast (AES-256-CBC via `APP_KEY`)
- **Password**: hash bcrypt (standard Laravel)
- **RBAC**: middleware `permission:` su ogni rotta
- **CSRF**: token integrato in tutti i form Livewire
- **Rate limiting**: login con throttle configurabile
- **Security headers**: X-Frame-Options, X-Content-Type-Options, X-XSS-Protection (via Nginx)
- **Sessioni**: Redis con cookie `termanager2_session` (httpOnly, secure in produzione)
### GDPR
- **Minimizzazione**: solo nome, cognome e stato attivo per i proclamatori
- **Cifratura a riposo**: dati illeggibili con accesso diretto al DB
- **Audit trail**: log completo di tutte le operazioni sui dati
- **Diritto all'oblio**: metodo `anonimizza()` sul Proclamatore (sostituisce dati con placeholder)
- **Retention**: pulizia automatica dei log audit oltre il periodo configurato
- **Ruoli e autorizzazioni**: accesso ai dati solo per chi ha permessi specifici
- **No log di dati sensibili**: l'audit log registra `proclamatore_id` ma non nome/cognome in chiaro nei `properties`
---
## Modello dati
### Tabelle principali (10 migrazioni)
| Tabella | Descrizione |
|-------------------|--------------------------------------------------------------|
| `users` | Utenti app con auth standard Laravel |
| `settings` | Configurazione singleton (congregazione, soglie, retention) |
| `zone` | Zone territoriali (id, nome, attivo) |
| `tipologie` | Tipologie territorio (id, nome, attivo) |
| `proclamatori` | Assegnatari con nome/cognome cifrati, soft delete |
| `territori` | Cartoline con zona, tipologia, PDF, soft delete |
| `anni_teocratici` | Periodi Set→Ago con label univoca |
| `campagne` | Periodi speciali con date e descrizione |
| `assegnazioni` | Join territorio↔proclamatore con date, anno, campagna |
| `activity_log` | Audit trail (spatie/laravel-activitylog) |
### Indici e vincoli
- `territori.numero` — UNIQUE
- `anni_teocratici.label` — UNIQUE
- `assegnazioni(territorio_id, returned_at)` — INDEX composito
- `assegnazioni(proclamatore_id, returned_at)` — INDEX composito
- `assegnazioni(anno_teocratico_id)` — INDEX
- FK con `RESTRICT` su territorio e proclamatore (protezione integrità)
---
## Regole di business
### Anno teocratico
- **Settembre → Agosto**: se mese ≥ 9 → anno = `corrente/corrente+1`; se mese ≤ 8 → anno = `corrente-1/corrente`
- Creato automaticamente se non esiste (metodo `AnnoTeocratico::perData()`)
- L'assegnazione conta per l'anno determinato dalla **data di assegnazione**
### Stato territorio (calcolato dinamicamente)
- **in_reparto**: attivo, nessuna assegnazione aperta
- **assegnato**: ha assegnazione con `returned_at = null`
- **da_rientrare**: assegnato da più giorni della soglia configurata
- **inattivo**: `attivo = false`
### Priorità
- **Manuale**: flag `prioritario = true` impostato dall'admin
- **Automatica**: nessuna assegnazione negli ultimi N mesi (soglia configurabile)
- La soglia automatica **vince sempre** sul flag manuale (l'admin può aggiungere ma non rimuovere se la soglia scatta)
### Campagne — percentuale percorrenza
- **Denominatore**: tutti i territori assegnati nel range della campagna
- **Numeratore**: assegnazioni conteggiate in campagna (`counted_in_campaign = true`)
- Il prompt retroattivo appare al rientro se l'assegnazione è nel range di una campagna
---
## Comandi utili
```bash
# Avviare i container
docker compose up -d
# Fermare i container
docker compose down
# Shell nel container app
docker compose exec app bash
# Eseguire migrazioni
docker compose exec app php artisan migrate
# Seed dati di sviluppo
docker compose exec app php artisan db:seed
# Compilare asset (dev con hot reload)
docker compose exec app npm run dev
# Compilare asset (produzione)
docker compose exec app npm run build
# Pulizia manuale audit log
docker compose exec app php artisan audit:cleanup
# Cache configurazione (produzione)
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
# Svuotare cache
docker compose exec app php artisan optimize:clear
```
---
## Produzione
Per il deploy in produzione:
1. **Variabili d'ambiente**:
- `APP_ENV=production`
- `APP_DEBUG=false`
- Password sicure per DB e Redis (non i default)
- `APP_KEY` generata e conservata come secret
2. **HTTPS**: configurare un reverse proxy (Traefik, Nginx) con certificato SSL/TLS e HSTS
3. **Immagini Docker**: build senza volume codice montato, asset pre-compilati
4. **Backup**: backup cifrati del database con rotazione automatica
5. **Segreti**: gestire `APP_KEY`, password DB/Redis con Docker secrets o secret manager
6. **Performance**:
- `php artisan config:cache && route:cache && view:cache`
- OPcache abilitato (già configurato in `php.ini`)
- Redis per cache, sessioni e code
7. **Monitoraggio**: configurare health checks e log aggregation
---
## Licenza
Progetto privato — tutti i diritti riservati.

640
TerManager2_v2.md Normal file
View File

@@ -0,0 +1,640 @@
# TerManager2 — Specifiche funzionali + istruzioni per container (Laravel)
> Documento operativo per realizzare **TerManager2**, applicazione Laravel per la gestione dellassegnazione/rientro di cartoline territorio, statistiche di percorrenza e campagne speciali.
>
> **Obiettivo del documento**: chiarire requisiti, proporre unarchitettura “container-ready”, definire modello dati e regole di calcolo, e fornire una **scomposizione in sottotask** con checklist di controllo a fine attività.
---
## 1) Visione dinsieme
**TerManager2** gestisce:
- **Territori** (cartoline) con PDF, stato (in reparto / assegnato / da rientrare / prioritario / inattivo) e proprietà (Zona, Tipologia, ecc.).
- **Proclamatori** (assegnatari) con dati **illeggibili in DB** (cifratura applicativa a livello di colonna) e gestione **GDPR**.
- **Assegnazioni** con data assegnazione/rientro, teocratic year, opzionale “conteggio campagna”.
- **Campagne** (periodi speciali) con percentuali di percorrenza e dashboard.
- **Statistiche** (media mensile di percorrenza nellanno teocratico, indicatori di giacenza e “smarriti”).
**Ruoli (RBAC)**:
- **Amministratore**: tutto (impostazioni, territori, proclamatori, campagne, registro, proprietà territorio, assegnazione/rientro).
- **Assistente**: proclamatori, campagne, assegnazione/rientro.
- **Operatore**: assegnazione/rientro.
---
## 2) Requisiti non funzionali (sicurezza, GDPR, UX)
### 2.1 Sicurezza e dati illeggibili in DB
Requisito: i dati dei proclamatori devono risultare **illeggibili** se si accede direttamente al database.
**Scelta consigliata (Laravel)**:
- Password utente: **hash** (bcrypt/argon2id) (standard Laravel).
- Dati sensibili (nome, cognome, eventuali note): **cifratura applicativa per colonna**.
- Usare **Laravel Eloquent encrypted casts** (es. `protected $casts = ['nome' => 'encrypted', ...];`) oppure un **Custom Cast** che cifri/decifri con AES-256-GCM.
- La chiave di cifratura è `APP_KEY` (gestita come secret in ambiente). **Mai** versionarla.
**Nota importante**: la cifratura applicativa protegge “a riposo” nel DB. Per un livello ulteriore:
- cifrare anche **backup**;
- considerare cifratura volume/disco a livello host.
### 2.2 GDPR — criteri base da rispettare
Implementare almeno:
- **Minimizzazione**: salvare solo ciò che serve (nome/cognome e stato attivo).
- **Ruoli e autorizzazioni**: accesso ai dati solo per chi ha permessi.
- **Audit trail**: log di operazioni critiche (creazione/modifica/eliminazione, assegnazione/rientro, cambio stato territorio).
- **Retention**: definire policy su conservazione storici (es. registro assegnazioni conservato per X anni — parametro impostazioni).
- **Diritto alloblio**: possibilità di **disattivare/anonimizzare** un proclamatore (senza rompere lo storico).
- **Esportazione**: esportazione dati (CSV/PDF) su richiesta (opzionale ma consigliato).
- **Consenso e informativa**: pagina “Privacy / Informativa” e log di accettazione (se necessario al contesto).
### 2.3 UI/UX
- Responsive “mobile-first” (smartphone/tablet), grafica minimale e moderna.
- Layout con:
- header con logo + congregazione;
- menu laterale (collassabile su mobile);
- contenuti centrali con card e tabelle filtrabili.
Tecnologie UI consigliate:
- **TailwindCSS** + **Livewire** (rapido, server-driven) **oppure** Inertia + Vue/React.
- Componenti: ricerca veloce, paginazione, badge di stato, drawer per dettagli.
---
## 3) Architettura applicativa (Laravel)
### 3.1 Componenti
- Laravel (>=10) con:
- Auth (Breeze/Jetstream o Fortify) + **RBAC** (Spatie Laravel Permission consigliato).
- DB: MySQL/MariaDB o PostgreSQL.
- Storage: `storage/app` per PDF (in dev) + volume Docker; in produzione valutare S3/MinIO.
- Queue: Redis (opzionale ma utile per report/esportazioni).
- Scheduler: `artisan schedule:work` per job (es. calcoli, pulizie, report).
### 3.2 Concetti chiave di dominio
- **Anno teocratico**: da **Settembre** (anno precedente) ad **Agosto** (anno corrente).
- **Percorrenza**: giorni tra assegnazione e rientro (nellanno teocratico corrente) aggregati e convertiti in media mensile.
- **Campagna**: periodo con start/end; a ogni rientro si chiede se quel rientro conta per la campagna (anche retroattivo se assegnato durante il periodo ma rientrato dopo).
---
## 4) Modello dati (proposta)
> I nomi campi sono indicativi; adeguare naming (snake_case) e indici.
### 4.1 Tabelle
#### `users`
- auth utenti app (admin/assistente/operatore)
- campi standard Laravel (`email`, `password`, `name`, ecc.).
#### `roles`, `permissions`, `model_has_roles`, ...
- se si usa Spatie Permission.
#### `settings`
- configurazioni globali:
- `congregazione_nome`
- soglia `giorni_giacenza_da_assegnare`
- soglia `giorni_giacenza_prioritari`
- soglia `giorni_per_smarrito`
- `home_limit_list` (default 10)
- altre.
#### `proclamatori`
- `id`
- `nome` **encrypted**
- `cognome` **encrypted**
- `attivo` boolean
- timestamps
> (Opzionale) `display_name_hash` per ricerche rapide senza decifrare tutto (hash normalizzato di nome+cognome). Attenzione: lhash può comunque essere dato personale; trattarlo come tale.
#### `territori`
- `id`
- `numero` (string/int) **univoco**
- `zona_id`
- `tipologia_id`
- `note` (text)
- `confini` (text)
- `pdf_path` (string)
- `attivo` boolean
- `prioritario` boolean
- timestamps
#### `zone` e `tipologie`
- `id`, `nome`, `attivo`.
#### `anni_teocratici`
- `id`
- `label` (es. `2025-2026`)
- `start_date` (1 settembre)
- `end_date` (31 agosto)
- univoco per `label`.
#### `campagne`
- `id`
- `start_date`
- `end_date`
- `descrizione`
- `attiva` (derivabile da date, ma può essere utile una view/flag)
- timestamps
#### `assegnazioni`
- `id`
- `territorio_id`
- `proclamatore_id`
- `anno_teocratico_id`
- `assigned_at`
- `returned_at` (nullable)
- `counted_in_campaign` boolean nullable (null = non chiesto/non applicabile)
- `campaign_id` nullable (se conteggiata)
- `note` nullable
- `created_by` (user_id)
- `returned_by` (user_id) nullable
- timestamps
#### `audit_logs` (o `activity_log`) — per pagina Log attività
- tabella dedicata a tracciare azioni utenti (vedi §6.7)
- se si usa un pacchetto, mantenere naming/colonne del pacchetto; altrimenti creare tabella custom.
### 4.2 Vincoli e indici (essenziali)
- `territori.numero` unique
- `assegnazioni`:
- indice su (`territorio_id`, `returned_at`)
- indice su (`proclamatore_id`, `returned_at`)
- indice su (`anno_teocratico_id`)
- integrità referenziale con `ON DELETE CASCADE` dove sensato.
- **Attenzione**: requisito: eliminazione territorio => eliminazione su tutto il registro assegnazioni. Quindi `assegnazioni.territorio_id` con cascade.
---
## 5) Regole di business e calcoli
### 5.1 Anno teocratico (creazione automatica)
- Allavvio Home o in fase di assegnazione/rientro:
1. determinare data odierna;
2. se mese >= 9 (settembre) -> anno teocratico = `anno_corrente-anno_corrente+1`;
3. se mese <= 8 -> anno teocratico = `anno_corrente-1-anno_corrente`;
4. se non esiste in DB, crearlo con start_date=1/09 e end_date=31/08.
### 5.2 Stato territorio
- **In reparto**: nessuna assegnazione aperta (ultima assegnazione `returned_at` non null) e attivo.
- **Assegnato a X**: esiste assegnazione aperta (`returned_at` null).
- **Smarrito / da rientrare**: assegnazione aperta da più giorni rispetto a `settings.giorni_per_smarrito`.
- **Prioritario**: flag `prioritario=true` oppure giacenza in reparto > soglia `giorni_giacenza_prioritari`.
- **Inattivo**: `attivo=false` (non assegnabile).
### 5.3 “Territori da assegnare” (Home)
- Territori **in reparto** con giacenza > `giorni_giacenza_da_assegnare`.
- Mostrare primi N (`home_limit_list`).
- Azione: assegnazione rapida a proclamatore.
### 5.4 “Territori prioritari” (Home)
- Territori in reparto con giacenza > `giorni_giacenza_prioritari` **oppure** `prioritario=true`.
- Azione: assegnazione rapida.
### 5.5 “Territori da rientrare” (Home)
- Territori con assegnazione aperta da più giorni di `giorni_per_smarrito`.
- Azione: rientro rapido (sullattuale assegnatario).
### 5.6 Media mensile di percorrenza (Home)
Definizione richiesta: media mensile di percorrenza di tutti i territori nellanno teocratico corrente.
Interpretazione operativa (coerente con testo):
- Per ogni **assegnazione chiusa** (returned_at valorizzato) nellanno teocratico corrente:
- `giorni = returned_at - assigned_at` (in giorni, >=0)
- Sommare tutti i `giorni` e normalizzare su base mensile.
Proposta formula (trasparente e verificabile):
- `giorni_totali = Σ giorni` per tutte le assegnazioni chiuse dellanno teocratico corrente
- `mesi_trascorsi = numero mesi dallinizio anno teocratico a oggi (min 1)`
- `media_mensile = giorni_totali / mesi_trascorsi`
> Alternativa: media per territorio o per assegnazione; scegliere una e documentarla in impostazioni. Limportante è che sia consistente e testata.
### 5.7 Campagne (Home + Tab Campagne)
- Campagna attiva se `today` tra `start_date` e `end_date`.
- Percentuale percorrenza campagna:
- `territori_percorsi_in_campagna / territori_totali_pertinenti`.
Chiarimento necessario per “totali pertinenti”: suggerito:
- denominatore = numero di territori rientrati **conteggiati campagna** durante il periodo **+** territori assegnati nel periodo ma non ancora rientrati? (da definire).
Per evitare ambiguità e mantenere semplice:
- **Proposta**: percentuale = (numero assegnazioni chiuse conteggiate campagna) / (numero assegnazioni chiuse totali che hanno `assigned_at` nel range campagna) * 100.
#### Avviso rientro “con o senza campagna” (retroattivo)
Alla chiusura di unassegnazione:
- se esiste campagna tale che `assigned_at` è compreso nel range campagna **(anche se oggi è dopo end_date)**, mostrare prompt:
- “Questo rientro conta per la campagna X?”
- se sì: `counted_in_campaign=true` e `campaign_id`.
- se no: `counted_in_campaign=false`.
- se non applicabile: lasciare null.
#### Vista “territori percorsi e in quanto tempo”
Per campagna selezionata:
- lista assegnazioni conteggiate in campagna con:
- territorio, proclamatore (mostrato in chiaro solo in UI), assigned_at, returned_at, giorni.
---
## 6) Pagine e funzionalità (corrette/riordinate)
### 6.1 Wizard iniziale (primo avvio)
Se mancano impostazioni base (`settings` vuoto):
- Step 1: Nome congregazione + logo (upload).
- Step 2: soglie (giacenza, prioritari, smarrito) + limite liste Home.
- Step 3: conferma e creazione admin (se necessario).
### 6.2 Home
Header:
- sinistra: logo + nome congregazione
- destra: nome utente + ruolo + logout
Sidebar:
- voci variabili in base ai permessi.
Contenuto centrale:
1) **Anno teocratico in corso** (auto-creazione se non esiste)
2) **Media mensile di percorrenza** (anno teocratico corrente)
3) **Campagna in corso** (se attiva): descrizione + percentuale + pulsante “Dettagli in tempo reale”
4) **Assegnazione rapida** (3 colonne/zone):
- Territori da assegnare
- Territori prioritari
- Territori da rientrare
Ogni lista:
- mostra top N
- ricerca veloce
- pulsante “Vedi tutti” (pagina completa con filtri)
### 6.3 Gestione Territori
Lista filtrabile con colonne:
- Numero
- Zona / Tipologia
- Stato (badge)
- Ultimo rientro
- Azioni: Visualizza / Modifica / Elimina / Attiva-Disattiva
Dettaglio territorio (“Visualizza”):
- dati completi + PDF viewer
- storico assegnazioni raggruppato per anno teocratico
Regole:
- Eliminazione territorio => rimuove anche tutte le assegnazioni (cascade).
- Territorio inattivo non è assegnabile e non appare nelle liste “da assegnare/prioritari” (a meno di filtro admin).
### 6.4 Gestione Proclamatori
Lista filtrabile:
- Proclamatore (nome/cognome decifrati in UI)
- Attivo
- Territori attualmente in assegnazione (conteggio + link)
- Azioni: Visualizza / Modifica / Elimina / Disattiva
Regole consigliate:
- Eliminazione fisica solo se nessuno storico (oppure prevedere “anonimizza”).
### 6.5 Gestione Campagne
- Form creazione campagna (start, end, descrizione)
- Evidenza campagna attiva
- Elenco campagne completate (indipendente dallanno teocratico)
- Dettaglio: elenco assegnazioni conteggiate, tempi, export.
### 6.6 Registro assegnazioni
Lista filtrabile per:
- anno teocratico
- territorio
- zona
- tipologia
- proclamatore
Output:
- tabella con assegnazioni (assegnato, rientrato, giorni, campagna sì/no)
- export CSV/PDF (opzionale)
### 6.7 Log attività (Audit trail) — **REQUISITO OBBLIGATORIO**
> Deve essere disponibile una **pagina di log** che riporti **tutte le azioni degli utenti** (Amministratore, Assistente, Operatore) e in particolare le operazioni di:
> - **Assegnazione** territorio
> - **Rientro** territorio (con/senza campagna)
> - **Creazione / modifica / cancellazione** di: Territori, Proclamatori, Campagne, Zone, Tipologie, Impostazioni
> - **Attiva/Disattiva** territorio o proclamatore
> - **Upload / sostituzione / eliminazione** PDF territorio
> - (Consigliato) **Login/Logout**, tentativi falliti, export dati
#### 6.7.1 Scopo
- Tracciabilità e accountability (chi ha fatto cosa e quando).
- Supporto a sicurezza e requisiti GDPR (audit delle operazioni sui dati).
#### 6.7.2 Permessi di accesso alla pagina log
- **Visualizzazione log**: `audit.view` (almeno Amministratore).
- Opzionale: consentire anche ad Assistente una vista “limitata” (solo eventi di assegnazione/rientro e senza IP/user-agent).
- **Esportazione log**: `audit.export` (solo Amministratore).
#### 6.7.3 Dati minimi da registrare per ogni evento
Registrare per ogni azione:
- `id`
- `created_at` (data/ora evento)
- `causer_user_id` (utente che ha eseguito)
- `causer_role_snapshot` (ruolo al momento dellazione)
- `event` (es. `created`, `updated`, `deleted`, `assigned`, `returned`, `activated`, `deactivated`, `uploaded_pdf`)
- `subject_type` + `subject_id` (entità coinvolta: Territorio/Proclamatore/Campagna/Assegnazione/Setting...)
- `description` (testo leggibile)
- `properties` (JSON) con dettagli evento:
- **prima/dopo** per update (diff campi)
- motivazioni/flag (es. `counted_in_campaign=true`)
- valori di soglia usati in calcoli/decisioni (se rilevanti)
- (Consigliato) `ip_address` e `user_agent` (dato personale → retention e minimizzazione)
> Nota GDPR: retention configurabile (es. 365/730 giorni). Evitare di salvare nei log dati sensibili in chiaro non necessari: per Proclamatori salvare `proclamatore_id` e non nome/cognome.
#### 6.7.4 Implementazione consigliata (senza vincoli)
- È possibile implementare il log con tabella custom (`audit_logs`) oppure usare un pacchetto dedicato.
- Scelta spesso adottata in Laravel: **spatie/laravel-activitylog** per logging automatico su modelli + logging manuale per eventi “di flusso” (assegnazione/rientro).
#### 6.7.5 UI pagina Log
Funzioni minime:
- Tabella: Data/Ora, Utente, Ruolo, Azione, Oggetto, Esito, Dettagli.
- Filtri: date, utente, ruolo, tipo evento, entità.
- Dettaglio evento (drawer/modal): diff before/after + metadati.
- Paginazione + ricerca veloce.
---
## 7) Containerizzazione (Docker) — sviluppo e produzione
### 7.1 Struttura consigliata repository
```
termanager2/
docker/
nginx/
default.conf
php/
Dockerfile
php.ini
.env.example
docker-compose.yml
composer.json
package.json
...
```
> In dev si può usare `npm run dev` in un container separato o sul host.
### 7.6 Produzione (note rapide)
- Impostare `APP_ENV=production`, `APP_DEBUG=false`.
- Usare immagini “buildate” senza montare volume codice.
- Gestire segreti con Docker secrets o secret manager.
- Abilitare HTTPS con reverse proxy (Traefik/Nginx) e HSTS.
- Backup cifrati e rotazione.
---
## 8) RBAC: permessi e gating UI
Definire permessi granulari (anche se i ruoli sono solo 3):
- `settings.manage`
- `proclamatori.manage`
- `territori.manage`
- `campagne.manage`
- `registro.view`
- `territori.assign`
- `territori.return`
- `audit.view`
- `audit.export` (opzionale)
Mappatura ruoli:
- Admin: tutti (incluso `audit.view` e opzionale `audit.export`)
- Assistente: proclamatori.manage, campagne.manage, territori.assign, territori.return
- Operatore: territori.assign, territori.return
Applicare:
- Middleware route (`can:` / `role:` / `permission:`)
- Condizionali menu sidebar
## 8-bis) API routes / schermate (URL, metodi, middleware)
> Nota: di seguito sono indicate **route Web** (UI) e (opzionalmente) **route API**. Anche se lapp nasce “web”, formalizzare le route aiuta a:
> - applicare correttamente `auth` + permessi,
> - mantenere coerenza tra schermate e controller,
> - facilitare test e manutenzione.
### 8-bis.1 Convenzioni middleware
- `auth` su tutte le rotte applicative.
- `permission:<permesso>` (o `can:<permesso>`) per schermate/azioni.
- `throttle` (consigliato) su login e su endpoint di export.
### 8-bis.2 Mappa schermate principali
- **Home/Dashboard**: `/` (GET)
- **Wizard iniziale**: `/setup/*` (GET/POST)
- **Territori**: `/territori` (lista), `/territori/create`, `/territori/{id}` (dettaglio), `/territori/{id}/edit`
- **Proclamatori**: `/proclamatori` + CRUD
- **Campagne**: `/campagne` + CRUD + dettaglio
- **Registro**: `/registro` (filtri)
- **Audit log**: `/audit` (lista) + `/audit/{id}` (dettaglio)
### 8-bis.3 Route Web (UI) — dettaglio
#### Autenticazione
- `GET /login` → form login | middleware: `guest`
- `POST /login` → login | middleware: `guest`, `throttle:login`
- `POST /logout` → logout | middleware: `auth`
#### Wizard iniziale (primo avvio)
- `GET /setup` → step 1 | middleware: `setup.required`
- `POST /setup/step-1` → salva congregazione | middleware: `setup.required`
- `POST /setup/step-2` → salva soglie | middleware: `setup.required`
- `POST /setup/finish` → completa setup | middleware: `setup.required`
> `setup.required` è un middleware custom che intercetta lassenza di settings e forza il wizard.
#### Home
- `GET /` → dashboard | middleware: `auth`
#### Territori
- `GET /territori` → lista | middleware: `auth`, `permission:territori.manage`
- `GET /territori/create` → form create | middleware: `auth`, `permission:territori.manage`
- `POST /territori` → store | middleware: `auth`, `permission:territori.manage`
- `GET /territori/{territorio}` → dettaglio | middleware: `auth`, `permission:territori.manage`
- `GET /territori/{territorio}/edit` → form edit | middleware: `auth`, `permission:territori.manage`
- `PUT /territori/{territorio}` → update | middleware: `auth`, `permission:territori.manage`
- `DELETE /territori/{territorio}` → delete (cascade assegnazioni) | middleware: `auth`, `permission:territori.manage`
- `PATCH /territori/{territorio}/toggle-active` → attiva/disattiva | middleware: `auth`, `permission:territori.manage`
- `PATCH /territori/{territorio}/toggle-priority` → prioritario sì/no | middleware: `auth`, `permission:territori.manage`
- `POST /territori/{territorio}/pdf` → upload/sostituzione PDF | middleware: `auth`, `permission:territori.manage`
- `DELETE /territori/{territorio}/pdf` → rimuovi PDF | middleware: `auth`, `permission:territori.manage`
#### Proclamatori
- `GET /proclamatori` → lista | middleware: `auth`, `permission:proclamatori.manage`
- `GET /proclamatori/create` → form create | middleware: `auth`, `permission:proclamatori.manage`
- `POST /proclamatori` → store | middleware: `auth`, `permission:proclamatori.manage`
- `GET /proclamatori/{proclamatore}` → dettaglio | middleware: `auth`, `permission:proclamatori.manage`
- `GET /proclamatori/{proclamatore}/edit` → form edit | middleware: `auth`, `permission:proclamatori.manage`
- `PUT /proclamatori/{proclamatore}` → update | middleware: `auth`, `permission:proclamatori.manage`
- `DELETE /proclamatori/{proclamatore}` → delete/anonimizza (policy) | middleware: `auth`, `permission:proclamatori.manage`
- `PATCH /proclamatori/{proclamatore}/toggle-active` → attiva/disattiva | middleware: `auth`, `permission:proclamatori.manage`
#### Campagne
- `GET /campagne` → lista + campagna attiva | middleware: `auth`, `permission:campagne.manage`
- `GET /campagne/create` → form create | middleware: `auth`, `permission:campagne.manage`
- `POST /campagne` → store | middleware: `auth`, `permission:campagne.manage`
- `GET /campagne/{campagna}` → dettaglio + tempi real-time | middleware: `auth`, `permission:campagne.manage`
- `GET /campagne/{campagna}/edit` → form edit | middleware: `auth`, `permission:campagne.manage`
- `PUT /campagne/{campagna}` → update | middleware: `auth`, `permission:campagne.manage`
- `DELETE /campagne/{campagna}` → delete | middleware: `auth`, `permission:campagne.manage`
#### Assegnazioni (azioni core)
- `POST /assegnazioni/assign` → assegna territorio a proclamatore | middleware: `auth`, `permission:territori.assign`
- `POST /assegnazioni/{assegnazione}/return` → rientro territorio | middleware: `auth`, `permission:territori.return`
> Nota: `assign` e `return` devono sempre scrivere su **audit log**.
#### Registro
- `GET /registro` → lista filtrabile | middleware: `auth`, `permission:registro.view`
- (Opzionale) `GET /registro/export` → export | middleware: `auth`, `permission:registro.export`
#### Audit log
- `GET /audit` → lista log | middleware: `auth`, `permission:audit.view`
- `GET /audit/{log}` → dettaglio log | middleware: `auth`, `permission:audit.view`
- (Opzionale) `GET /audit/export` → export | middleware: `auth`, `permission:audit.export`
### 8-bis.4 Route API (opzionali, se si usa UI “dinamica”)
> Se si adotta Livewire spesso bastano le Web route. Se si usa SPA (Inertia/Vue/React) o si vuole un frontend più interattivo, prevedere API JSON:
- `GET /api/territori?filters=...` | `auth:sanctum`, `permission:territori.manage`
- `GET /api/home/quicklists` | `auth:sanctum`
- `POST /api/assegnazioni/assign` | `auth:sanctum`, `permission:territori.assign`
- `POST /api/assegnazioni/{id}/return` | `auth:sanctum`, `permission:territori.return`
- `GET /api/campagne/{id}/realtime` | `auth:sanctum`, `permission:campagne.manage`
---
## 9) Checklist di funzionalità (con sottotask e ricontrollo)
> Regola di lavoro: **ogni funzionalità va spezzata in sottotask**, ognuno con test + verifica UI. A completamento, ricontrollare lintera funzionalità end-to-end.
### EPIC A — Setup progetto e base security
**Sottotask**
1. Creazione progetto Laravel + repo + CI base (lint/test).
2. Setup Auth (login/logout) + password policy.
3. Setup RBAC (ruoli/permessi) + seed iniziale admin.
4. Setup cifratura colonne proclamatori + test round-trip.
5. Logging audit (azioni critiche) + retention log.
**Ricontrollo (DoD)**
- Login funziona, utenti e ruoli corretti.
- Dati proclamatori illeggibili in DB (verifica diretta tabella).
- Rotte protette da permessi.
### EPIC B — Wizard iniziale + Settings
**Sottotask**
1. Model/settings + migrazione.
2. Wizard UI 3 step + validazione.
3. Blocco app finché wizard non completato (redirect).
**Ricontrollo**
- Primo avvio porta al wizard.
- Home mostra logo/nome congregazione.
### EPIC C — Gestione Territori
**Sottotask**
1. Tabelle territori/zone/tipologie + seed.
2. CRUD territori + upload PDF.
3. Lista filtrabile + badge di stato.
4. Dettaglio con PDF viewer + storico per anno teocratico.
5. Attiva/disattiva + prioritario.
6. Delete con cascade su assegnazioni.
**Ricontrollo**
- Stati corretti e coerenti.
- PDF visibile e scaricabile.
- Eliminazione rimuove storico come richiesto.
### EPIC D — Gestione Proclamatori
**Sottotask**
1. CRUD proclamatori (encrypted fields).
2. Lista con filtro + indicazione territori assegnati.
3. Disattivazione / (opzionale) anonimizzazione.
**Ricontrollo**
- Dati illeggibili in DB.
- Non assegnabile se attivo=false.
### EPIC E — Assegnazione e rientro (core)
**Sottotask**
1. Flusso assegnazione: selezione territorio + proclamatore + conferma.
2. Prevenire doppia assegnazione (vincolo logico).
3. Flusso rientro: chiusura assegnazione + calcolo giorni.
4. Prompt campagna (retroattivo) al rientro.
5. Audit: created_by/returned_by.
**Ricontrollo**
- Un territorio non può avere due assegnazioni aperte.
- Prompt campagna compare quando dovuto.
### EPIC F — Home Dashboard
**Sottotask**
1. Calcolo anno teocratico corrente + autocreazione.
2. Calcolo media mensile percorrenza.
3. Liste rapide (da assegnare/prioritari/da rientrare) con filtri e “vedi tutti”.
4. UI responsive.
**Ricontrollo**
- Numeri coerenti con registro.
- Liste mostrano gli stessi risultati delle pagine complete.
### EPIC G — Campagne
**Sottotask**
1. CRUD campagne.
2. Indicazione campagna attiva.
3. Percentuale e dettaglio “in tempo reale”.
4. Lista campagne completate.
**Ricontrollo**
- Retroattività corretta.
- Dettaglio mostra tempi e territori.
### EPIC H — Registro e statistiche
**Sottotask**
1. Vista registro per territorio con filtri richiesti.
2. Export (opzionale).
3. Test prestazioni su query con indici.
**Ricontrollo**
- Filtri corretti.
- Dati coerenti con assegnazioni.
### EPIC I — Audit Log (pagina log e tracciamento eventi)
**Sottotask**
1. Definizione eventi da loggare (CRUD + flussi assegnazione/rientro + login/logout opzionale).
2. Implementazione persistenza log (tabella custom o activity log) + migrazioni.
3. Hook automatici su modelli (created/updated/deleted) e log manuali per `assign/return`.
4. Pagina `/audit` con filtri, paginazione, dettaglio evento (before/after).
5. RBAC: `audit.view` e (opzionale) `audit.export`.
6. Retention configurabile + job scheduler (opzionale) per pulizia log.
**Ricontrollo**
- Ogni operazione critica genera un record log.
- Il log non espone dati sensibili non necessari (es. nomi proclamatori in chiaro nei properties).
- Filtri e dettaglio mostrano correttamente chi/cosa/quando e diff.
---
## 10) Test consigliati
- **Unit test**: calcolo anno teocratico, stati territorio, logica campagne.
- **Feature test**: RBAC per pagina, assegnazione/rientro, wizard.
- **Security test**: accesso non autorizzato, CSRF, rate limit login.
- **UX test**: mobile viewport, tabelle filtrabili, azioni rapide.
- **Audit test**: ogni create/update/delete e assign/return produce log con subject/causer corretti.
---
## 11) “Done” globale (criteri di completamento)
- Tutte le funzionalità richieste presenti e testate.
- Responsività verificata su smartphone/tablet.
- Dati proclamatori cifrati e illeggibili in DB.
- Log operazioni critiche + policy di retention.
- Backup e gestione segreti definiti (per produzione).
---
### Allegato — Note di chiarezza (minime)
- La formula di “media mensile” e la definizione di denominatore percentuale campagna vanno **documentate** e rese consistenti con i test.
- La cancellazione territorio con cascade è conforme al requisito, ma valutare impatto GDPR/contabile: in alternativa soft-delete + “nascondi dallo storico”.

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;
});
}
}

16
artisan Normal file
View File

@@ -0,0 +1,16 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
$kernel->terminate($input, $status);
exit($status);

27
bootstrap/app.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'setup.required' => \App\Http\Middleware\SetupRequired::class,
]);
$middleware->web(append: [
\App\Http\Middleware\SetupRequired::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

80
bootstrap/cache/packages.php vendored Executable file
View File

@@ -0,0 +1,80 @@
<?php return array (
'laravel/breeze' =>
array (
'providers' =>
array (
0 => 'Laravel\\Breeze\\BreezeServiceProvider',
),
),
'laravel/sail' =>
array (
'providers' =>
array (
0 => 'Laravel\\Sail\\SailServiceProvider',
),
),
'laravel/tinker' =>
array (
'providers' =>
array (
0 => 'Laravel\\Tinker\\TinkerServiceProvider',
),
),
'livewire/livewire' =>
array (
'aliases' =>
array (
'Livewire' => 'Livewire\\Livewire',
),
'providers' =>
array (
0 => 'Livewire\\LivewireServiceProvider',
),
),
'nesbot/carbon' =>
array (
'providers' =>
array (
0 => 'Carbon\\Laravel\\ServiceProvider',
),
),
'nunomaduro/collision' =>
array (
'providers' =>
array (
0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
),
),
'nunomaduro/termwind' =>
array (
'providers' =>
array (
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
),
),
'spatie/laravel-activitylog' =>
array (
'providers' =>
array (
0 => 'Spatie\\Activitylog\\ActivitylogServiceProvider',
),
),
'spatie/laravel-ignition' =>
array (
'aliases' =>
array (
'Flare' => 'Spatie\\LaravelIgnition\\Facades\\Flare',
),
'providers' =>
array (
0 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
),
),
'spatie/laravel-permission' =>
array (
'providers' =>
array (
0 => 'Spatie\\Permission\\PermissionServiceProvider',
),
),
);

267
bootstrap/cache/services.php vendored Executable file
View File

@@ -0,0 +1,267 @@
<?php return array (
'providers' =>
array (
0 => 'Illuminate\\Auth\\AuthServiceProvider',
1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
2 => 'Illuminate\\Bus\\BusServiceProvider',
3 => 'Illuminate\\Cache\\CacheServiceProvider',
4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
5 => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
6 => 'Illuminate\\Cookie\\CookieServiceProvider',
7 => 'Illuminate\\Database\\DatabaseServiceProvider',
8 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
9 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
10 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
11 => 'Illuminate\\Hashing\\HashServiceProvider',
12 => 'Illuminate\\Mail\\MailServiceProvider',
13 => 'Illuminate\\Notifications\\NotificationServiceProvider',
14 => 'Illuminate\\Pagination\\PaginationServiceProvider',
15 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
16 => 'Illuminate\\Pipeline\\PipelineServiceProvider',
17 => 'Illuminate\\Queue\\QueueServiceProvider',
18 => 'Illuminate\\Redis\\RedisServiceProvider',
19 => 'Illuminate\\Session\\SessionServiceProvider',
20 => 'Illuminate\\Translation\\TranslationServiceProvider',
21 => 'Illuminate\\Validation\\ValidationServiceProvider',
22 => 'Illuminate\\View\\ViewServiceProvider',
23 => 'Laravel\\Breeze\\BreezeServiceProvider',
24 => 'Laravel\\Sail\\SailServiceProvider',
25 => 'Laravel\\Tinker\\TinkerServiceProvider',
26 => 'Livewire\\LivewireServiceProvider',
27 => 'Carbon\\Laravel\\ServiceProvider',
28 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
29 => 'Termwind\\Laravel\\TermwindServiceProvider',
30 => 'Spatie\\Activitylog\\ActivitylogServiceProvider',
31 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
32 => 'Spatie\\Permission\\PermissionServiceProvider',
33 => 'App\\Providers\\AppServiceProvider',
),
'eager' =>
array (
0 => 'Illuminate\\Auth\\AuthServiceProvider',
1 => 'Illuminate\\Cookie\\CookieServiceProvider',
2 => 'Illuminate\\Database\\DatabaseServiceProvider',
3 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
6 => 'Illuminate\\Notifications\\NotificationServiceProvider',
7 => 'Illuminate\\Pagination\\PaginationServiceProvider',
8 => 'Illuminate\\Session\\SessionServiceProvider',
9 => 'Illuminate\\View\\ViewServiceProvider',
10 => 'Livewire\\LivewireServiceProvider',
11 => 'Carbon\\Laravel\\ServiceProvider',
12 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
13 => 'Termwind\\Laravel\\TermwindServiceProvider',
14 => 'Spatie\\Activitylog\\ActivitylogServiceProvider',
15 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider',
16 => 'Spatie\\Permission\\PermissionServiceProvider',
17 => 'App\\Providers\\AppServiceProvider',
),
'deferred' =>
array (
'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
'cache' => 'Illuminate\\Cache\\CacheServiceProvider',
'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider',
'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider',
'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider',
'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider',
'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Concurrency\\Console\\InvokeSerializedClosureCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ApiInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\BroadcastingInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ClassMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnumMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\InterfaceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\JobMiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\TraitMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Concurrency\\ConcurrencyManager' => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
'hash' => 'Illuminate\\Hashing\\HashServiceProvider',
'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider',
'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider',
'mailer' => 'Illuminate\\Mail\\MailServiceProvider',
'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider',
'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
'queue' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider',
'redis' => 'Illuminate\\Redis\\RedisServiceProvider',
'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider',
'translator' => 'Illuminate\\Translation\\TranslationServiceProvider',
'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider',
'validator' => 'Illuminate\\Validation\\ValidationServiceProvider',
'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider',
'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider',
'Laravel\\Breeze\\Console\\InstallCommand' => 'Laravel\\Breeze\\BreezeServiceProvider',
'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider',
'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider',
'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider',
),
'when' =>
array (
'Illuminate\\Broadcasting\\BroadcastServiceProvider' =>
array (
),
'Illuminate\\Bus\\BusServiceProvider' =>
array (
),
'Illuminate\\Cache\\CacheServiceProvider' =>
array (
),
'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' =>
array (
),
'Illuminate\\Concurrency\\ConcurrencyServiceProvider' =>
array (
),
'Illuminate\\Hashing\\HashServiceProvider' =>
array (
),
'Illuminate\\Mail\\MailServiceProvider' =>
array (
),
'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' =>
array (
),
'Illuminate\\Pipeline\\PipelineServiceProvider' =>
array (
),
'Illuminate\\Queue\\QueueServiceProvider' =>
array (
),
'Illuminate\\Redis\\RedisServiceProvider' =>
array (
),
'Illuminate\\Translation\\TranslationServiceProvider' =>
array (
),
'Illuminate\\Validation\\ValidationServiceProvider' =>
array (
),
'Laravel\\Breeze\\BreezeServiceProvider' =>
array (
),
'Laravel\\Sail\\SailServiceProvider' =>
array (
),
'Laravel\\Tinker\\TinkerServiceProvider' =>
array (
),
),
);

5
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

70
composer.json Normal file
View File

@@ -0,0 +1,70 @@
{
"name": "termanager2/termanager2",
"type": "project",
"description": "TerManager2 - Territory Management Application",
"keywords": ["laravel", "territory", "management"],
"license": "proprietary",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/tinker": "^2.9",
"livewire/livewire": "^3.5",
"spatie/laravel-permission": "^6.4",
"spatie/laravel-activitylog": "^4.8"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/breeze": "^2.0",
"laravel/pint": "^1.13",
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.1",
"phpunit/phpunit": "^11.0",
"spatie/laravel-ignition": "^2.4"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8990
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
config/app.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
return [
'name' => env('APP_NAME', 'TerManager2'),
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false),
'url' => env('APP_URL', 'http://localhost'),
'timezone' => env('APP_TIMEZONE', 'Europe/Rome'),
'locale' => 'it',
'fallback_locale' => 'en',
'faker_locale' => 'it_IT',
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', env('APP_PREVIOUS_KEYS', ''))
),
],
'maintenance' => [
'driver' => 'file',
],
];

35
config/auth.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
return [
'defaults' => [
'guard' => 'web',
'passwords' => 'users',
],
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_reset_tokens',
'expire' => 60,
'throttle' => 60,
],
],
'password_timeout' => 10800,
];

58
config/database.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
return [
'default' => env('DB_CONNECTION', 'mysql'),
'connections' => [
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'mariadb'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'termanager2'),
'username' => env('DB_USERNAME', 'termanager2'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
],
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', 'termanager2_'),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', 'redis'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', 'redis'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
];

206
config/permission.php Normal file
View File

@@ -0,0 +1,206 @@
<?php
use Spatie\Permission\DefaultTeamResolver;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return [
'models' => [
/*
* When using the "HasPermissions" trait from this package, we need to know which
* Eloquent model should be used to retrieve your permissions. Of course, it
* is often just the "Permission" model but you may use whatever you like.
*
* The model you want to use as a Permission model needs to implement the
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
* Eloquent model should be used to retrieve your roles. Of course, it
* is often just the "Role" model but you may use whatever you like.
*
* The model you want to use as a Role model needs to implement the
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Role::class,
],
'table_names' => [
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'roles' => 'roles',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your permissions. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'permissions' => 'permissions',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your models permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_permissions' => 'model_has_permissions',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your models roles. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_roles' => 'model_has_roles',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
/*
* Change this if you want to name the related pivots other than defaults
*/
'role_pivot_key' => null, // default 'role_id',
'permission_pivot_key' => null, // default 'permission_id',
/*
* Change this if you want to name the related model primary key other than
* `model_id`.
*
* For example, this would be nice if your primary keys are all UUIDs. In
* that case, name this `model_uuid`.
*/
'model_morph_key' => 'model_id',
/*
* Change this if you want to use the teams feature and your related model's
* foreign key is other than `team_id`.
*/
'team_foreign_key' => 'team_id',
],
/*
* When set to true, the method for checking permissions will be registered on the gate.
* Set this to false if you want to implement custom logic for checking permissions.
*/
'register_permission_check_method' => true,
/*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
*/
'register_octane_reset_listener' => false,
/*
* Events will fire when a role or permission is assigned/unassigned:
* \Spatie\Permission\Events\RoleAttached
* \Spatie\Permission\Events\RoleDetached
* \Spatie\Permission\Events\PermissionAttached
* \Spatie\Permission\Events\PermissionDetached
*
* To enable, set to true, and then create listeners to watch these events.
*/
'events_enabled' => false,
/*
* Teams Feature.
* When set to true the package implements teams using the 'team_foreign_key'.
* If you want the migrations to register the 'team_foreign_key', you must
* set this to true before doing the migration.
* If you already did the migration then you must make a new migration to also
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
* (view the latest version of this package's migration file)
*/
'teams' => false,
/*
* The class to use to resolve the permissions team id
*/
'team_resolver' => DefaultTeamResolver::class,
/*
* Passport Client Credentials Grant
* When set to true the package will use Passports Client to check permissions
*/
'use_passport_client_credentials' => false,
/*
* When set to true, the required permission names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_permission_in_exception' => false,
/*
* When set to true, the required role names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_role_in_exception' => false,
/*
* By default wildcard permission lookups are disabled.
* See documentation to understand supported syntax.
*/
'enable_wildcard_permission' => false,
/*
* The class to use for interpreting wildcard permissions.
* If you need to modify delimiters, override the class and specify its name here.
*/
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
/* Cache-specific settings */
'cache' => [
/*
* By default all permissions are cached for 24 hours to speed up performance.
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.
*/
'key' => 'spatie.permission.cache',
/*
* You may optionally indicate a specific cache driver to use for permission and
* role caching using any of the `store` drivers listed in the cache.php config
* file. Using 'default' here means to use the `default` set in cache.php.
*/
'store' => 'default',
],
];

23
config/session.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
return [
'default' => env('SESSION_DRIVER', 'redis'),
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
'encrypt' => env('SESSION_ENCRYPT', false),
'files' => storage_path('framework/sessions'),
'connection' => env('SESSION_CONNECTION'),
'table' => env('SESSION_TABLE', 'sessions'),
'store' => env('SESSION_STORE'),
'lottery' => [2, 100],
'cookie' => env('SESSION_COOKIE', 'termanager2_session'),
'path' => env('SESSION_PATH', '/'),
'domain' => env('SESSION_DOMAIN'),
'secure' => env('SESSION_SECURE_COOKIE'),
'http_only' => env('SESSION_HTTP_ONLY', true),
'same_site' => env('SESSION_SAME_SITE', 'lax'),
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
public function down(): void
{
Schema::dropIfExists('sessions');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('users');
}
};

View File

@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('failed_jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('jobs');
Schema::dropIfExists('cache_locks');
Schema::dropIfExists('cache');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->id();
$table->string('congregazione_nome')->nullable();
$table->string('logo_path')->nullable();
$table->unsignedInteger('giorni_giacenza_da_assegnare')->default(120);
$table->unsignedInteger('giorni_giacenza_prioritari')->default(180);
$table->unsignedInteger('giorni_per_smarrito')->default(120);
$table->unsignedInteger('home_limit_list')->default(10);
$table->unsignedInteger('audit_retention_days')->default(730);
$table->boolean('setup_completed')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('settings');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('zone', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->boolean('attivo')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('zone');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tipologie', function (Blueprint $table) {
$table->id();
$table->string('nome');
$table->boolean('attivo')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tipologie');
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('proclamatori', function (Blueprint $table) {
$table->id();
$table->text('nome'); // encrypted at application level
$table->text('cognome'); // encrypted at application level
$table->boolean('attivo')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('proclamatori');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('territori', function (Blueprint $table) {
$table->id();
$table->string('numero')->unique();
$table->foreignId('zona_id')->nullable()->constrained('zone')->nullOnDelete();
$table->foreignId('tipologia_id')->nullable()->constrained('tipologie')->nullOnDelete();
$table->text('note')->nullable();
$table->text('confini')->nullable();
$table->string('pdf_path')->nullable();
$table->boolean('attivo')->default(true);
$table->boolean('prioritario')->default(false);
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('territori');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('anni_teocratici', function (Blueprint $table) {
$table->id();
$table->string('label')->unique(); // e.g. "2025-2026"
$table->date('start_date');
$table->date('end_date');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('anni_teocratici');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('campagne', function (Blueprint $table) {
$table->id();
$table->date('start_date');
$table->date('end_date');
$table->string('descrizione');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('campagne');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('assegnazioni', function (Blueprint $table) {
$table->id();
$table->foreignId('territorio_id')->constrained('territori')->restrictOnDelete();
$table->foreignId('proclamatore_id')->constrained('proclamatori')->restrictOnDelete();
$table->foreignId('anno_teocratico_id')->constrained('anni_teocratici')->restrictOnDelete();
$table->date('assigned_at');
$table->date('returned_at')->nullable();
$table->boolean('counted_in_campaign')->nullable();
$table->foreignId('campaign_id')->nullable()->constrained('campagne')->nullOnDelete();
$table->text('note')->nullable();
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
$table->foreignId('returned_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
// Composite indexes for performance
$table->index(['territorio_id', 'returned_at']);
$table->index(['proclamatore_id', 'returned_at']);
$table->index(['anno_teocratico_id']);
});
}
public function down(): void
{
Schema::dropIfExists('assegnazioni');
}
};

View File

@@ -0,0 +1,134 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), Exception::class, 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // permission id
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::drop($tableNames['role_has_permissions']);
Schema::drop($tableNames['model_has_roles']);
Schema::drop($tableNames['model_has_permissions']);
Schema::drop($tableNames['roles']);
Schema::drop($tableNames['permissions']);
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->create(config('activitylog.table_name'), function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('log_name')->nullable();
$table->text('description');
$table->nullableMorphs('subject', 'subject');
$table->nullableMorphs('causer', 'causer');
$table->json('properties')->nullable();
$table->timestamps();
$table->index('log_name');
});
}
public function down()
{
Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name'));
}
}

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddEventColumnToActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
$table->string('event')->nullable()->after('subject_type');
});
}
public function down()
{
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
$table->dropColumn('event');
});
}
}

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddBatchUuidColumnToActivityLogTable extends Migration
{
public function up()
{
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
$table->uuid('batch_uuid')->nullable()->after('properties');
});
}
public function down()
{
Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
$table->dropColumn('batch_uuid');
});
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
RolesAndPermissionsSeeder::class,
DevSeeder::class,
]);
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\User;
use App\Models\Setting;
use App\Models\Zona;
use App\Models\Tipologia;
use App\Models\Territorio;
use App\Models\Proclamatore;
use App\Models\AnnoTeocratico;
use App\Models\Assegnazione;
use Carbon\Carbon;
class DevSeeder extends Seeder
{
public function run(): void
{
// ─── Admin User ─────────────────────────────
$admin = User::firstOrCreate(
['email' => 'admin@termanager2.local'],
[
'name' => 'Amministratore',
'password' => bcrypt('password'),
]
);
$admin->assignRole('amministratore');
$assistente = User::firstOrCreate(
['email' => 'assistente@termanager2.local'],
[
'name' => 'Assistente',
'password' => bcrypt('password'),
]
);
$assistente->assignRole('assistente');
$operatore = User::firstOrCreate(
['email' => 'operatore@termanager2.local'],
[
'name' => 'Operatore',
'password' => bcrypt('password'),
]
);
$operatore->assignRole('operatore');
// ─── Settings ───────────────────────────────
Setting::firstOrCreate([], [
'congregazione_nome' => 'Congregazione Demo',
'giorni_giacenza_da_assegnare' => 120,
'giorni_giacenza_prioritari' => 180,
'giorni_per_smarrito' => 120,
'home_limit_list' => 10,
'audit_retention_days' => 730,
'setup_completed' => true,
]);
// ─── Zone ───────────────────────────────────
$zone = [];
foreach (['Centro', 'Nord', 'Sud', 'Est', 'Ovest'] as $nome) {
$zone[$nome] = Zona::firstOrCreate(['nome' => $nome]);
}
// ─── Tipologie ─────────────────────────────
$tipologie = [];
foreach (['Residenziale', 'Commerciale', 'Rurale', 'Telefono'] as $nome) {
$tipologie[$nome] = Tipologia::firstOrCreate(['nome' => $nome]);
}
// ─── Proclamatori ───────────────────────────
$nomi = [
['Mario', 'Rossi'], ['Luisa', 'Bianchi'], ['Giovanni', 'Verdi'],
['Anna', 'Neri'], ['Paolo', 'Esposito'], ['Francesca', 'Romano'],
['Marco', 'Colombo'], ['Sara', 'Ricci'], ['Luca', 'Marino'],
['Elena', 'Greco'], ['Roberto', 'Bruno'], ['Chiara', 'Gallo'],
];
$proclamatori = [];
foreach ($nomi as [$nome, $cognome]) {
$proclamatori[] = Proclamatore::firstOrCreate(
// Can't search encrypted fields, use a simple check
['id' => count($proclamatori) + 1],
['nome' => $nome, 'cognome' => $cognome, 'attivo' => true]
);
}
// Re-fetch to get proper IDs
$proclamatori = Proclamatore::all();
// ─── Territori ──────────────────────────────
$zoneKeys = array_values($zone);
$tipoKeys = array_values($tipologie);
for ($i = 1; $i <= 30; $i++) {
Territorio::firstOrCreate(
['numero' => (string) $i],
[
'zona_id' => $zoneKeys[($i - 1) % count($zoneKeys)]->id,
'tipologia_id' => $tipoKeys[($i - 1) % count($tipoKeys)]->id,
'note' => $i <= 5 ? "Note territorio $i" : null,
'confini' => "Confini del territorio $i",
'attivo' => $i <= 28, // 2 territories inactive
'prioritario' => $i <= 3, // 3 manual priority
]
);
}
// ─── Anno teocratico ────────────────────────
$anno = AnnoTeocratico::corrente();
// ─── Assegnazioni di esempio ────────────────
$territori = Territorio::where('attivo', true)->get();
// 10 closed assignments (various dates)
for ($i = 0; $i < 10; $i++) {
$territorio = $territori[$i];
$proclamatore = $proclamatori[$i % $proclamatori->count()];
$assignedAt = Carbon::now()->subDays(rand(30, 200));
$returnedAt = $assignedAt->copy()->addDays(rand(14, 90));
if ($returnedAt->isFuture()) {
$returnedAt = now()->subDays(rand(1, 10));
}
Assegnazione::firstOrCreate(
[
'territorio_id' => $territorio->id,
'anno_teocratico_id' => $anno->id,
'assigned_at' => $assignedAt->toDateString(),
],
[
'proclamatore_id' => $proclamatore->id,
'returned_at' => $returnedAt->toDateString(),
'created_by' => $admin->id,
'returned_by' => $admin->id,
]
);
}
// 5 open assignments (currently assigned)
for ($i = 15; $i < 20; $i++) {
$territorio = $territori[$i];
$proclamatore = $proclamatori[$i % $proclamatori->count()];
$assignedAt = Carbon::now()->subDays(rand(5, 150));
Assegnazione::firstOrCreate(
[
'territorio_id' => $territorio->id,
'anno_teocratico_id' => $anno->id,
'assigned_at' => $assignedAt->toDateString(),
],
[
'proclamatore_id' => $proclamatore->id,
'returned_at' => null,
'created_by' => $admin->id,
]
);
}
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
class RolesAndPermissionsSeeder extends Seeder
{
public function run(): void
{
// Reset cached roles and permissions
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
// Create permissions
$permissions = [
'settings.manage',
'proclamatori.manage',
'territori.manage',
'campagne.manage',
'registro.view',
'registro.export',
'territori.assign',
'territori.return',
'audit.view',
'audit.export',
];
foreach ($permissions as $permission) {
Permission::firstOrCreate(['name' => $permission]);
}
// Amministratore: all permissions
$admin = Role::firstOrCreate(['name' => 'amministratore']);
$admin->syncPermissions($permissions);
// Assistente: proclamatori, campagne, assign, return
$assistente = Role::firstOrCreate(['name' => 'assistente']);
$assistente->syncPermissions([
'proclamatori.manage',
'campagne.manage',
'territori.assign',
'territori.return',
'registro.view',
]);
// Operatore: assign and return only
$operatore = Role::firstOrCreate(['name' => 'operatore']);
$operatore->syncPermissions([
'territori.assign',
'territori.return',
]);
}
}

96
docker-compose.yml Normal file
View File

@@ -0,0 +1,96 @@
services:
app:
build:
context: ./docker/php
args:
USER_ID: ${USER_ID:-1000}
GROUP_ID: ${GROUP_ID:-1000}
container_name: termanager2_app
restart: unless-stopped
working_dir: /var/www/html
volumes:
- .:/var/www/html
- storage_data:/var/www/html/storage/app
networks:
- termanager2
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
environment:
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=1
nginx:
image: nginx:1.25-alpine
container_name: termanager2_nginx
restart: unless-stopped
ports:
- "${APP_PORT:-8080}:80"
volumes:
- .:/var/www/html:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- termanager2
depends_on:
- app
mariadb:
image: mariadb:11
container_name: termanager2_db
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_DATABASE:-termanager2}
MARIADB_USER: ${DB_USERNAME:-termanager2}
MARIADB_PASSWORD: ${DB_PASSWORD:-secret}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret}
volumes:
- db_data:/var/lib/mysql
ports:
- "${DB_PORT:-3306}:3306"
networks:
- termanager2
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: termanager2_redis
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redissecret}
volumes:
- redis_data:/data
ports:
- "${REDIS_PORT:-6379}:6379"
networks:
- termanager2
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redissecret}", "ping"]
interval: 10s
timeout: 5s
retries: 5
mailpit:
image: axllent/mailpit:latest
container_name: termanager2_mail
restart: unless-stopped
ports:
- "${MAIL_PORT:-1025}:1025"
- "${MAILPIT_UI_PORT:-8025}:8025"
networks:
- termanager2
volumes:
db_data:
driver: local
redis_data:
driver: local
storage_data:
driver: local
networks:
termanager2:
driver: bridge

35
docker/nginx/default.conf Normal file
View File

@@ -0,0 +1,35 @@
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php index.html;
charset utf-8;
client_max_body_size 64M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

65
docker/php/Dockerfile Normal file
View File

@@ -0,0 +1,65 @@
FROM php:8.3-fpm
ARG USER_ID=1000
ARG GROUP_ID=1000
# System dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
libonig-dev \
libxml2-dev \
libzip-dev \
libicu-dev \
zip \
unzip \
supervisor \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd \
intl \
zip \
opcache \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Install Node.js 20 LTS
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Create system user
RUN groupadd -g ${GROUP_ID} appuser \
&& useradd -u ${USER_ID} -g appuser -m appuser
# Set working directory
WORKDIR /var/www/html
# Copy PHP configuration
COPY php.ini /usr/local/etc/php/conf.d/custom.ini
# Copy PHP-FPM pool config (run workers as appuser)
COPY www.conf /usr/local/etc/php-fpm.d/www.conf
# Set ownership
RUN chown -R appuser:appuser /var/www/html
USER appuser
EXPOSE 9000
CMD ["php-fpm"]

17
docker/php/php.ini Normal file
View File

@@ -0,0 +1,17 @@
[PHP]
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 120
max_input_vars = 3000
[opcache]
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 10000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
[Date]
date.timezone = Europe/Rome

13
docker/php/www.conf Normal file
View File

@@ -0,0 +1,13 @@
[www]
user = appuser
group = appuser
listen = 0.0.0.0:9000
listen.owner = appuser
listen.group = appuser
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500

2315
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5",
"@tailwindcss/vite": "^4.0",
"autoprefixer": "^10.4",
"axios": "^1.7",
"laravel-vite-plugin": "^1.0",
"tailwindcss": "^4.0",
"vite": "^6.0"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
{
"resources/css/app.css": {
"file": "assets/app-Cnyz0W61.css",
"src": "resources/css/app.css",
"isEntry": true
},
"resources/js/app.js": {
"file": "assets/app-BjqOcoUn.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
}
}

21
public/index.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Request::capture()
)->send();
$kernel->terminate($request, $response);

1
resources/css/app.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

1
resources/js/app.js Normal file
View File

@@ -0,0 +1 @@
import './bootstrap';

3
resources/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,3 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="it" class="h-full bg-gray-50">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'TerManager2' }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="h-full">
<div class="min-h-full">
{{-- Header --}}
<nav class="bg-indigo-700 shadow-lg">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center gap-3">
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden text-white p-2"
x-data x-on:click="$dispatch('toggle-sidebar')">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
@php $settings = \App\Models\Setting::first(); @endphp
@if($settings && $settings->logo_path)
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-8 w-8 rounded">
@else
<div class="h-8 w-8 bg-indigo-500 rounded flex items-center justify-center text-white font-bold text-sm">T2</div>
@endif
<span class="text-white font-semibold text-lg hidden sm:block">
{{ $settings->congregazione_nome ?? 'TerManager2' }}
</span>
</div>
<div class="flex items-center gap-4">
<span class="text-indigo-200 text-sm hidden sm:block">
{{ auth()->user()->name }}
<span class="text-indigo-300 text-xs">({{ auth()->user()->roles->first()?->name ?? 'utente' }})</span>
</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-indigo-200 hover:text-white text-sm font-medium transition">
Esci
</button>
</form>
</div>
</div>
</div>
</nav>
<div class="flex" x-data="{ sidebarOpen: false }" @toggle-sidebar.window="sidebarOpen = !sidebarOpen">
{{-- Sidebar overlay (mobile) --}}
<div x-show="sidebarOpen" x-transition:enter="transition-opacity ease-linear duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-linear duration-300"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 z-40 bg-gray-600/75 lg:hidden" @click="sidebarOpen = false">
</div>
{{-- Sidebar --}}
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 lg:z-auto lg:shadow-none lg:border-r lg:border-gray-200 pt-16 lg:pt-0">
<nav class="mt-4 px-3 space-y-1">
<a href="{{ route('dashboard') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('dashboard') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/></svg>
Home
</a>
@can('territori.manage')
<a href="{{ route('territori.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('territori.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
Territori
</a>
@endcan
@can('proclamatori.manage')
<a href="{{ route('proclamatori.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('proclamatori.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Proclamatori
</a>
@endcan
@can('campagne.manage')
<a href="{{ route('campagne.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('campagne.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/></svg>
Campagne
</a>
@endcan
@can('registro.view')
<a href="{{ route('registro.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('registro.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
Registro
</a>
@endcan
@can('audit.view')
<a href="{{ route('audit.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('audit.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
Audit Log
</a>
@endcan
@can('settings.manage')
<div class="pt-4 mt-4 border-t border-gray-200">
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider">Amministrazione</p>
<a href="{{ route('settings.edit') }}"
class="flex items-center gap-3 px-3 py-2 mt-2 text-sm font-medium rounded-lg {{ request()->routeIs('settings.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Impostazioni
</a>
<a href="{{ route('zone.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('zone.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Zone
</a>
<a href="{{ route('tipologie.index') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('tipologie.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
Tipologie
</a>
</div>
@endcan
<div class="pt-4 mt-4 border-t border-gray-200">
<a href="{{ route('privacy') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('privacy') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
Privacy
</a>
</div>
</nav>
</aside>
{{-- Main content --}}
<main class="flex-1 px-4 sm:px-6 lg:px-8 py-6 min-h-screen">
{{-- Flash messages --}}
@if (session()->has('success'))
<div class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-700 border border-green-200">
{{ session('success') }}
</div>
@endif
@if (session()->has('error'))
<div class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-700 border border-red-200">
{{ session('error') }}
</div>
@endif
{{ $slot }}
</main>
</div>
</div>
@livewireScripts
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="it" class="h-full bg-gray-50">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'TerManager2' }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="h-full flex items-center justify-center">
<div class="w-full max-w-md">
{{ $slot }}
</div>
@livewireScripts
</body>
</html>

View File

@@ -0,0 +1,46 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Assegna Territorio</h1>
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna ai territori</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
<form wire:submit="save" class="space-y-4">
<div>
<label for="territorio_id" class="block text-sm font-medium text-gray-700">Territorio *</label>
<select wire:model="territorio_id" id="territorio_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" @if($preselectedTerritorioId) disabled @endif>
<option value="">Seleziona un territorio</option>
@foreach($territoriDisponibili as $t)
<option value="{{ $t->id }}"> {{ $t->numero }} {{ $t->zona?->nome }} ({{ $t->tipologia?->nome }})</option>
@endforeach
</select>
@if($preselectedTerritorioId)
<input type="hidden" wire:model="territorio_id" value="{{ $preselectedTerritorioId }}">
@endif
@error('territorio_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="proclamatore_id" class="block text-sm font-medium text-gray-700">Proclamatore *</label>
<select wire:model="proclamatore_id" id="proclamatore_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
<option value="">Seleziona un proclamatore</option>
@foreach($proclamatoriAttivi as $p)
<option value="{{ $p->id }}">{{ $p->cognome }} {{ $p->nome }}</option>
@endforeach
</select>
@error('proclamatore_id') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="assigned_at" class="block text-sm font-medium text-gray-700">Data Assegnazione *</label>
<input wire:model="assigned_at" type="date" id="assigned_at" max="{{ now()->format('Y-m-d') }}" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Assegna</button>
<a href="{{ route('territori.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,60 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Rientro Territorio</h1>
<a href="{{ route('territori.show', $assegnazione->territorio) }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna al territorio</a>
</div>
{{-- Assignment summary --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Territorio</p>
<p class="mt-1 text-lg font-bold text-gray-900"> {{ $assegnazione->territorio->numero }}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Proclamatore</p>
<p class="mt-1 text-sm text-gray-900">{{ $assegnazione->proclamatore->nome_completo }}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Assegnato il</p>
<p class="mt-1 text-sm text-gray-900">{{ $assegnazione->assigned_at->format('d/m/Y') }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
<form wire:submit="save" class="space-y-4">
<div>
<label for="returned_at" class="block text-sm font-medium text-gray-700">Data Rientro *</label>
<input wire:model.live="returned_at" type="date" id="returned_at"
min="{{ $assegnazione->assigned_at->format('Y-m-d') }}"
max="{{ now()->format('Y-m-d') }}"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
@error('returned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
{{-- Campaign retroactive prompt --}}
@if($showCampaignPrompt && $campagna)
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p class="text-sm font-medium text-amber-800 mb-2">
⚠️ Campagna Attiva: <strong>{{ $campagna->descrizione }}</strong>
</p>
<p class="text-xs text-amber-700 mb-3">
Questo territorio è stato assegnato durante la campagna "{{ $campagna->descrizione }}"
({{ $campagna->start_date->format('d/m/Y') }} {{ $campagna->end_date->format('d/m/Y') }}).
Vuoi conteggiarlo nella percentuale di percorrenza?
</p>
<div class="flex items-center gap-2">
<input wire:model="counted_in_campaign" type="checkbox" id="counted_in_campaign" class="rounded border-gray-300 text-amber-600 focus:ring-amber-500">
<label for="counted_in_campaign" class="text-sm text-amber-800">, conteggia in campagna</label>
</div>
</div>
@endif
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition">Registra Rientro</button>
<a href="{{ route('territori.show', $assegnazione->territorio) }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,108 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Audit Log</h1>
<p class="text-sm text-gray-500">Registro di tutte le azioni eseguite nel sistema.</p>
</div>
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca..." class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<select wire:model.live="filterEvent" class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutti gli eventi</option>
@foreach($events as $event)
<option value="{{ $event }}">{{ $event }}</option>
@endforeach
</select>
<select wire:model.live="filterCauser" class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutti gli utenti</option>
@foreach($users as $user)
<option value="{{ $user->id }}">{{ $user->name }}</option>
@endforeach
</select>
</div>
</div>
{{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Data/Ora</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Utente</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Evento</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Soggetto</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Dettagli</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($activities as $activity)
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">{{ $activity->created_at->format('d/m/Y H:i:s') }}</td>
<td class="px-3 py-2 text-xs">{{ $activity->causer?->name ?? 'Sistema' }}</td>
<td class="px-3 py-2">
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full
{{ match($activity->description) {
'created' => 'bg-green-100 text-green-800',
'updated' => 'bg-blue-100 text-blue-800',
'deleted' => 'bg-red-100 text-red-800',
'assigned' => 'bg-indigo-100 text-indigo-800',
'returned' => 'bg-amber-100 text-amber-800',
'restored' => 'bg-purple-100 text-purple-800',
'login' => 'bg-cyan-100 text-cyan-800',
default => 'bg-gray-100 text-gray-700',
} }}">
{{ $activity->description }}
</span>
</td>
<td class="px-3 py-2 text-xs text-gray-600">
@if($activity->subject_type)
{{ class_basename($activity->subject_type) }} #{{ $activity->subject_id }}
@else
-
@endif
</td>
<td class="px-3 py-2 text-xs text-gray-500">
@if($activity->properties->isNotEmpty())
<details>
<summary class="cursor-pointer text-indigo-600 hover:text-indigo-800">Mostra</summary>
<div class="mt-1 p-2 bg-gray-50 rounded text-xs font-mono max-w-xs overflow-auto">
@if($activity->properties->has('old'))
<div class="mb-1">
<span class="font-semibold text-red-600">Vecchio:</span>
@foreach($activity->properties['old'] as $k => $v)
<div>{{ $k }}: {{ is_string($v) ? $v : json_encode($v) }}</div>
@endforeach
</div>
<div>
<span class="font-semibold text-green-600">Nuovo:</span>
@foreach($activity->properties['attributes'] ?? [] as $k => $v)
<div>{{ $k }}: {{ is_string($v) ? $v : json_encode($v) }}</div>
@endforeach
</div>
@else
@foreach($activity->properties->toArray() as $k => $v)
<div>{{ $k }}: {{ is_string($v) ? $v : json_encode($v) }}</div>
@endforeach
@endif
</div>
</details>
@else
-
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-8 text-center text-gray-500">Nessuna attività registrata.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="px-4 py-3 border-t border-gray-200">
{{ $activities->links() }}
</div>
</div>
</div>

View File

@@ -0,0 +1,38 @@
<div class="bg-white shadow-xl rounded-2xl p-8">
<div class="text-center mb-8">
<div class="mx-auto h-12 w-12 bg-indigo-600 rounded-xl flex items-center justify-center text-white font-bold text-xl mb-4">T2</div>
<h2 class="text-2xl font-bold text-gray-900">TerManager2</h2>
<p class="text-gray-500 text-sm mt-1">Accedi per continuare</p>
</div>
<form wire:submit="login" class="space-y-5">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input wire:model="email" type="email" id="email" autocomplete="email"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm px-4 py-2.5">
@error('email') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input wire:model="password" type="password" id="password" autocomplete="current-password"
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm px-4 py-2.5">
@error('password') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="flex items-center">
<input wire:model="remember" type="checkbox" id="remember"
class="h-4 w-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
<label for="remember" class="ml-2 text-sm text-gray-600">Ricordami</label>
</div>
<button type="submit"
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition">
<span wire:loading.remove>Accedi</span>
<span wire:loading class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
Accesso in corso...
</span>
</button>
</form>
</div>

View File

@@ -0,0 +1,34 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1>
<a href="{{ route('campagne.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
<form wire:submit="save" class="space-y-4">
<div>
<label for="descrizione" class="block text-sm font-medium text-gray-700">Descrizione *</label>
<input wire:model="descrizione" type="text" id="descrizione" placeholder="es. Campagna della Commemorazione 2025" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" required>
@error('descrizione') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700">Data Inizio *</label>
<input wire:model="start_date" type="date" id="start_date" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" required>
@error('start_date') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700">Data Fine *</label>
<input wire:model="end_date" type="date" id="end_date" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" required>
@error('end_date') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">{{ $btnLabel }}</button>
<a href="{{ route('campagne.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,67 @@
<div>
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900">Campagne</h1>
@can('campagne.manage')
<a href="{{ route('campagne.create') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
+ Nuova Campagna
</a>
@endcan
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Descrizione</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Inizio</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Fine</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Stato</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">% Percorrenza</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse($campagne as $campagna)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ $campagna->descrizione }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $campagna->start_date->format('d/m/Y') }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $campagna->end_date->format('d/m/Y') }}</td>
<td class="px-4 py-3 text-sm">
@if($campagna->is_attiva)
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attiva</span>
@elseif($campagna->end_date->isPast())
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Conclusa</span>
@else
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-800">Futura</span>
@endif
</td>
<td class="px-4 py-3 text-sm">
<div class="flex items-center gap-2">
<div class="w-20 bg-gray-200 rounded-full h-2">
<div class="bg-indigo-500 h-2 rounded-full" style="width:{{ min($campagna->percentuale_percorrenza, 100) }}%"></div>
</div>
<span class="text-xs text-gray-600">{{ $campagna->percentuale_percorrenza }}%</span>
</div>
</td>
<td class="px-4 py-3 text-sm text-right space-x-2">
<a href="{{ route('campagne.show', $campagna) }}" class="text-indigo-600 hover:text-indigo-800 text-xs">Dettaglio</a>
@can('campagne.manage')
<a href="{{ route('campagne.edit', $campagna) }}" class="text-yellow-600 hover:text-yellow-800 text-xs">Modifica</a>
<button wire:click="deleteCampagna({{ $campagna->id }})" wire:confirm="Eliminare la campagna '{{ $campagna->descrizione }}'?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500 text-sm">Nessuna campagna registrata.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="px-4 py-3 border-t border-gray-200">
{{ $campagne->links() }}
</div>
</div>
</div>

View File

@@ -0,0 +1,75 @@
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ $campagna->descrizione }}</h1>
<a href="{{ route('campagne.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div>
@can('campagne.manage')
<a href="{{ route('campagne.edit', $campagna) }}" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Modifica</a>
@endcan
</div>
{{-- Stats --}}
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Periodo</p>
<p class="mt-1 text-sm font-medium text-gray-900">{{ $campagna->start_date->format('d/m/Y') }} {{ $campagna->end_date->format('d/m/Y') }}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Stato</p>
@if($campagna->is_attiva)
<span class="inline-flex mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Attiva</span>
@elseif($campagna->end_date->isPast())
<span class="inline-flex mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">Conclusa</span>
@else
<span class="inline-flex mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Futura</span>
@endif
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Conteggiati / Assegnati</p>
<p class="mt-1 text-2xl font-bold text-indigo-600">{{ $conteggiate->count() }} / {{ $assegnateNelRange }}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Percentuale</p>
<p class="mt-1 text-2xl font-bold text-gray-900">{{ $campagna->percentuale_percorrenza }}%</p>
<div class="mt-2 w-full bg-gray-200 rounded-full h-2">
<div class="bg-indigo-500 h-2 rounded-full" style="width: {{ min($campagna->percentuale_percorrenza, 100) }}%"></div>
</div>
</div>
</div>
{{-- Counted assignments --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-gray-50 border-b">
<h3 class="text-sm font-semibold text-gray-700">Territori Conteggiati</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($conteggiate as $a)
<tr>
<td class="px-4 py-2"><a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline"> {{ $a->territorio?->numero }}</a></td>
<td class="px-4 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
<td class="px-4 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td class="px-4 py-2">{{ $a->returned_at?->format('d/m/Y') }}</td>
<td class="px-4 py-2">{{ $a->giorni }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500">Nessun territorio conteggiato in questa campagna.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,139 @@
<div>
{{-- Header --}}
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
@if($annoCorrente)
<p class="text-sm text-gray-500">Anno Teocratico {{ $annoCorrente->label }} {{ $annoCorrente->mesi_trascorsi }} mesi trascorsi</p>
@endif
</div>
{{-- Stats cards --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Territori Attivi</p>
<p class="mt-1 text-3xl font-bold text-gray-900">{{ $totTerritoriAttivi }}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Assegnati</p>
<p class="mt-1 text-3xl font-bold text-blue-600">{{ $totAssegnati }}</p>
<p class="text-xs text-gray-500">{{ $totInReparto }} in reparto</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Percorsi (anno)</p>
<p class="mt-1 text-3xl font-bold text-green-600">{{ $territoriPercorsi }}</p>
<p class="text-xs text-gray-500">media {{ $mediaPercorrenzaMensile }}/mese</p>
</div>
@if($campagnaStats)
<div class="bg-amber-50 rounded-xl shadow-sm border border-amber-200 p-4">
<p class="text-xs font-medium text-amber-600 uppercase">Campagna</p>
<p class="mt-1 text-lg font-bold text-amber-800">{{ $campagnaStats['descrizione'] }}</p>
<div class="mt-2">
<div class="flex justify-between text-xs text-amber-700 mb-1">
<span>{{ $campagnaStats['percentuale'] }}%</span>
<span>scade {{ $campagnaStats['fine'] }}</span>
</div>
<div class="w-full bg-amber-200 rounded-full h-2">
<div class="bg-amber-500 h-2 rounded-full transition-all" style="width: {{ min($campagnaStats['percentuale'], 100) }}%"></div>
</div>
</div>
</div>
@else
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Campagna</p>
<p class="mt-1 text-sm text-gray-400">Nessuna campagna attiva</p>
</div>
@endif
</div>
{{-- Quick lists --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Da assegnare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-green-50 border-b border-green-100">
<h3 class="text-sm font-semibold text-green-800">Da Assegnare</h3>
</div>
<ul class="divide-y divide-gray-100">
@forelse($daAssegnare as $t)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
<div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a>
<p class="text-xs text-gray-500">{{ $t->zona?->nome }} {{ $t->tipologia?->nome }}</p>
</div>
@can('territori.assign')
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="text-xs font-medium text-green-600 hover:text-green-800">Assegna </a>
@endcan
</li>
@empty
<li class="px-4 py-4 text-sm text-gray-400 text-center">Tutti assegnati</li>
@endforelse
</ul>
@if($daAssegnare->count() >= 10)
<div class="px-4 py-2 bg-gray-50 border-t text-center">
<a href="{{ route('territori.index') }}?filtroStato=in_reparto" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a>
</div>
@endif
</div>
{{-- Prioritari --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-amber-50 border-b border-amber-100">
<h3 class="text-sm font-semibold text-amber-800"> Prioritari</h3>
</div>
<ul class="divide-y divide-gray-100">
@forelse($prioritari as $t)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
<div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a>
<p class="text-xs text-gray-500">
{{ $t->zona?->nome }}
@if($t->ultimaAssegnazione?->returned_at)
ultimo rientro {{ $t->ultimaAssegnazione->returned_at->diffForHumans() }}
@endif
</p>
</div>
<span class="text-xs font-medium text-amber-600">
{{ $t->prioritario ? 'Man' : 'Auto' }}
</span>
</li>
@empty
<li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio prioritario</li>
@endforelse
</ul>
@if($prioritari->count() >= 10)
<div class="px-4 py-2 bg-gray-50 border-t text-center">
<a href="{{ route('territori.index') }}?filtroStato=prioritari" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a>
</div>
@endif
</div>
{{-- Da rientrare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-red-50 border-b border-red-100">
<h3 class="text-sm font-semibold text-red-800">Da Rientrare</h3>
</div>
<ul class="divide-y divide-gray-100">
@forelse($daRientrare as $t)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
<div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a>
<p class="text-xs text-gray-500">
{{ $t->assegnazioneCorrente?->proclamatore?->nome_completo }}
{{ $t->assegnazioneCorrente?->giorni }} giorni
</p>
</div>
@can('territori.return')
<a href="{{ route('assegnazioni.rientra', $t->assegnazioneCorrente) }}" class="text-xs font-medium text-red-600 hover:text-red-800">Rientra </a>
@endcan
</li>
@empty
<li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio da rientrare</li>
@endforelse
</ul>
@if($daRientrare->count() >= 10)
<div class="px-4 py-2 bg-gray-50 border-t text-center">
<a href="{{ route('territori.index') }}?filtroStato=da_rientrare" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a>
</div>
@endif
</div>
</div>
</div>

View File

@@ -0,0 +1,72 @@
<div>
<div class="max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Informativa Privacy</h1>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 md:p-8 space-y-6 text-sm text-gray-700 leading-relaxed">
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Titolare del trattamento</h2>
<p>Congregazione: <strong>{{ $congregazione }}</strong></p>
<p>L'applicazione TerManager2 è utilizzata esclusivamente per la gestione interna dei territori di predicazione.</p>
</section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Dati trattati</h2>
<ul class="list-disc list-inside space-y-1">
<li><strong>Proclamatori</strong>: nome e cognome — conservati in forma crittografata (AES-256-CBC) nel database.</li>
<li><strong>Territori</strong>: numero, località, zona, tipologia, mappe PDF — nessun dato personale.</li>
<li><strong>Assegnazioni</strong>: collegamento tra proclamatore e territorio con date di assegnazione e rientro.</li>
<li><strong>Utenti</strong>: nome, email e ruolo per l'accesso all'applicazione.</li>
</ul>
</section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Finalità del trattamento</h2>
<p>I dati personali sono trattati esclusivamente per:</p>
<ul class="list-disc list-inside space-y-1">
<li>Organizzare l'assegnazione e il rientro dei territori di predicazione.</li>
<li>Monitorare la copertura territoriale per anno teocratico.</li>
<li>Gestire le campagne speciali di predicazione.</li>
</ul>
</section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Base giuridica</h2>
<p>Il trattamento è basato sul legittimo interesse dell'organizzazione religiosa nella gestione delle proprie attività interne (Art. 6(1)(f) GDPR) e sull'appartenenza volontaria dei proclamatori alla congregazione (Art. 9(2)(d) GDPR).</p>
</section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Misure di sicurezza</h2>
<ul class="list-disc list-inside space-y-1">
<li><strong>Crittografia</strong>: i dati personali (nome/cognome) sono crittografati con AES-256-CBC prima della memorizzazione.</li>
<li><strong>Controllo accessi</strong>: sistema RBAC con tre ruoli (Admin, Servitore di Territorio, Proclamatore Semplificato).</li>
<li><strong>Audit trail</strong>: tutte le operazioni sono registrate e conservate per {{ $auditRetention }} giorni.</li>
<li><strong>Comunicazioni sicure</strong>: l'accesso avviene tramite HTTPS.</li>
<li><strong>Hosting locale</strong>: i dati risiedono sull'infrastruttura della congregazione, non su servizi cloud di terze parti.</li>
</ul>
</section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
<ul class="list-disc list-inside space-y-1">
<li>I dati dei proclamatori inattivi possono essere anonimizzati su richiesta.</li>
<li>I territori eliminati sono conservati nel cestino (soft-delete) prima dell'eliminazione definitiva.</li>
<li>I log di audit sono conservati per <strong>{{ $auditRetention }} giorni</strong>, configurabili nelle impostazioni.</li>
<li>Lo storico assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
</ul>
</section>
<section>
<h2 class="text-lg font-semibold text-gray-900 mb-2">Diritti dell'interessato</h2>
<p>In conformità al GDPR, ogni persona i cui dati sono trattati ha il diritto di:</p>
<ul class="list-disc list-inside space-y-1">
<li><strong>Accesso</strong>: richiedere copia dei propri dati.</li>
<li><strong>Rettifica</strong>: correggere dati inesatti.</li>
<li><strong>Cancellazione</strong>: richiedere l'anonimizzazione o la rimozione dei propri dati.</li>
<li><strong>Portabilità</strong>: richiedere l'esportazione dei propri dati.</li>
</ul>
<p class="mt-2">Le richieste possono essere inoltrate al Servitore di Territorio o all'Amministratore dell'applicazione.</p>
</section>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Cestino Proclamatori</h1>
<a href="{{ route('proclamatori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nome Completo</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Eliminato il</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse($proclamatori as $proclamatore)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-semibold text-gray-900">{{ $proclamatore->nome_completo }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->deleted_at->format('d/m/Y H:i') }}</td>
<td class="px-4 py-3 text-sm text-right space-x-2">
<button wire:click="restore({{ $proclamatore->id }})" class="text-green-600 hover:text-green-800 text-xs font-medium">Ripristina</button>
<button wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile." class="text-red-600 hover:text-red-800 text-xs font-medium">Elimina definitivamente</button>
</td>
</tr>
@empty
<tr>
<td colspan="3" class="px-4 py-8 text-center text-gray-500 text-sm">Il cestino è vuoto.</td>
</tr>
@endforelse
</tbody>
</table>
<div class="px-4 py-3 border-t border-gray-200">
{{ $proclamatori->links() }}
</div>
</div>
</div>

View File

@@ -0,0 +1,32 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ $titolo }}</h1>
<a href="{{ route('proclamatori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
<form wire:submit="save" class="space-y-4">
<div>
<label for="cognome" class="block text-sm font-medium text-gray-700">Cognome *</label>
<input wire:model="cognome" type="text" id="cognome" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" required>
@error('cognome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="nome" class="block text-sm font-medium text-gray-700">Nome *</label>
<input wire:model="nome" type="text" id="nome" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" required>
@error('nome') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex items-center gap-2">
<input wire:model="attivo" type="checkbox" id="attivo" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
<label for="attivo" class="text-sm text-gray-700">Attivo</label>
</div>
<div class="flex items-center gap-3 pt-4">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">{{ $btnLabel }}</button>
<a href="{{ route('proclamatori.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,81 @@
<div>
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 class="text-2xl font-bold text-gray-900">Proclamatori</h1>
@can('proclamatori.manage')
<a href="{{ route('proclamatori.create') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
+ Nuovo Proclamatore
</a>
@endcan
</div>
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca nome o cognome..." class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<select wire:model.live="filtroStato" class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutti</option>
<option value="attivo">Attivi</option>
<option value="inattivo">Inattivi</option>
</select>
<div class="text-xs text-gray-500 flex items-center">
@can('proclamatori.manage')
<a href="{{ route('proclamatori.cestino') }}" class="text-red-500 hover:text-red-700">🗑 Cestino</a>
@endcan
</div>
</div>
</div>
{{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th wire:click="sortBy('cognome')" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-gray-700">
Cognome @if($sortField==='cognome') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
</th>
<th wire:click="sortBy('nome')" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-gray-700">
Nome @if($sortField==='nome') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Stato</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Territori Assegnati</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Azioni</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse($proclamatori as $proclamatore)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ $proclamatore->nome }}</td>
<td class="px-4 py-3 text-sm">
@if($proclamatore->attivo)
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attivo</span>
@else
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
@endif
</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni()->aperte()->count() }}</td>
<td class="px-4 py-3 text-sm text-right space-x-2">
<a href="{{ route('proclamatori.show', $proclamatore) }}" class="text-indigo-600 hover:text-indigo-800 text-xs">Dettaglio</a>
@can('proclamatori.manage')
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="text-yellow-600 hover:text-yellow-800 text-xs">Modifica</a>
<button wire:click="toggleActive({{ $proclamatore->id }})" class="text-xs {{ $proclamatore->attivo ? 'text-gray-500 hover:text-gray-700' : 'text-green-600 hover:text-green-800' }}">
{{ $proclamatore->attivo ? 'Disattiva' : 'Attiva' }}
</button>
<button wire:click="deleteProclamatore({{ $proclamatore->id }})" wire:confirm="Spostare il proclamatore nel cestino?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-8 text-center text-gray-500 text-sm">Nessun proclamatore trovato.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="px-4 py-3 border-t border-gray-200">
{{ $proclamatori->links() }}
</div>
</div>
</div>

View File

@@ -0,0 +1,85 @@
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
<a href="{{ route('proclamatori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div>
@can('proclamatori.manage')
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Modifica</a>
@endcan
</div>
{{-- Stats --}}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Stato</p>
@if($proclamatore->attivo)
<span class="inline-flex mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Attivo</span>
@else
<span class="inline-flex mt-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">Inattivo</span>
@endif
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Territori Attualmente</p>
<p class="mt-1 text-2xl font-bold text-indigo-600">{{ $stats['attualmente_assegnati'] }}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-500 uppercase">Media Giorni Trattenuta</p>
<p class="mt-1 text-2xl font-bold text-gray-900">{{ $stats['media_giorni'] }} gg</p>
<p class="text-xs text-gray-500">su {{ $stats['totale_assegnazioni'] }} assegnazioni totali</p>
</div>
</div>
{{-- Assignment history --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Storico Assegnazioni</h3>
@forelse($assegnazioniPerAnno as $annoLabel => $assegnazioni)
<div class="mb-6">
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Assegnato</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Rientrato</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Giorni</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Campagna</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($assegnazioni as $assegnazione)
<tr>
<td class="px-3 py-2">
<a href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">
{{ $assegnazione->territorio?->numero ?? 'N/A' }}
</a>
</td>
<td class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
<td class="px-3 py-2">
@if($assegnazione->returned_at)
{{ $assegnazione->returned_at->format('d/m/Y') }}
@else
<span class="text-amber-600 font-medium">In corso</span>
@endif
</td>
<td class="px-3 py-2">{{ $assegnazione->giorni }}</td>
<td class="px-3 py-2">
@if($assegnazione->counted_in_campaign)
<span class="text-green-600">{{ $assegnazione->campagna?->descrizione }}</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@empty
<p class="text-gray-500 text-sm">Nessuna assegnazione registrata.</p>
@endforelse
</div>
</div>

View File

@@ -0,0 +1,93 @@
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Registro Assegnazioni</h1>
</div>
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca territorio o proclamatore..." class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<select wire:model.live="filtroAnno" class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutti gli anni</option>
@foreach($anni as $anno)
<option value="{{ $anno->id }}">{{ $anno->label }}</option>
@endforeach
</select>
<select wire:model.live="filtroZona" class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le zone</option>
@foreach($zone as $zona)
<option value="{{ $zona->id }}">{{ $zona->nome }}</option>
@endforeach
</select>
<select wire:model.live="filtroTipologia" class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte le tipologie</option>
@foreach($tipologie as $tipologia)
<option value="{{ $tipologia->id }}">{{ $tipologia->nome }}</option>
@endforeach
</select>
<select wire:model.live="filtroStato" class="rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">Tutte</option>
<option value="aperte">Aperte (in corso)</option>
<option value="chiuse">Chiuse (rientrate)</option>
</select>
</div>
</div>
{{-- Table --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th wire:click="sortBy('territorio_id')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer">Territorio</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Proclamatore</th>
<th wire:click="sortBy('assigned_at')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer">
Assegnato @if($sortField==='assigned_at') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
</th>
<th wire:click="sortBy('returned_at')" class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer">
Rientrato @if($sortField==='returned_at') {{ $sortDirection==='asc'?'↑':'↓' }} @endif
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Giorni</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Anno</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase">Campagna</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($assegnazioni as $a)
<tr class="hover:bg-gray-50">
<td class="px-3 py-2">
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-medium"> {{ $a->territorio?->numero }}</a>
<span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span>
</td>
<td class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
<td class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
<td class="px-3 py-2">
@if($a->returned_at)
{{ $a->returned_at->format('d/m/Y') }}
@else
<span class="text-amber-600 font-medium text-xs">In corso</span>
@endif
</td>
<td class="px-3 py-2">{{ $a->giorni }}</td>
<td class="px-3 py-2 text-xs text-gray-500">{{ $a->annoTeocratico?->label }}</td>
<td class="px-3 py-2 text-xs">
@if($a->counted_in_campaign)
<span class="text-green-600">{{ $a->campagna?->descrizione }}</span>
@else
<span class="text-gray-300">-</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500">Nessuna assegnazione trovata.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="px-4 py-3 border-t border-gray-200">
{{ $assegnazioni->links() }}
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More