Primo commit
This commit is contained in:
38
.env.example
Normal file
38
.env.example
Normal 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
14
.gitignore
vendored
Normal 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
448
README.md
Normal 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
640
TerManager2_v2.md
Normal file
@@ -0,0 +1,640 @@
|
||||
# TerManager2 — Specifiche funzionali + istruzioni per container (Laravel)
|
||||
|
||||
> Documento operativo per realizzare **TerManager2**, applicazione Laravel per la gestione dell’assegnazione/rientro di cartoline territorio, statistiche di percorrenza e campagne speciali.
|
||||
>
|
||||
> **Obiettivo del documento**: chiarire requisiti, proporre un’architettura “container-ready”, definire modello dati e regole di calcolo, e fornire una **scomposizione in sottotask** con checklist di controllo a fine attività.
|
||||
|
||||
---
|
||||
|
||||
## 1) Visione d’insieme
|
||||
|
||||
**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 nell’anno 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 all’oblio**: 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 (nell’anno 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: l’hash 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)
|
||||
- All’avvio 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 (sull’attuale assegnatario).
|
||||
|
||||
### 5.6 Media mensile di percorrenza (Home)
|
||||
Definizione richiesta: media mensile di percorrenza di tutti i territori nell’anno teocratico corrente.
|
||||
|
||||
Interpretazione operativa (coerente con testo):
|
||||
- Per ogni **assegnazione chiusa** (returned_at valorizzato) nell’anno 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 dell’anno teocratico corrente
|
||||
- `mesi_trascorsi = numero mesi dall’inizio 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. L’importante è 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 un’assegnazione:
|
||||
- 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 dall’anno 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 dell’azione)
|
||||
- `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 l’app 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 l’assenza 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 l’intera 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”.
|
||||
24
app/Console/Commands/AuditCleanup.php
Normal file
24
app/Console/Commands/AuditCleanup.php
Normal 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;
|
||||
}
|
||||
}
|
||||
24
app/Http/Middleware/SetupRequired.php
Normal file
24
app/Http/Middleware/SetupRequired.php
Normal 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);
|
||||
}
|
||||
}
|
||||
99
app/Livewire/Assegnazioni/Assegna.php
Normal file
99
app/Livewire/Assegnazioni/Assegna.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
99
app/Livewire/Assegnazioni/Rientra.php
Normal file
99
app/Livewire/Assegnazioni/Rientra.php
Normal 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
57
app/Livewire/AuditLog.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
app/Livewire/Auth/Login.php
Normal file
54
app/Livewire/Auth/Login.php
Normal 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');
|
||||
}
|
||||
}
|
||||
44
app/Livewire/Campagne/CampagnaCreate.php
Normal file
44
app/Livewire/Campagne/CampagnaCreate.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
53
app/Livewire/Campagne/CampagnaEdit.php
Normal file
53
app/Livewire/Campagne/CampagnaEdit.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
26
app/Livewire/Campagne/CampagnaIndex.php
Normal file
26
app/Livewire/Campagne/CampagnaIndex.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Livewire/Campagne/CampagnaShow.php
Normal file
41
app/Livewire/Campagne/CampagnaShow.php
Normal 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
81
app/Livewire/Home.php
Normal 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
19
app/Livewire/Privacy.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
44
app/Livewire/Proclamatori/ProclamatoreCestino.php
Normal file
44
app/Livewire/Proclamatori/ProclamatoreCestino.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
48
app/Livewire/Proclamatori/ProclamatoreCreate.php
Normal file
48
app/Livewire/Proclamatori/ProclamatoreCreate.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
53
app/Livewire/Proclamatori/ProclamatoreEdit.php
Normal file
53
app/Livewire/Proclamatori/ProclamatoreEdit.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
135
app/Livewire/Proclamatori/ProclamatoreIndex.php
Normal file
135
app/Livewire/Proclamatori/ProclamatoreIndex.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
app/Livewire/Proclamatori/ProclamatoreShow.php
Normal file
42
app/Livewire/Proclamatori/ProclamatoreShow.php
Normal 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
101
app/Livewire/Registro.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
53
app/Livewire/Settings/SettingsEdit.php
Normal file
53
app/Livewire/Settings/SettingsEdit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
68
app/Livewire/Settings/TipologieIndex.php
Normal file
68
app/Livewire/Settings/TipologieIndex.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
68
app/Livewire/Settings/ZoneIndex.php
Normal file
68
app/Livewire/Settings/ZoneIndex.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
121
app/Livewire/Setup/Wizard.php
Normal file
121
app/Livewire/Setup/Wizard.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
45
app/Livewire/Territori/TerritorioCestino.php
Normal file
45
app/Livewire/Territori/TerritorioCestino.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
74
app/Livewire/Territori/TerritorioCreate.php
Normal file
74
app/Livewire/Territori/TerritorioCreate.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
96
app/Livewire/Territori/TerritorioEdit.php
Normal file
96
app/Livewire/Territori/TerritorioEdit.php
Normal 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}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
105
app/Livewire/Territori/TerritorioIndex.php
Normal file
105
app/Livewire/Territori/TerritorioIndex.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
31
app/Livewire/Territori/TerritorioShow.php
Normal file
31
app/Livewire/Territori/TerritorioShow.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
70
app/Models/AnnoTeocratico.php
Normal file
70
app/Models/AnnoTeocratico.php
Normal 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
113
app/Models/Assegnazione.php
Normal 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
94
app/Models/Campagna.php
Normal 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');
|
||||
}
|
||||
}
|
||||
70
app/Models/Proclamatore.php
Normal file
70
app/Models/Proclamatore.php
Normal 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
56
app/Models/Setting.php
Normal 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
219
app/Models/Territorio.php
Normal 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
41
app/Models/Tipologia.php
Normal 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
42
app/Models/User.php
Normal 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
41
app/Models/Zona.php
Normal 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);
|
||||
}
|
||||
}
|
||||
27
app/Providers/AppServiceProvider.php
Normal file
27
app/Providers/AppServiceProvider.php
Normal 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
16
artisan
Normal 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
27
bootstrap/app.php
Normal 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
80
bootstrap/cache/packages.php
vendored
Executable 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
267
bootstrap/cache/services.php
vendored
Executable 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
5
bootstrap/providers.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
70
composer.json
Normal file
70
composer.json
Normal 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
8990
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
config/app.php
Normal file
24
config/app.php
Normal 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
35
config/auth.php
Normal 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
58
config/database.php
Normal 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
206
config/permission.php
Normal 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
23
config/session.php
Normal 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),
|
||||
|
||||
];
|
||||
43
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
43
database/migrations/0001_01_01_000000_create_users_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
65
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
65
database/migrations/0001_01_01_000001_create_cache_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
23
database/migrations/2024_01_01_000020_create_zone_table.php
Normal file
23
database/migrations/2024_01_01_000020_create_zone_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
}
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
16
database/seeders/DatabaseSeeder.php
Normal file
16
database/seeders/DatabaseSeeder.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
160
database/seeders/DevSeeder.php
Normal file
160
database/seeders/DevSeeder.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
database/seeders/RolesAndPermissionsSeeder.php
Normal file
55
database/seeders/RolesAndPermissionsSeeder.php
Normal 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
96
docker-compose.yml
Normal 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
35
docker/nginx/default.conf
Normal 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
65
docker/php/Dockerfile
Normal 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
17
docker/php/php.ini
Normal 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
13
docker/php/www.conf
Normal 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
2315
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
public/build/assets/app-BjqOcoUn.js
Normal file
6
public/build/assets/app-BjqOcoUn.js
Normal file
File diff suppressed because one or more lines are too long
1
public/build/assets/app-Cnyz0W61.css
Normal file
1
public/build/assets/app-Cnyz0W61.css
Normal file
File diff suppressed because one or more lines are too long
13
public/build/manifest.json
Normal file
13
public/build/manifest.json
Normal 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
21
public/index.php
Normal 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
1
resources/css/app.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
1
resources/js/app.js
Normal file
1
resources/js/app.js
Normal file
@@ -0,0 +1 @@
|
||||
import './bootstrap';
|
||||
3
resources/js/bootstrap.js
vendored
Normal file
3
resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
160
resources/views/components/layouts/app.blade.php
Normal file
160
resources/views/components/layouts/app.blade.php
Normal 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>
|
||||
17
resources/views/components/layouts/guest.blade.php
Normal file
17
resources/views/components/layouts/guest.blade.php
Normal 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>
|
||||
46
resources/views/livewire/assegnazioni/assegna.blade.php
Normal file
46
resources/views/livewire/assegnazioni/assegna.blade.php
Normal 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 }}">N° {{ $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>
|
||||
60
resources/views/livewire/assegnazioni/rientra.blade.php
Normal file
60
resources/views/livewire/assegnazioni/rientra.blade.php
Normal 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">N° {{ $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">Sì, 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>
|
||||
108
resources/views/livewire/audit-log.blade.php
Normal file
108
resources/views/livewire/audit-log.blade.php
Normal 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>
|
||||
38
resources/views/livewire/auth/login.blade.php
Normal file
38
resources/views/livewire/auth/login.blade.php
Normal 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>
|
||||
34
resources/views/livewire/campagne/campagna-form.blade.php
Normal file
34
resources/views/livewire/campagne/campagna-form.blade.php
Normal 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>
|
||||
67
resources/views/livewire/campagne/campagna-index.blade.php
Normal file
67
resources/views/livewire/campagne/campagna-index.blade.php
Normal 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>
|
||||
75
resources/views/livewire/campagne/campagna-show.blade.php
Normal file
75
resources/views/livewire/campagne/campagna-show.blade.php
Normal 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">N° {{ $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>
|
||||
139
resources/views/livewire/home.blade.php
Normal file
139
resources/views/livewire/home.blade.php
Normal 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">N° {{ $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">N° {{ $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">N° {{ $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>
|
||||
72
resources/views/livewire/privacy.blade.php
Normal file
72
resources/views/livewire/privacy.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
93
resources/views/livewire/registro.blade.php
Normal file
93
resources/views/livewire/registro.blade.php
Normal 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">N° {{ $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
Reference in New Issue
Block a user